Compare commits

..
22 Commits
Author SHA1 Message Date
André Koch-Kramer b542936c09 Release of version 4.2.9 2019-08-18 18:28:35 +02:00
Ahmad af4dbc00d7 Added likes_count to PostCommentAnswer tuple (#379) 2019-08-18 18:18:35 +02:00
Alexander Graf 0aa1ec7c76 Getting locations now requires --login
Closes #376.
2019-08-18 10:48:46 +02:00
Alexander Graf 75a729781d 194_top_x_of_user example: Fix sort order 2019-08-10 16:25:22 +02:00
Alexander Graf a88316224b Remove ',' after URL in "Checkpoint required" message
Closes #364.
2019-08-10 16:19:35 +02:00
Alexander Graf 87dc82d1b1 Travis: Skip test with Python 3.8 beta 2019-07-27 22:04:56 +02:00
Alexander Graf 6a3b05762e Fix missing directory error in some rare cases 2019-07-27 21:08:29 +02:00
Alexander Graf 6d5bb1e181 Release of Version 4.2.8 2019-05-27 18:26:10 +02:00
Alexander Graf 819057eb6f travis: use Pipfile for dependency pinning 2019-05-27 18:15:45 +02:00
Alexander Graf 5235cc0f6a Travis: test against Python 3.8 2019-05-27 18:08:17 +02:00
Alexander Graf fb06b4b7d6 Fix iPhone query rate control
Resolves #306.
2019-05-27 18:05:51 +02:00
Alexander Graf 1a382cd61c Fixes to unittests
- Fix type issue with unittest.SkipTest

- Fix query timestamp preservation
2019-05-17 21:58:51 +02:00
André Koch-Kramer 0345da71ec Release of version 4.2.7 2019-05-17 21:44:11 +02:00
André Koch-Kramer 749ff60639 Do not require rhx_gix in metadata
Closes #297.
2019-05-17 21:38:39 +02:00
Alexander Graf 3528e8d19b Release of Version 4.2.6 2019-05-08 22:18:29 +02:00
Alexander Graf 5fce6946ea AUR deploy: Do not deploy prereleases 2019-05-08 22:10:15 +02:00
Alexander Graf 692cbc000d Use stronger pylint configuration 2019-05-08 21:49:06 +02:00
Alexander Graf 102f27c4ef doc: Add versionadded directives to new Post properties 2019-05-08 21:31:40 +02:00
Alexander Graf 33d13c4b32 Fix import issue with Python 3.5
Closes #293.
2019-05-08 21:24:00 +02:00
Alexander Graf 408cf22c18 Fix target directory with tagged and highlights
Fixes #292.
2019-05-08 21:21:16 +02:00
André Koch-Kramer 93882d93e0 Release of version 4.2.5 2019-05-07 18:04:19 +02:00
Alexander Graf 8ef7b3242c Add trendy Badges to README.rst and index.rst 2019-05-07 18:01:13 +02:00
18 changed files with 558 additions and 87 deletions
+1
View File
@@ -1 +1,2 @@
__pycache__/ __pycache__/
.mypy_cache/
+1 -5
View File
@@ -62,9 +62,7 @@ confidence=
# --disable=W". # --disable=W".
disable=invalid-name, disable=invalid-name,
missing-docstring, missing-docstring,
line-too-long,
too-many-lines, too-many-lines,
bad-whitespace,
print-statement, print-statement,
parameter-unpacking, parameter-unpacking,
unpacking-in-except, unpacking-in-except,
@@ -83,7 +81,6 @@ disable=invalid-name,
useless-suppression, useless-suppression,
deprecated-pragma, deprecated-pragma,
use-symbolic-message-instead, use-symbolic-message-instead,
cyclic-import,
too-many-instance-attributes, too-many-instance-attributes,
too-many-public-methods, too-many-public-methods,
too-many-branches, too-many-branches,
@@ -91,7 +88,6 @@ disable=invalid-name,
too-many-locals, too-many-locals,
too-many-statements, too-many-statements,
no-else-return, no-else-return,
inconsistent-return-statements,
no-else-raise, no-else-raise,
unnecessary-pass, unnecessary-pass,
wildcard-import, wildcard-import,
@@ -362,7 +358,7 @@ indent-after-paren=4
indent-string=' ' indent-string=' '
# Maximum number of characters on a single line. # Maximum number of characters on a single line.
max-line-length=100 max-line-length=120
# Maximum number of lines in a module. # Maximum number of lines in a module.
max-module-lines=1000 max-module-lines=1000
+3 -4
View File
@@ -5,11 +5,10 @@ python:
- "3.6" - "3.6"
- "3.7" - "3.7"
install: install:
- pip install pylint~=2.3.1 requests mypy - pipenv sync --dev
- pip install -r docs/requirements.txt
script: script:
- python3 -m pylint instaloader - pylint instaloader
- python3 -m mypy -m instaloader - mypy -m instaloader
- make -C docs html SPHINXOPTS="-W -n" - make -C docs html SPHINXOPTS="-W -n"
addons: addons:
ssh_known_hosts: aur.archlinux.org ssh_known_hosts: aur.archlinux.org
+12
View File
@@ -0,0 +1,12 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
pylint = "*"
mypy = "*"
sphinx = "*"
[packages]
requests = "*"
Generated
+360
View File
@@ -0,0 +1,360 @@
{
"_meta": {
"hash": {
"sha256": "dfbaa1642735b07bdcc118cb0c9328817b1d558af626b696593db099df863b39"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
"sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
],
"version": "==2019.3.9"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"urllib3": {
"hashes": [
"sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1",
"sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"
],
"version": "==1.25.3"
}
},
"develop": {
"alabaster": {
"hashes": [
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
],
"version": "==0.7.12"
},
"astroid": {
"hashes": [
"sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4",
"sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4"
],
"version": "==2.2.5"
},
"babel": {
"hashes": [
"sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
"sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
],
"version": "==2.7.0"
},
"certifi": {
"hashes": [
"sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
"sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
],
"version": "==2019.3.9"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"version": "==0.14"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"imagesize": {
"hashes": [
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
"sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
],
"version": "==1.1.0"
},
"isort": {
"hashes": [
"sha256:c40744b6bc5162bbb39c1257fe298b7a393861d50978b565f3ccd9cb9de0182a",
"sha256:f57abacd059dc3bd666258d1efb0377510a89777fda3e3274e3c01f7c03ae22d"
],
"version": "==4.3.20"
},
"jinja2": {
"hashes": [
"sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013",
"sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"
],
"version": "==2.10.1"
},
"lazy-object-proxy": {
"hashes": [
"sha256:159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661",
"sha256:23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f",
"sha256:3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13",
"sha256:3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821",
"sha256:4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71",
"sha256:4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e",
"sha256:64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea",
"sha256:6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229",
"sha256:7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4",
"sha256:7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e",
"sha256:8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20",
"sha256:a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16",
"sha256:acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b",
"sha256:be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7",
"sha256:bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c",
"sha256:c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a",
"sha256:dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e",
"sha256:e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1"
],
"version": "==1.4.1"
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
],
"version": "==1.1.1"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"mypy": {
"hashes": [
"sha256:2afe51527b1f6cdc4a5f34fc90473109b22bf7f21086ba3e9451857cf11489e6",
"sha256:56a16df3e0abb145d8accd5dbb70eba6c4bd26e2f89042b491faa78c9635d1e2",
"sha256:5764f10d27b2e93c84f70af5778941b8f4aa1379b2430f85c827e0f5464e8714",
"sha256:5bbc86374f04a3aa817622f98e40375ccb28c4836f36b66706cf3c6ccce86eda",
"sha256:6a9343089f6377e71e20ca734cd8e7ac25d36478a9df580efabfe9059819bf82",
"sha256:6c9851bc4a23dc1d854d3f5dfd5f20a016f8da86bcdbb42687879bb5f86434b0",
"sha256:b8e85956af3fcf043d6f87c91cbe8705073fc67029ba6e22d3468bfee42c4823",
"sha256:b9a0af8fae490306bc112229000aa0c2ccc837b49d29a5c42e088c132a2334dd",
"sha256:bbf643528e2a55df2c1587008d6e3bda5c0445f1240dfa85129af22ae16d7a9a",
"sha256:c46ab3438bd21511db0f2c612d89d8344154c0c9494afc7fbc932de514cf8d15",
"sha256:f7a83d6bd805855ef83ec605eb01ab4fa42bcef254b13631e451cbb44914a9b0"
],
"index": "pypi",
"version": "==0.701"
},
"mypy-extensions": {
"hashes": [
"sha256:37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812",
"sha256:b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"
],
"version": "==0.4.1"
},
"packaging": {
"hashes": [
"sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
"sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
],
"version": "==19.0"
},
"pygments": {
"hashes": [
"sha256:36586500a94cd97f8c2c19d251cdb78868d1a822e0e491bfc1d811766aedb772",
"sha256:b437bc0d04dc36f1f5b3592985b3e0a3d0af46b7c39199231706d19a4ee63344"
],
"version": "==2.4.1"
},
"pylint": {
"hashes": [
"sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09",
"sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1"
],
"index": "pypi",
"version": "==2.3.1"
},
"pyparsing": {
"hashes": [
"sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a",
"sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"
],
"version": "==2.4.0"
},
"pytz": {
"hashes": [
"sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda",
"sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"
],
"version": "==2019.1"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"six": {
"hashes": [
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
],
"version": "==1.12.0"
},
"snowballstemmer": {
"hashes": [
"sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
"sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
],
"version": "==1.2.1"
},
"sphinx": {
"hashes": [
"sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b",
"sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce"
],
"index": "pypi",
"version": "==2.0.1"
},
"sphinxcontrib-applehelp": {
"hashes": [
"sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897",
"sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"
],
"version": "==1.0.1"
},
"sphinxcontrib-devhelp": {
"hashes": [
"sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34",
"sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"
],
"version": "==1.0.1"
},
"sphinxcontrib-htmlhelp": {
"hashes": [
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422",
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"
],
"version": "==1.0.2"
},
"sphinxcontrib-jsmath": {
"hashes": [
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
],
"version": "==1.0.1"
},
"sphinxcontrib-qthelp": {
"hashes": [
"sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20",
"sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"
],
"version": "==1.0.2"
},
"sphinxcontrib-serializinghtml": {
"hashes": [
"sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227",
"sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"
],
"version": "==1.1.3"
},
"typed-ast": {
"hashes": [
"sha256:132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b",
"sha256:18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d",
"sha256:2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a",
"sha256:3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462",
"sha256:4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee",
"sha256:4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a",
"sha256:5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4",
"sha256:6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649",
"sha256:7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a",
"sha256:8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f",
"sha256:8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7",
"sha256:912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760",
"sha256:b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18",
"sha256:c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616",
"sha256:c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd",
"sha256:ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21",
"sha256:eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93",
"sha256:f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb",
"sha256:f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7"
],
"markers": "implementation_name == 'cpython'",
"version": "==1.3.5"
},
"urllib3": {
"hashes": [
"sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1",
"sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"
],
"version": "==1.25.3"
},
"wrapt": {
"hashes": [
"sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533"
],
"version": "==1.11.1"
}
}
}
+36
View File
@@ -1,5 +1,41 @@
.. image:: https://raw.githubusercontent.com/instaloader/instaloader/master/docs/logo_heading.png .. image:: https://raw.githubusercontent.com/instaloader/instaloader/master/docs/logo_heading.png
.. badges-start
|travis| |pypi| |pyversion| |license| |aur| |contributors| |downloads| |saythanks|
.. |travis| image:: https://img.shields.io/travis/instaloader/instaloader/master.svg
:alt: Travis-CI Build Status
:target: https://travis-ci.org/instaloader/instaloader
.. |pypi| image:: https://img.shields.io/pypi/v/instaloader.svg
:alt: Instaloader PyPI Project Page
:target: https://pypi.org/project/instaloader/
.. |license| image:: https://img.shields.io/github/license/instaloader/instaloader.svg
:alt: MIT License
:target: https://github.com/instaloader/instaloader/blob/master/LICENSE
.. |pyversion| image:: https://img.shields.io/pypi/pyversions/instaloader.svg
:alt: Supported Python Versions
.. |contributors| image:: https://img.shields.io/github/contributors/instaloader/instaloader.svg
:alt: Contributor Count
:target: https://github.com/instaloader/instaloader/graphs/contributors
.. |saythanks| image:: https://img.shields.io/badge/Say%20Thanks-!-ff7efd.svg
:alt: Say Thanks!
:target: https://saythanks.io/to/aandergr
.. |aur| image:: https://img.shields.io/aur/version/instaloader.svg
:alt: Arch User Repository Package
:target: https://aur.archlinux.org/packages/instaloader/
.. |downloads| image:: https://img.shields.io/pypi/dm/instaloader.svg
:alt: PyPI Download Count
.. badges-end
:: ::
$ pip3 install instaloader $ pip3 install instaloader
+5 -1
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
VERSION=${1:1}
# do not deploy pre-releases
echo $VERSION | grep -qe "[abc]" && exit 0
cd $(dirname $0) cd $(dirname $0)
# decrypt and add ssh key # decrypt and add ssh key
@@ -12,7 +17,6 @@ ssh-add /tmp/AUR_openssh
git clone --depth 1 ssh://aur@aur.archlinux.org/instaloader.git git clone --depth 1 ssh://aur@aur.archlinux.org/instaloader.git
curl -sSfOJ https://codeload.github.com/instaloader/instaloader/tar.gz/$1 curl -sSfOJ https://codeload.github.com/instaloader/instaloader/tar.gz/$1
VERSION=${1:1}
HASH=$(sha512sum instaloader-$VERSION.tar.gz | cut -f1 -d " ") HASH=$(sha512sum instaloader-$VERSION.tar.gz | cut -f1 -d " ")
sed -e "s/{{version}}/$VERSION/g" -e "s/{{hash}}/$HASH/g" PKGBUILD.template > instaloader/PKGBUILD sed -e "s/{{version}}/$VERSION/g" -e "s/{{hash}}/$HASH/g" PKGBUILD.template > instaloader/PKGBUILD
sed -e "s/{{version}}/$VERSION/g" -e "s/{{hash}}/$HASH/g" .SRCINFO.template > instaloader/.SRCINFO sed -e "s/{{version}}/$VERSION/g" -e "s/{{hash}}/$HASH/g" .SRCINFO.template > instaloader/.SRCINFO
+2 -1
View File
@@ -89,6 +89,7 @@ Instaloader supports the following targets:
Posts tagged with a given location; the location ID is the numerical ID Posts tagged with a given location; the location ID is the numerical ID
Instagram labels a location with (e.g. Instagram labels a location with (e.g.
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/). \https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
Requires :option:`--login`.
.. versionadded:: 4.2 .. versionadded:: 4.2
@@ -122,7 +123,7 @@ downloads the pictures and videos and their captions. You can specify
- :option:`--geotags` - :option:`--geotags`
**download geotags** of each post and save them as **download geotags** of each post and save them as
Google Maps link, Google Maps link (requires :option:`--login`),
For a reference of all supported command line options, see For a reference of all supported command line options, see
:ref:`command-line-options`. :ref:`command-line-options`.
+1 -2
View File
@@ -61,8 +61,7 @@ What to Download of each Post
**Download geotags** when available. Geotags are stored as a text file with **Download geotags** when available. Geotags are stored as a text file with
the location's name and a Google Maps link. This requires an additional the location's name and a Google Maps link. This requires an additional
request to the Instagram server for each picture, which is why it is disabled request to the Instagram server for each picture. Requires :option:`--login`.
by default.
.. option:: --comments, -C .. option:: --comments, -C
+3 -1
View File
@@ -9,7 +9,9 @@ X_percentage = 10 # percentage of posts that should be downloaded
L = Instaloader() L = Instaloader()
profile = Profile.from_username(L.context, PROFILE) profile = Profile.from_username(L.context, PROFILE)
posts_sorted_by_likes = sorted(profile.get_posts(), key = lambda p: p.likes + p.comments) posts_sorted_by_likes = sorted(profile.get_posts(),
key=lambda p: p.likes + p.comments,
reverse=True)
for post in islice(posts_sorted_by_likes, ceil(profile.mediacount * X_percentage / 100)): for post in islice(posts_sorted_by_likes, ceil(profile.mediacount * X_percentage / 100)):
L.download_post(post, PROFILE) L.download_post(post, PROFILE)
+4
View File
@@ -15,6 +15,10 @@ Instaloader
**Instaloader** is a tool to download pictures (or videos) along with **Instaloader** is a tool to download pictures (or videos) along with
their captions and other metadata from Instagram. their captions and other metadata from Instagram.
.. include:: ../README.rst
:start-after: badges-start
:end-before: badges-end
With `Python <https://www.python.org/>`__ installed, do:: With `Python <https://www.python.org/>`__ installed, do::
$ pip3 install instaloader $ pip3 install instaloader
+3 -3
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.2.5rc1' __version__ = '4.2.9'
try: try:
@@ -15,5 +15,5 @@ else:
from .exceptions import * from .exceptions import *
from .instaloader import Instaloader from .instaloader import Instaloader
from .instaloadercontext import InstaloaderContext from .instaloadercontext import InstaloaderContext
from .structures import (Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation, Profile, Story, StoryItem, from .structures import (Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation, Profile, Story,
load_structure_from_file, save_structure_to_file) StoryItem, load_structure_from_file, save_structure_to_file)
+6 -2
View File
@@ -98,6 +98,9 @@ def _main(instaloader: Instaloader, targetlist: List[str],
else: else:
instaloader.interactive_login(username) instaloader.interactive_login(username)
instaloader.context.log("Logged in as %s." % username) instaloader.context.log("Logged in as %s." % username)
# since 4.2.9 login is required for geotags
if instaloader.download_geotags and not instaloader.context.is_logged_in:
instaloader.context.error("Warning: Use --login to download geotags of posts.")
# Try block for KeyboardInterrupt (save session on ^C) # Try block for KeyboardInterrupt (save session on ^C)
profiles = set() profiles = set()
anonymous_retry_profiles = set() anonymous_retry_profiles = set()
@@ -223,7 +226,8 @@ def main():
help="Download all followees of profile. Requires --login. " help="Download all followees of profile. Requires --login. "
"Consider using :feed rather than @yourself.") "Consider using :feed rather than @yourself.")
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.") g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
g_targets.add_argument('_location', nargs='*', metavar='%location_id', help="Download %%location_id.") g_targets.add_argument('_location', nargs='*', metavar='%location_id',
help="Download %%location_id. Requires --login.")
g_targets.add_argument('_feed', nargs='*', metavar=":feed", g_targets.add_argument('_feed', nargs='*', metavar=":feed",
help="Download pictures from your feed. Requires --login.") help="Download pictures from your feed. Requires --login.")
g_targets.add_argument('_stories', nargs='*', metavar=":stories", g_targets.add_argument('_stories', nargs='*', metavar=":stories",
@@ -258,7 +262,7 @@ def main():
help='Download geotags when available. Geotags are stored as a ' help='Download geotags when available. Geotags are stored as a '
'text file with the location\'s name and a Google Maps link. ' 'text file with the location\'s name and a Google Maps link. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each picture, which is why it is disabled by default.') 'server for each picture. Requires --login.')
g_post.add_argument('-C', '--comments', action='store_true', g_post.add_argument('-C', '--comments', action='store_true',
help='Download and update comments for each post. ' help='Download and update comments for each post. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
+49 -24
View File
@@ -13,14 +13,16 @@ from datetime import datetime, timezone
from functools import wraps from functools import wraps
from hashlib import md5 from hashlib import md5
from io import BytesIO from io import BytesIO
from typing import Any, Callable, ContextManager, Iterator, List, Optional, Set, Union, cast from pathlib import Path
from typing import Any, Callable, Iterator, List, Optional, Set, Union
import requests import requests
import urllib3 # type: ignore import urllib3 # type: ignore
from .exceptions import * from .exceptions import *
from .instaloadercontext import InstaloaderContext from .instaloadercontext import InstaloaderContext
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file, load_structure_from_file from .structures import (Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
save_structure_to_file, load_structure_from_file)
def get_default_session_filename(username: str) -> str: def get_default_session_filename(username: str) -> str:
@@ -45,8 +47,6 @@ def _requires_login(func: Callable) -> Callable:
if not instaloader.context.is_logged_in: if not instaloader.context.is_logged_in:
raise LoginRequiredException("--login=USERNAME required.") raise LoginRequiredException("--login=USERNAME required.")
return func(instaloader, *args, **kwargs) return func(instaloader, *args, **kwargs)
docstring_text = ":raises LoginRequiredException: If called without being logged in.\n"
call.__doc__ = call.__doc__ + docstring_text if call.__doc__ is not None else docstring_text
return call return call
@@ -244,6 +244,7 @@ class Instaloader:
filename += '.json.xz' filename += '.json.xz'
else: else:
filename += '.json' filename += '.json'
os.makedirs(os.path.dirname(filename), exist_ok=True)
save_structure_to_file(structure, filename) save_structure_to_file(structure, filename)
if isinstance(structure, (Post, StoryItem)): if isinstance(structure, (Post, StoryItem)):
# log 'json ' message when saving Post or StoryItem # log 'json ' message when saving Post or StoryItem
@@ -254,7 +255,8 @@ class Instaloader:
return {'id': comment.id, return {'id': comment.id,
'created_at': int(comment.created_at_utc.replace(tzinfo=timezone.utc).timestamp()), 'created_at': int(comment.created_at_utc.replace(tzinfo=timezone.utc).timestamp()),
'text': comment.text, 'text': comment.text,
'owner': comment.owner._asdict()} 'owner': comment.owner._asdict(),
'likes_count': comment.likes_count}
def _postcomment_asdict(comment): def _postcomment_asdict(comment):
return {**_postcommentanswer_asdict(comment), return {**_postcommentanswer_asdict(comment),
@@ -271,11 +273,13 @@ class Instaloader:
for x, y in zip(comments_list[:-1], comments_list[1:]): for x, y in zip(comments_list[:-1], comments_list[1:]):
if x['id'] != y['id']: if x['id'] != y['id']:
unique_comments_list.append(y) unique_comments_list.append(y)
elif combine_answers: else:
combined_answers = unique_comments_list[-1].get('answers') or list() unique_comments_list[-1]['likes_count'] = y.get('likes_count')
if 'answers' in y: if combine_answers:
combined_answers.extend(y['answers']) combined_answers = unique_comments_list[-1].get('answers') or list()
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers) if 'answers' in y:
combined_answers.extend(y['answers'])
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers)
return unique_comments_list return unique_comments_list
filename += '_comments.json' filename += '_comments.json'
try: try:
@@ -368,9 +372,11 @@ class Instaloader:
profile_pic_identifier, profile_pic_extension) profile_pic_identifier, profile_pic_extension)
content_length = profile_pic_response.headers.get('Content-Length', None) content_length = profile_pic_response.headers.get('Content-Length', None)
if os.path.isfile(filename) and (not self.context.is_logged_in or if os.path.isfile(filename) and (not self.context.is_logged_in or
content_length is not None and os.path.getsize(filename) >= int(content_length)): (content_length is not None and
os.path.getsize(filename) >= int(content_length))):
self.context.log(filename + ' already exists') self.context.log(filename + ' already exists')
return None return None
os.makedirs(os.path.dirname(filename), exist_ok=True)
self.context.write_raw(profile_pic_bytes if profile_pic_bytes else profile_pic_response, filename) self.context.write_raw(profile_pic_bytes if profile_pic_bytes else profile_pic_response, filename)
if date_object: if date_object:
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp())) os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
@@ -381,6 +387,7 @@ class Instaloader:
"""Saves internally stored :class:`requests.Session` object. """Saves internally stored :class:`requests.Session` object.
:param filename: Filename, or None to use default filename. :param filename: Filename, or None to use default filename.
:raises LoginRequiredException: If called without being logged in.
""" """
if filename is None: if filename is None:
assert self.context.username is not None assert self.context.username is not None
@@ -417,7 +424,8 @@ class Instaloader:
:raises InvalidArgumentException: If the provided username does not exist. :raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong. :raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed. :raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :meth:`Instaloader.two_factor_login`.""" :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`."""
self.context.login(user, passwd) self.context.login(user, passwd)
def two_factor_login(self, two_factor_code) -> None: def two_factor_login(self, two_factor_code) -> None:
@@ -430,13 +438,13 @@ class Instaloader:
.. versionadded:: 4.2""" .. versionadded:: 4.2"""
self.context.two_factor_login(two_factor_code) self.context.two_factor_login(two_factor_code)
def format_filename(self, item: Union[Post, StoryItem], target: Optional[str] = None): def format_filename(self, item: Union[Post, StoryItem], target: Optional[Union[str, Path]] = None):
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter. """Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
.. versionadded:: 4.1""" .. versionadded:: 4.1"""
return _PostPathFormatter(item).format(self.filename_pattern, target=target) return _PostPathFormatter(item).format(self.filename_pattern, target=target)
def download_post(self, post: Post, target: str) -> bool: def download_post(self, post: Post, target: Union[str, Path]) -> bool:
""" """
Download everything associated with one instagram post node, i.e. picture, caption and video. Download everything associated with one instagram post node, i.e. picture, caption and video.
@@ -504,6 +512,7 @@ class Instaloader:
To use this, one needs to be logged in To use this, one needs to be logged in
:param userids: List of user IDs to be processed in terms of downloading their stories, or None. :param userids: List of user IDs to be processed in terms of downloading their stories, or None.
:raises LoginRequiredException: If called without being logged in.
""" """
if not userids: if not userids:
@@ -540,6 +549,7 @@ class Instaloader:
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
or None if profile name should be used instead or None if profile name should be used instead
: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.
""" """
if not userids: if not userids:
@@ -563,7 +573,7 @@ class Instaloader:
if fast_update and not downloaded: if fast_update and not downloaded:
break break
def download_storyitem(self, item: StoryItem, target: str) -> bool: def download_storyitem(self, item: StoryItem, target: Union[str, Path]) -> bool:
"""Download one user story. """Download one user story.
:param item: Story item, as in story['items'] for story in :meth:`get_stories` :param item: Story item, as in story['items'] for story in :meth:`get_stories`
@@ -599,6 +609,7 @@ class Instaloader:
.. versionadded:: 4.1 .. versionadded:: 4.1
:param user: ID or Profile of the user whose highlights should get fetched. :param user: ID or Profile of the user whose highlights should get fetched.
:raises LoginRequiredException: If called without being logged in.
""" """
userid = user if isinstance(user, int) else user.userid userid = user if isinstance(user, int) else user.userid
@@ -628,6 +639,7 @@ class Instaloader:
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
or None if profile name and the highlights' titles should be used instead or None if profile name and the highlights' titles should be used instead
: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.
""" """
for user_highlight in self.get_highlights(user): for user_highlight in self.get_highlights(user):
name = user_highlight.owner_username name = user_highlight.owner_username
@@ -640,10 +652,11 @@ class Instaloader:
continue continue
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True) self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
count += 1 count += 1
with self.context.error_catcher('Download highlights \"{}\" from user {}'.format(user_highlight.title, name)): with self.context.error_catcher('Download highlights \"{}\" from user {}'.format(user_highlight.title,
name)):
downloaded = self.download_storyitem(item, filename_target downloaded = self.download_storyitem(item, filename_target
if filename_target if filename_target
else '{}/{}'.format(name, user_highlight.title)) else Path(name) / Path(user_highlight.title))
if fast_update and not downloaded: if fast_update and not downloaded:
break break
@@ -652,6 +665,7 @@ class Instaloader:
"""Get Posts of the user's feed. """Get Posts of the user's feed.
:return: Iterator over Posts of the user's feed. :return: Iterator over Posts of the user's feed.
:raises LoginRequiredException: If called without being logged in.
""" """
data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"] data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]
@@ -685,6 +699,7 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
: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 post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be downloaded
:raises LoginRequiredException: If called without being logged in.
""" """
self.context.log("Retrieving pictures from your feed...") self.context.log("Retrieving pictures from your feed...")
count = 1 count = 1
@@ -710,6 +725,7 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
: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 post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be downloaded
:raises LoginRequiredException: If called without being logged in.
""" """
self.context.log("Retrieving saved posts...") self.context.log("Retrieving saved posts...")
count = 1 count = 1
@@ -727,12 +743,17 @@ class Instaloader:
if fast_update and not downloaded: if fast_update and not downloaded:
break break
@_requires_login
def get_location_posts(self, location: str) -> Iterator[Post]: def get_location_posts(self, location: str) -> Iterator[Post]:
"""Get Posts which are listed by Instagram for a given Location. """Get Posts which are listed by Instagram for a given Location.
:return: Iterator over Posts of a location's posts :return: Iterator over Posts of a location's posts
:raises LoginRequiredException: If called without being logged in.
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram)
""" """
has_next_page = True has_next_page = True
end_cursor = None end_cursor = None
@@ -747,6 +768,7 @@ class Instaloader:
has_next_page = location_data['page_info']['has_next_page'] has_next_page = location_data['page_info']['has_next_page']
end_cursor = location_data['page_info']['end_cursor'] end_cursor = location_data['page_info']['end_cursor']
@_requires_login
def download_location(self, location: str, def download_location(self, location: str,
max_count: Optional[int] = None, max_count: Optional[int] = None,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
@@ -762,8 +784,12 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
:param post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be 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
:raises LoginRequiredException: If called without being logged in.
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram)
""" """
self.context.log("Retrieving pictures for location {}...".format(location)) self.context.log("Retrieving pictures for location {}...".format(location))
count = 1 count = 1
@@ -785,13 +811,14 @@ class Instaloader:
"""Get Posts which are worthy of exploring suggested by Instagram. """Get Posts which are worthy of exploring suggested by Instagram.
:return: Iterator over Posts of the user's suggested posts. :return: Iterator over Posts of the user's suggested posts.
:raises LoginRequiredException: If called without being logged in.
""" """
data = self.context.get_json('explore/', {}) data = self.context.get_json('explore/', {})
yield from (Post(self.context, node) yield from (Post(self.context, node)
for node in self.context.graphql_node_list("df0dcc250c2b18d9fd27c5581ef33c7c", for node in self.context.graphql_node_list("df0dcc250c2b18d9fd27c5581ef33c7c",
{}, 'https://www.instagram.com/explore/', {}, 'https://www.instagram.com/explore/',
lambda d: d['data']['user']['edge_web_discover_media'], lambda d: d['data']['user']['edge_web_discover_media'],
data['rhx_gis'])) data.get('rhx_gis')))
def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]: def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]:
"""Get Posts associated with a #hashtag.""" """Get Posts associated with a #hashtag."""
@@ -846,8 +873,6 @@ class Instaloader:
"""Download all posts where a profile is tagged. """Download all posts where a profile is tagged.
.. versionadded:: 4.1""" .. versionadded:: 4.1"""
if target is None:
target = profile.username + '/:tagged'
self.context.log("Retrieving tagged posts for profile {}.".format(profile.username)) self.context.log("Retrieving tagged posts for profile {}.".format(profile.username))
count = 1 count = 1
for post in profile.get_tagged_posts(): for post in profile.get_tagged_posts():
@@ -856,7 +881,7 @@ class Instaloader:
if post_filter is not None and not post_filter(post): if post_filter is not None and not post_filter(post):
self.context.log('<{} skipped>'.format(post)) self.context.log('<{} skipped>'.format(post))
with self.context.error_catcher('Download tagged {}'.format(profile.username)): with self.context.error_catcher('Download tagged {}'.format(profile.username)):
downloaded = self.download_post(post, target) downloaded = self.download_post(post, target if target else Path(profile.username) / Path(':tagged'))
if fast_update and not downloaded: if fast_update and not downloaded:
break break
@@ -953,11 +978,11 @@ class Instaloader:
def _error_raiser(_str): def _error_raiser(_str):
yield yield
error_handler = cast(Callable[[Optional[str]], ContextManager[None]], # error_handler type is Callable[[Optional[str]], ContextManager[None]] (not supported with Python 3.5)
_error_raiser if raise_errors else self.context.error_catcher) error_handler = _error_raiser if raise_errors else self.context.error_catcher
for profile in profiles: for profile in profiles:
with error_handler(profile.username): with error_handler(profile.username): # type: ignore
profile_name = profile.username profile_name = profile.username
# Download profile picture # Download profile picture
+13 -7
View File
@@ -181,7 +181,8 @@ class InstaloaderContext:
:raises InvalidArgumentException: If the provided username does not exist. :raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong. :raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed. :raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :meth:`Instaloader.two_factor_login`.""" :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`."""
import http.client import http.client
# pylint:disable=protected-access # pylint:disable=protected-access
http.client._MAXHEADERS = 200 http.client._MAXHEADERS = 200
@@ -211,7 +212,7 @@ class InstaloaderContext:
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.") raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
if resp_json.get('checkpoint_url'): if resp_json.get('checkpoint_url'):
raise ConnectionException("Login: Checkpoint required. Point your browser to " raise ConnectionException("Login: Checkpoint required. Point your browser to "
"https://www.instagram.com{}, " "https://www.instagram.com{} - "
"follow the instructions, then retry.".format(resp_json.get('checkpoint_url'))) "follow the instructions, then retry.".format(resp_json.get('checkpoint_url')))
if resp_json['status'] != 'ok': if resp_json['status'] != 'ok':
if 'message' in resp_json: if 'message' in resp_json:
@@ -278,7 +279,7 @@ class InstaloaderContext:
def _graphql_request_count_per_sliding_window(self, query_hash: str) -> int: def _graphql_request_count_per_sliding_window(self, query_hash: str) -> int:
"""Return how many GraphQL requests can be done within the sliding window.""" """Return how many GraphQL requests can be done within the sliding window."""
if self.is_logged_in: if self.is_logged_in:
max_reqs = {'1cb6ec562846122743b61e492c85999f': 20, '33ba35852cb50da46f5b5e889df7d159': 20} max_reqs = {'1cb6ec562846122743b61e492c85999f': 20, '33ba35852cb50da46f5b5e889df7d159': 20, 'iphone': 100}
else: else:
max_reqs = {'1cb6ec562846122743b61e492c85999f': 200, '33ba35852cb50da46f5b5e889df7d159': 200} max_reqs = {'1cb6ec562846122743b61e492c85999f': 200, '33ba35852cb50da46f5b5e889df7d159': 200}
return max_reqs.get(query_hash) or min(max_reqs.values()) return max_reqs.get(query_hash) or min(max_reqs.values())
@@ -345,11 +346,14 @@ class InstaloaderContext:
:raises ConnectionException: When query repeatedly failed. :raises ConnectionException: When query repeatedly failed.
""" """
is_graphql_query = 'query_hash' in params and 'graphql/query' in path is_graphql_query = 'query_hash' in params and 'graphql/query' in path
is_iphone_query = host == 'i.instagram.com'
sess = session if session else self._session sess = session if session else self._session
try: try:
self.do_sleep() self.do_sleep()
if is_graphql_query: if is_graphql_query:
self._ratecontrol_graphql_query(params['query_hash']) self._ratecontrol_graphql_query(params['query_hash'])
if is_iphone_query:
self._ratecontrol_graphql_query('iphone')
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False) resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
while resp.is_redirect: while resp.is_redirect:
redirect_url = resp.headers['location'] redirect_url = resp.headers['location']
@@ -388,8 +392,10 @@ class InstaloaderContext:
raise ConnectionException(error_string) from err raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False) self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
try: try:
if isinstance(err, TooManyRequestsException): if is_graphql_query and isinstance(err, TooManyRequestsException):
self._ratecontrol_graphql_query(params['query_hash'], untracked_queries=True) self._ratecontrol_graphql_query(params['query_hash'], untracked_queries=True)
if is_iphone_query and isinstance(err, TooManyRequestsException):
self._ratecontrol_graphql_query('iphone', untracked_queries=True)
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1) return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
except KeyboardInterrupt: except KeyboardInterrupt:
self.error("[skipped by user]", repeat_at_end=False) self.error("[skipped by user]", repeat_at_end=False)
@@ -529,6 +535,6 @@ class InstaloaderContext:
# At the moment, rhx_gis seems to be required for anonymous requests only. By returning None when logged # At the moment, rhx_gis seems to be required for anonymous requests only. By returning None when logged
# in, we can save the root_rhx_gis lookup query. # in, we can save the root_rhx_gis lookup query.
return None return None
if not self._root_rhx_gis: if self._root_rhx_gis is None:
self._root_rhx_gis = self.get_json('', {})['rhx_gis'] self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
return self._root_rhx_gis return self._root_rhx_gis or None
+33 -13
View File
@@ -17,11 +17,12 @@ PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail." PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail."
PostSidecarNode.video_url.__doc__ = "URL of video or None." PostSidecarNode.video_url.__doc__ = "URL of video or None."
PostCommentAnswer = namedtuple('PostCommentAnswer', ['id', 'created_at_utc', 'text', 'owner']) PostCommentAnswer = namedtuple('PostCommentAnswer', ['id', 'created_at_utc', 'text', 'owner', 'likes_count'])
PostCommentAnswer.id.__doc__ = "ID number of comment." PostCommentAnswer.id.__doc__ = "ID number of comment."
PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)." PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)."
PostCommentAnswer.text.__doc__ = "Comment text." PostCommentAnswer.text.__doc__ = "Comment text."
PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment." PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment."
PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment."
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
for field in PostCommentAnswer._fields: for field in PostCommentAnswer._fields:
@@ -132,7 +133,7 @@ class Post:
if not self._full_metadata_dict: if not self._full_metadata_dict:
pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={}) pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={})
self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media'] self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media']
self._rhx_gis_str = pic_json['rhx_gis'] self._rhx_gis_str = pic_json.get('rhx_gis')
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
@@ -144,9 +145,8 @@ class Post:
return self._full_metadata_dict return self._full_metadata_dict
@property @property
def _rhx_gis(self) -> str: def _rhx_gis(self) -> Optional[str]:
self._obtain_metadata() self._obtain_metadata()
assert self._rhx_gis_str is not None
return self._rhx_gis_str return self._rhx_gis_str
def _field(self, *keys) -> Any: def _field(self, *keys) -> Any:
@@ -190,12 +190,16 @@ class Post:
@property @property
def date_local(self) -> datetime: def date_local(self) -> datetime:
"""Timestamp when the post was created (local time zone).""" """Timestamp when the post was created (local time zone)."""
return datetime.fromtimestamp(self._node["date"] if "date" in self._node else self._node["taken_at_timestamp"]) return datetime.fromtimestamp(self._node["date"]
if "date" in self._node
else self._node["taken_at_timestamp"])
@property @property
def date_utc(self) -> datetime: def date_utc(self) -> datetime:
"""Timestamp when the post was created (UTC).""" """Timestamp when the post was created (UTC)."""
return datetime.utcfromtimestamp(self._node["date"] if "date" in self._node else self._node["taken_at_timestamp"]) return datetime.utcfromtimestamp(self._node["date"]
if "date" in self._node
else self._node["taken_at_timestamp"])
@property @property
def date(self) -> datetime: def date(self) -> datetime:
@@ -257,7 +261,9 @@ class Post:
@property @property
def pcaption(self) -> str: def pcaption(self) -> str:
"""Printable caption, useful as a format specifier for --filename-pattern.""" """Printable caption, useful as a format specifier for --filename-pattern.
.. versionadded:: 4.2.6"""
def _elliptify(caption): def _elliptify(caption):
pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip() pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip()
return (pcaption[:30] + u"\u2026") if len(pcaption) > 31 else pcaption return (pcaption[:30] + u"\u2026") if len(pcaption) > 31 else pcaption
@@ -286,14 +292,18 @@ class Post:
@property @property
def video_view_count(self) -> Optional[int]: def video_view_count(self) -> Optional[int]:
"""View count of the video, or None.""" """View count of the video, or None.
.. versionadded:: 4.2.6"""
if self.is_video: if self.is_video:
return self._field('video_view_count') return self._field('video_view_count')
return None return None
@property @property
def video_duration(self) -> Optional[float]: def video_duration(self) -> Optional[float]:
"""Duration of the video in seconds, or None.""" """Duration of the video in seconds, or None.
.. versionadded:: 4.2.6"""
if self.is_video: if self.is_video:
return self._field('video_duration') return self._field('video_duration')
return None return None
@@ -331,7 +341,8 @@ class Post:
return PostCommentAnswer(id=int(node['id']), return PostCommentAnswer(id=int(node['id']),
created_at_utc=datetime.utcfromtimestamp(node['created_at']), created_at_utc=datetime.utcfromtimestamp(node['created_at']),
text=node['text'], text=node['text'],
owner=Profile(self._context, node['owner'])) owner=Profile(self._context, node['owner']),
likes_count=node['edge_liked_by']['count'])
def _postcommentanswers(node): def _postcommentanswers(node):
if 'edge_threaded_comments' not in node: if 'edge_threaded_comments' not in node:
@@ -399,10 +410,17 @@ class Post:
@property @property
def location(self) -> Optional[PostLocation]: def location(self) -> Optional[PostLocation]:
"""If the Post has a location, returns PostLocation namedtuple with fields 'id', 'lat' and 'lng' and 'name'.""" """
If the Post has a location, returns PostLocation namedtuple with fields 'id', 'lat' and 'lng' and 'name'.
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram), return None if not logged-in.
"""
loc = self._field("location") loc = self._field("location")
if self._location or not loc: if self._location or not loc:
return self._location return self._location
if not self._context.is_logged_in:
return None
location_id = int(loc['id']) location_id = int(loc['id'])
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')): if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
loc = self._context.get_json("explore/locations/{0}/".format(location_id), loc = self._context.get_json("explore/locations/{0}/".format(location_id),
@@ -444,6 +462,7 @@ class Profile:
self._context = context self._context = context
self._has_public_story = None # type: Optional[bool] self._has_public_story = None # type: Optional[bool]
self._node = node self._node = node
self._has_full_metadata = False
self._rhx_gis = None self._rhx_gis = None
self._iphone_struct_ = None self._iphone_struct_ = None
if 'iphone_struct' in node: if 'iphone_struct' in node:
@@ -504,10 +523,11 @@ class Profile:
def _obtain_metadata(self): def _obtain_metadata(self):
try: try:
if not self._rhx_gis: if not self._has_full_metadata:
metadata = self._context.get_json('{}/'.format(self.username), params={}) metadata = self._context.get_json('{}/'.format(self.username), params={})
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user'] self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
self._rhx_gis = metadata['rhx_gis'] self._has_full_metadata = True
self._rhx_gis = metadata.get('rhx_gis')
except (QueryReturnedNotFoundException, KeyError) as err: except (QueryReturnedNotFoundException, KeyError) as err:
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err
+1
View File
@@ -60,6 +60,7 @@ setup(
'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet', 'Topic :: Internet',
'Topic :: Multimedia :: Graphics' 'Topic :: Multimedia :: Graphics'
+25 -24
View File
@@ -5,6 +5,7 @@ import shutil
import tempfile import tempfile
import unittest import unittest
from itertools import islice from itertools import islice
from typing import Dict, List
import instaloader import instaloader
@@ -22,7 +23,7 @@ EMPTY_PROFILE = "not_public"
EMPTY_PROFILE_ID = 1928659031 EMPTY_PROFILE_ID = 1928659031
# Preserve query timestamps (rate control) between tests to not get rate limited # Preserve query timestamps (rate control) between tests to not get rate limited
instaloadercontext_query_timestamps = dict() instaloadercontext_query_timestamps = dict() # type: Dict[str, List[float]]
class TestInstaloaderAnonymously(unittest.TestCase): class TestInstaloaderAnonymously(unittest.TestCase):
@@ -35,11 +36,13 @@ class TestInstaloaderAnonymously(unittest.TestCase):
download_comments=True, download_comments=True,
save_metadata=True) save_metadata=True)
self.L.context.raise_all_errors = True self.L.context.raise_all_errors = True
self.L.context.query_timestamps = instaloadercontext_query_timestamps.copy() # pylint:disable=protected-access
self.L.context._graphql_query_timestamps = instaloadercontext_query_timestamps.copy()
def tearDown(self): def tearDown(self):
# pylint:disable=global-statement,protected-access
global instaloadercontext_query_timestamps global instaloadercontext_query_timestamps
instaloadercontext_query_timestamps = self.L.context.query_timestamps.copy() instaloadercontext_query_timestamps = self.L.context._graphql_query_timestamps.copy()
self.L.close() self.L.close()
os.chdir('/') os.chdir('/')
print("Removing {}".format(self.dir)) print("Removing {}".format(self.dir))
@@ -53,16 +56,17 @@ class TestInstaloaderAnonymously(unittest.TestCase):
self.assertTrue(post.date_utc < previous_post.date_utc) self.assertTrue(post.date_utc < previous_post.date_utc)
previous_post = post previous_post = post
@unittest.SkipTest @unittest.skip('')
def test_public_profile_download(self): def test_public_profile_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic=False, fast_update=True) profiles = {self.L.check_profile_id(PUBLIC_PROFILE)}
self.L.download_profile(PUBLIC_PROFILE, profile_pic=False, fast_update=True) self.L.download_profiles(profiles, profile_pic=False, fast_update=True, raise_errors=True)
self.L.download_profiles(profiles, profile_pic=False, fast_update=True, raise_errors=True)
def test_public_profile_paging(self): def test_public_profile_paging(self):
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts()) self.post_paging_test(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts())
def test_profile_pic_download(self): def test_profile_pic_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True) self.L.download_profiles({self.L.check_profile_id(PUBLIC_PROFILE)}, posts=False, raise_errors=True)
def test_hashtag_download(self): def test_hashtag_download(self):
self.L.download_hashtag(HASHTAG, NORMAL_MAX_COUNT) self.L.download_hashtag(HASHTAG, NORMAL_MAX_COUNT)
@@ -73,15 +77,6 @@ class TestInstaloaderAnonymously(unittest.TestCase):
if count == PAGING_MAX_COUNT: if count == PAGING_MAX_COUNT:
break break
def test_location_download(self):
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
def test_location_paging(self):
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_get_id_by_username(self): def test_get_id_by_username(self):
self.assertEqual(PUBLIC_PROFILE_ID, self.assertEqual(PUBLIC_PROFILE_ID,
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid) instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
@@ -116,13 +111,13 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
super().setUp() super().setUp()
self.L.load_session_from_file(OWN_USERNAME) self.L.load_session_from_file(OWN_USERNAME)
@unittest.SkipTest @unittest.skip('')
def test_stories_download(self): def test_stories_download(self):
self.L.download_stories() self.L.download_stories()
@unittest.SkipTest @unittest.skip('')
def test_private_profile_download(self): def test_private_profile_download(self):
self.L.download_profile(PRIVATE_PROFILE, download_stories=True) self.L.download_profiles({self.L.check_profile_id(PRIVATE_PROFILE)}, stories=True, raise_errors=True)
def test_stories_paging(self): def test_stories_paging(self):
for user_story in self.L.get_stories(): for user_story in self.L.get_stories():
@@ -141,9 +136,6 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
def test_private_profile_paging(self): def test_private_profile_paging(self):
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts()) self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
def test_profile_pic_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
def test_feed_download(self): def test_feed_download(self):
self.L.download_feed_posts(NORMAL_MAX_COUNT) self.L.download_feed_posts(NORMAL_MAX_COUNT)
@@ -167,8 +159,8 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
def test_followees_and_stories(self): def test_followees_and_stories(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME) profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in islice(profile.get_followees(), PAGING_MAX_COUNT): followees = set(islice(profile.get_followees(), PAGING_MAX_COUNT))
self.L.download_profile(f.username, profile_pic=False, download_stories_only=True) self.L.download_profiles(followees, profile_pic=False, stories=True, posts=False, raise_errors=True)
def test_get_followees(self): def test_get_followees(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME) profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
@@ -192,6 +184,15 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
if count == PAGING_MAX_COUNT: if count == PAGING_MAX_COUNT:
break break
def test_location_download(self):
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
def test_location_paging(self):
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
print(post)
if count == PAGING_MAX_COUNT:
break
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()