mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-23 17:09:37 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e232c82d5e | ||
|
|
601dd0b106 | ||
|
|
4c20b573e2 | ||
|
|
db63e5c7e5 | ||
|
|
e21d34124d | ||
|
|
66b3ec6928 | ||
|
|
f7d82495bd | ||
|
|
b0a430051e | ||
|
|
8b56b90e72 | ||
|
|
0c93e63615 | ||
|
|
65025e61d9 | ||
|
|
11baf493db | ||
|
|
2345d90e0e | ||
|
|
71b09aed0f | ||
|
|
fd6d8542c9 | ||
|
|
df6fbbee76 | ||
|
|
1661bf7722 | ||
|
|
8f9723cf62 | ||
|
|
e1567a0342 | ||
|
|
610b6242a0 | ||
|
|
d90f90988a | ||
|
|
d3c747a306 | ||
|
|
225353dc21 | ||
|
|
ed499cb49c | ||
|
|
1c13d5a30b | ||
|
|
2fd1422619 | ||
|
|
c818165db4 | ||
|
|
26a78463f1 | ||
|
|
689dd3613b | ||
|
|
27b7c98be2 | ||
|
|
72d86533f2 | ||
|
|
7bb08a220a | ||
|
|
03aabc52bb | ||
|
|
9d4df0d5fb | ||
|
|
bf2f4f16ed | ||
|
|
1a3712d112 | ||
|
|
65462b01ec | ||
|
|
9857c5fa24 | ||
|
|
83f013f6ca | ||
|
|
e7437310ea | ||
|
|
dcba25a40e | ||
|
|
5e4b5fd877 | ||
|
|
657e76e2d9 | ||
|
|
78023b3c98 | ||
|
|
ab67ca30e5 | ||
|
|
0e13c73b62 | ||
|
|
b98e9941ae | ||
|
|
c9e4f68597 | ||
|
|
1338cf0fcd | ||
|
|
83a0f52c60 | ||
|
|
5c5c633bee | ||
|
|
dc458a3f3c | ||
|
|
08a359408c | ||
|
|
a794089c4f | ||
|
|
c76385bde2 | ||
|
|
b5bf8a0560 | ||
|
|
2790342a09 | ||
|
|
e725ca2640 |
@@ -24,4 +24,4 @@ If applicable, add error messages and tracebacks to help explain your problem.
|
|||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
||||||
(please also see https://instaloader.github.io/contributing.html for how to report a bug)
|
<!-- please also see https://instaloader.github.io/contributing.html for how to report a bug -->
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Contributing to Instaloader
|
||||||
|
url: https://instaloader.github.io/contributing.html
|
||||||
|
about: Guidelines for how to report an issue.
|
||||||
|
- name: Instaloader Troubleshooting
|
||||||
|
url: https://instaloader.github.io/troubleshooting.html
|
||||||
|
about: Many common problems and their workarounds are described here.
|
||||||
|
- name: Instaloader Documentation
|
||||||
|
url: https://instaloader.github.io/
|
||||||
|
about: Many questions might be already answered here.
|
||||||
@@ -15,10 +15,10 @@ A clear and concise description of what you want to happen.
|
|||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**If the feature request is accepted, would you be willing to submit a PR?**
|
**If the feature request is accepted, would you be willing to submit a PR?**
|
||||||
|
Yes / No
|
||||||
Yes / No _(Help can be provided if you need assistance submitting a PR)_
|
<!-- Help can be provided if you need assistance submitting a PR -->
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the feature request here.
|
Add any other context about the feature request here.
|
||||||
|
|
||||||
(please also see https://instaloader.github.io/contributing.html for how to suggest a feature)
|
<!-- please also see https://instaloader.github.io/contributing.html for how to suggest a feature -->
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ labels: question
|
|||||||
---
|
---
|
||||||
|
|
||||||
Your question here...
|
Your question here...
|
||||||
|
|
||||||
|
<!-- Please see our documentation: https://instaloader.github.io/ -->
|
||||||
|
|
||||||
|
<!-- Basic questions regarding Instaloader's usage might be more appropriate to ask on Stack Overflow -->
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Please describe:
|
||||||
|
|
||||||
|
- A motivation for this change, e.g.
|
||||||
|
- Fixes # .
|
||||||
|
- More general: What problem does the pull request solve?
|
||||||
|
|
||||||
|
- The changes proposed in this pull request
|
||||||
|
|
||||||
|
- The completeness of this change
|
||||||
|
- Is it just a proof of concept?
|
||||||
|
- Is the documentation updated (if appropriate)?
|
||||||
|
- Do you consider it ready to be merged or is it a draft?
|
||||||
|
- Can we help you at some point?
|
||||||
|
-->
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: PyLint and MyPy
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: PyLint and MyPy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.8]
|
||||||
|
steps:
|
||||||
|
- name: Checkout Instaloader Repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed for building docs
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install pipenv==2020.6.2
|
||||||
|
pipenv sync --dev
|
||||||
|
- name: PyLint
|
||||||
|
run: pipenv run pylint instaloader
|
||||||
|
- name: MyPy
|
||||||
|
run: pipenv run mypy -m instaloader
|
||||||
|
- name: Build Documentation
|
||||||
|
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
|
||||||
@@ -2,14 +2,25 @@ name: Mark stale issues and pull requests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "15 16 * * *"
|
- cron: "10 14 * * *"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
|
|
||||||
|
name: Mark stale issues and pull requests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/stale@v3
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
only-labels: 'question'
|
||||||
|
stale-issue-message: 'There has been no activity on this question for an extended period of time. This issue will be closed after further 14 days of inactivity.'
|
||||||
|
stale-issue-label: 'stale'
|
||||||
|
exempt-issue-labels: 'leave open'
|
||||||
|
days-before-stale: 15
|
||||||
|
days-before-close: -1
|
||||||
|
remove-stale-when-updated: false
|
||||||
- uses: actions/stale@v1
|
- uses: actions/stale@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -21,4 +32,3 @@ jobs:
|
|||||||
exempt-pr-label: 'leave open'
|
exempt-pr-label: 'leave open'
|
||||||
days-before-stale: 90
|
days-before-stale: 90
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
operations-per-run: 7
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on: [push, pull_request]
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
name: Windows EXE build
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Instaloader repository
|
- name: Checkout Instaloader repository
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ deploy:
|
|||||||
repo: instaloader/instaloader.github.io
|
repo: instaloader/instaloader.github.io
|
||||||
target-branch: master
|
target-branch: master
|
||||||
on:
|
on:
|
||||||
tags: true
|
branch: master
|
||||||
python: 3.6
|
python: 3.6
|
||||||
- provider: script
|
- provider: script
|
||||||
script: deploy/arch/deploy.sh $TRAVIS_TAG
|
script: deploy/arch/deploy.sh $TRAVIS_TAG
|
||||||
|
|||||||
@@ -8,33 +8,18 @@ import sys
|
|||||||
# copy the required files into repo root
|
# copy the required files into repo root
|
||||||
shutil.copy('docs/favicon.ico', '.')
|
shutil.copy('docs/favicon.ico', '.')
|
||||||
shutil.copy('deploy/windows/instaloader.spec', '.')
|
shutil.copy('deploy/windows/instaloader.spec', '.')
|
||||||
shutil.unpack_archive('deploy/windows/ps', '.', 'xztar')
|
|
||||||
|
|
||||||
code = """
|
code = """
|
||||||
|
import contextlib
|
||||||
import psutil
|
import psutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
def __main():
|
def __main():
|
||||||
grandparentpid = psutil.Process(os.getppid()).ppid()
|
with contextlib.suppress(AttributeError, psutil.Error):
|
||||||
grandparentpidsearchstring = ' ' + str(grandparentpid) + ' '
|
if psutil.Process().parent().parent().name() == "explorer.exe":
|
||||||
if hasattr(sys, "_MEIPASS"):
|
subprocess.Popen("powershell -NoExit -Command \\\"& {0}\\\"".format(sys.argv[0]))
|
||||||
ps = os.path.join(sys._MEIPASS, 'tasklist.exe')
|
return
|
||||||
else:
|
main()
|
||||||
ps = 'tasklist'
|
|
||||||
popen = subprocess.Popen(ps, stdout=subprocess.PIPE, universal_newlines=True)
|
|
||||||
for examine in iter(popen.stdout.readline, ""):
|
|
||||||
if grandparentpidsearchstring in examine:
|
|
||||||
pname = examine
|
|
||||||
break
|
|
||||||
popen.stdout.close()
|
|
||||||
return_code = popen.wait()
|
|
||||||
if return_code:
|
|
||||||
raise subprocess.CalledProcessError(return_code, ps)
|
|
||||||
if pname[0:12] == 'explorer.exe':
|
|
||||||
subprocess.Popen("cmd /K \\\"{0}\\\"".format(os.path.splitext(os.path.basename(sys.argv[0]))[0]))
|
|
||||||
else:
|
|
||||||
main()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
__main()
|
__main()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ block_cipher = None
|
|||||||
|
|
||||||
a = Analysis(['__main__.py'],
|
a = Analysis(['__main__.py'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[('tasklist.exe', '.'), ('framedyn.dll', '.')],
|
datas=[],
|
||||||
hiddenimports=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
|
|||||||
Binary file not shown.
Vendored
+8
-5
@@ -18,14 +18,17 @@ code {
|
|||||||
padding: 0.7em;
|
padding: 0.7em;
|
||||||
color: #fff; }
|
color: #fff; }
|
||||||
.highlight .c1 {
|
.highlight .c1 {
|
||||||
color: #008d06; }
|
color: #666; }
|
||||||
.highlight .k, .highlight .kn, .highlight .ow {
|
.highlight .k, .highlight .kn, .highlight .ow {
|
||||||
color: #f48400;
|
color: #008d06; }
|
||||||
font-weight: bold; }
|
.highlight .nb, .highlight .ne, .highlight .nf, .highlight .vm {
|
||||||
.highlight .nb {
|
|
||||||
color: #f48400; }
|
color: #f48400; }
|
||||||
.highlight .s1, .highlight .s2, .highlight .mi {
|
.highlight .s1, .highlight .s2, .highlight .sa, .highlight .se, .highlight .si, .highlight .mi {
|
||||||
color: #ff7efd; }
|
color: #ff7efd; }
|
||||||
|
.highlight .c1, .highlight .nf, .highlight .sa, .highlight .se, .highlight .si {
|
||||||
|
font-style: italic; }
|
||||||
|
.highlight .k, .highlight .kn, .highlight .ow {
|
||||||
|
font-weight: bold; }
|
||||||
|
|
||||||
.doc-content {
|
.doc-content {
|
||||||
padding-top: 0.5em;
|
padding-top: 0.5em;
|
||||||
|
|||||||
Vendored
+12
-5
@@ -37,21 +37,28 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.c1 {
|
.c1 {
|
||||||
color: $color_instaloader_main
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.k, .kn, .ow {
|
.k, .kn, .ow {
|
||||||
color: $color_instaloader_accent;
|
color: $color_instaloader_main
|
||||||
font-weight: bold
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nb {
|
.nb, .ne, .nf, .vm {
|
||||||
color: $color_instaloader_accent
|
color: $color_instaloader_accent
|
||||||
}
|
}
|
||||||
|
|
||||||
.s1, .s2, .mi {
|
.s1, .s2, .sa, .se, .si, .mi {
|
||||||
color: $color_instaloader_hyper
|
color: $color_instaloader_hyper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c1, .nf, .sa, .se, .si {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k, .kn, .ow {
|
||||||
|
font-weight: bold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-content {
|
.doc-content {
|
||||||
|
|||||||
+11
-3
@@ -37,7 +37,7 @@ picture, video or sidecar (set of multiple pictures/videos) posted in a user's
|
|||||||
profile. :class:`Instaloader` provides methods to iterate over Posts from a
|
profile. :class:`Instaloader` provides methods to iterate over Posts from a
|
||||||
certain source::
|
certain source::
|
||||||
|
|
||||||
for post in L.get_hashtag_posts('cat'):
|
for post in instaloader.Hashtag.from_name(L.context, 'cat').get_posts():
|
||||||
# post is an instance of instaloader.Post
|
# post is an instance of instaloader.Post
|
||||||
L.download_post(post, target='#cat')
|
L.download_post(post, target='#cat')
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ certain source::
|
|||||||
- :meth:`Instaloader.get_explore_posts`
|
- :meth:`Instaloader.get_explore_posts`
|
||||||
Media that is suggested by Instagram to explore.
|
Media that is suggested by Instagram to explore.
|
||||||
|
|
||||||
- :meth:`Instaloader.get_hashtag_posts`
|
- :meth:`Hashtag.get_posts`
|
||||||
Media associated with given hashtag.
|
Media associated with given hashtag.
|
||||||
|
|
||||||
With the :class:`Profile` class, Instaloader also makes it easy to access
|
With the :class:`Profile` class, Instaloader also makes it easy to access
|
||||||
@@ -155,6 +155,14 @@ Profiles
|
|||||||
.. autoclass:: Profile
|
.. autoclass:: Profile
|
||||||
:no-show-inheritance:
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
Hashtags
|
||||||
|
""""""""
|
||||||
|
|
||||||
|
.. autoclass:: Hashtag
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
|
||||||
TopSearchResults
|
TopSearchResults
|
||||||
""""""""""""""""
|
""""""""""""""""
|
||||||
|
|
||||||
@@ -166,7 +174,7 @@ TopSearchResults
|
|||||||
Loading and Saving
|
Loading and Saving
|
||||||
""""""""""""""""""
|
""""""""""""""""""
|
||||||
|
|
||||||
:class:`Post`, :class:`StoryItem` and :class:`Profile` can be saved and loaded
|
:class:`Post`, :class:`StoryItem`, :class:`Profile` and :class:`Hashtag` can be saved and loaded
|
||||||
to/from JSON files.
|
to/from JSON files.
|
||||||
|
|
||||||
.. autofunction:: load_structure_from_file
|
.. autofunction:: load_structure_from_file
|
||||||
|
|||||||
+14
-9
@@ -51,7 +51,7 @@ invoke it with
|
|||||||
instaloader --login=your_username profile [profile ...]
|
instaloader --login=your_username profile [profile ...]
|
||||||
|
|
||||||
When logging in, Instaloader **stores the session cookies** in a file in your
|
When logging in, Instaloader **stores the session cookies** in a file in your
|
||||||
temporary directory, which will be reused later the next time :option:`--login`
|
home directory, which will be reused later the next time :option:`--login`
|
||||||
is given. So you can download private profiles **non-interactively** when you
|
is given. So you can download private profiles **non-interactively** when you
|
||||||
already have a valid session cookie file.
|
already have a valid session cookie file.
|
||||||
|
|
||||||
@@ -111,8 +111,12 @@ Instaloader supports the following targets:
|
|||||||
``profile`` (requires :option:`--login`).
|
``profile`` (requires :option:`--login`).
|
||||||
|
|
||||||
- ``-post``
|
- ``-post``
|
||||||
The single **post** with the given shortcode. Must be preceeded by ``--`` in
|
Replace **post** with the post's shortcode to download single post. Must be preceeded by ``--`` in
|
||||||
the argument list to not be mistaken as an option flag.
|
the argument list to not be mistaken as an option flag::
|
||||||
|
|
||||||
|
instaloader -- -B_K4CykAOtf
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.. versionadded:: 4.1
|
.. versionadded:: 4.1
|
||||||
|
|
||||||
@@ -291,17 +295,18 @@ Instaloader as Cronjob
|
|||||||
Instaloader is suitable for running as a cronjob to periodically update your
|
Instaloader is suitable for running as a cronjob to periodically update your
|
||||||
personal Instagram archive. The :option:`--quiet` option disables user
|
personal Instagram archive. The :option:`--quiet` option disables user
|
||||||
interactions and logging of non-error messages. To non-interactively use
|
interactions and logging of non-error messages. To non-interactively use
|
||||||
Instaloader logged-in, create a session file, e.g. in your home directory::
|
Instaloader logged-in, create a session file::
|
||||||
|
|
||||||
instaloader --login=your_username --sessionfile=~/.instaloader-session
|
instaloader --login=your_username
|
||||||
|
|
||||||
Then use the same parameters in your cronjob to load the session and download
|
Then use the same parameter in your cronjob to load the session and download
|
||||||
the given targets::
|
the given targets::
|
||||||
|
|
||||||
instaloader --login=your_username --sessionfile=~/.instaloader-session --quiet <target> [...]
|
instaloader --login=your_username --quiet <target> [...]
|
||||||
|
|
||||||
Without :option:`--sessionfile` option, Instaloader saves the session file in
|
Instaloader saves the session file to
|
||||||
a path within your temporary directory.
|
``~/.config/instaloader/session-YOUR-USERNAME``. See
|
||||||
|
:option:`--sessionfile` option for how to override this path.
|
||||||
|
|
||||||
Programming Instaloader
|
Programming Instaloader
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|||||||
@@ -193,9 +193,8 @@ Instaloader to login.
|
|||||||
|
|
||||||
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
|
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
|
||||||
|
|
||||||
Path for loading and storing session key file. Defaults to a path within
|
Path for loading and storing session key file. Defaults to
|
||||||
your temporary directory, encoding your local username and your Instagram
|
``~/.config/instaloader/session-YOUR-USERNAME``.
|
||||||
profile name.
|
|
||||||
|
|
||||||
.. option:: --password YOUR-PASSWORD, -p YOUR-PASSWORD
|
.. option:: --password YOUR-PASSWORD, -p YOUR-PASSWORD
|
||||||
|
|
||||||
@@ -243,6 +242,11 @@ How to Download
|
|||||||
|
|
||||||
.. versionadded:: 4.2
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
We discourage from using the "commit mode" due to its bugs when being used
|
||||||
|
with other command line options (e.g. :issue:`257` with
|
||||||
|
:option:`--fast-update` and :issue:`483` with :option:`--tagged`).
|
||||||
|
|
||||||
.. option:: --request-timeout N
|
.. option:: --request-timeout N
|
||||||
|
|
||||||
Seconds to wait before timing out a connection request.
|
Seconds to wait before timing out a connection request.
|
||||||
|
|||||||
+11
-2
@@ -28,8 +28,9 @@ Download Posts in a Specific Period
|
|||||||
-----------------------------------
|
-----------------------------------
|
||||||
|
|
||||||
To only download Instagram pictures (and metadata) that are within a specific
|
To only download Instagram pictures (and metadata) that are within a specific
|
||||||
period, you can play around with :func:`~itertools.dropwhile` and
|
period, you can simply use :func:`~itertools.dropwhile` and
|
||||||
:func:`~itertools.takewhile` from :mod:`itertools` like in this snippet.
|
:func:`~itertools.takewhile` from :mod:`itertools` on a generator that returns
|
||||||
|
Posts in **exact chronological order**, such as :meth:`Profile.get_posts`.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/121_since_until.py
|
.. literalinclude:: codesnippets/121_since_until.py
|
||||||
|
|
||||||
@@ -37,6 +38,14 @@ See also :class:`Post`, :meth:`Instaloader.download_post`.
|
|||||||
|
|
||||||
Discussed in :issue:`121`.
|
Discussed in :issue:`121`.
|
||||||
|
|
||||||
|
The code example with :func:`~itertools.dropwhile` and
|
||||||
|
:func:`~itertools.takewhile` makes the assumption that the post iterator returns
|
||||||
|
posts in exact chronological order. As discussed in :issue:`666`, the following
|
||||||
|
approach fits for an **almost chronological order**, where up to *k* older posts
|
||||||
|
are inserted into an otherwise chronological order, such as an Hashtag feed.
|
||||||
|
|
||||||
|
.. literalinclude:: codesnippets/666_historical_hashtag_data.py
|
||||||
|
|
||||||
Likes of a Profile / Ghost Followers
|
Likes of a Profile / Ghost Followers
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import instaloader
|
|||||||
|
|
||||||
L = instaloader.Instaloader()
|
L = instaloader.Instaloader()
|
||||||
|
|
||||||
posts = L.get_hashtag_posts('urbanphotography')
|
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts()
|
||||||
|
|
||||||
users = set()
|
users = set()
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import instaloader
|
|||||||
|
|
||||||
L = instaloader.Instaloader()
|
L = instaloader.Instaloader()
|
||||||
|
|
||||||
posts = L.get_hashtag_posts('urbanphotography')
|
posts = instaloader.Profile.from_username(L.context, "instagram").get_posts()
|
||||||
# or
|
|
||||||
# posts = instaloader.Profile.from_username(L.context, PROFILE).get_posts()
|
|
||||||
|
|
||||||
SINCE = datetime(2015, 5, 1)
|
SINCE = datetime(2015, 5, 1)
|
||||||
UNTIL = datetime(2015, 3, 1)
|
UNTIL = datetime(2015, 3, 1)
|
||||||
|
|
||||||
for post in takewhile(lambda p: p.date > UNTIL, dropwhile(lambda p: p.date > SINCE, posts)):
|
for post in takewhile(lambda p: p.date > UNTIL, dropwhile(lambda p: p.date > SINCE, posts)):
|
||||||
print(post.date)
|
print(post.date)
|
||||||
L.download_post(post, '#urbanphotography')
|
L.download_post(post, "instagram")
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from argparse import ArgumentParser
|
||||||
|
from glob import glob
|
||||||
|
from os.path import expanduser
|
||||||
|
from platform import system
|
||||||
|
from sqlite3 import OperationalError, connect
|
||||||
|
|
||||||
|
try:
|
||||||
|
from instaloader import ConnectionException, Instaloader
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise SystemExit("Instaloader not found.\n pip install [--user] instaloader")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookiefile():
|
||||||
|
default_cookiefile = {
|
||||||
|
"Windows": "~/AppData/Roaming/Mozilla/Firefox/Profiles/*/cookies.sqlite",
|
||||||
|
"Darwin": "~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite",
|
||||||
|
}.get(system(), "~/.mozilla/firefox/*/cookies.sqlite")
|
||||||
|
cookiefiles = glob(expanduser(default_cookiefile))
|
||||||
|
if not cookiefiles:
|
||||||
|
raise SystemExit("No Firefox cookies.sqlite file found. Use -c COOKIEFILE.")
|
||||||
|
return cookiefiles[0]
|
||||||
|
|
||||||
|
|
||||||
|
def import_session(cookiefile, sessionfile):
|
||||||
|
print("Using cookies from {}.".format(cookiefile))
|
||||||
|
conn = connect(cookiefile)
|
||||||
|
try:
|
||||||
|
cookie_data = conn.execute(
|
||||||
|
"SELECT name, value FROM moz_cookies WHERE baseDomain='instagram.com'"
|
||||||
|
)
|
||||||
|
except OperationalError:
|
||||||
|
cookie_data = conn.execute(
|
||||||
|
"SELECT name, value FROM moz_cookies WHERE host LIKE '%instagram.com'"
|
||||||
|
)
|
||||||
|
instaloader = Instaloader(max_connection_attempts=1)
|
||||||
|
instaloader.context._session.cookies.update(cookie_data)
|
||||||
|
username = instaloader.test_login()
|
||||||
|
if not username:
|
||||||
|
raise SystemExit("Not logged in. Are you logged in successfully in Firefox?")
|
||||||
|
print("Imported session cookie for {}.".format(username))
|
||||||
|
instaloader.context.username = username
|
||||||
|
instaloader.save_session_to_file(sessionfile)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = ArgumentParser()
|
||||||
|
p.add_argument("-c", "--cookiefile")
|
||||||
|
p.add_argument("-f", "--sessionfile")
|
||||||
|
args = p.parse_args()
|
||||||
|
try:
|
||||||
|
import_session(args.cookiefile or get_cookiefile(), args.sessionfile)
|
||||||
|
except (ConnectionException, OperationalError) as e:
|
||||||
|
raise SystemExit("Cookie import failed: {}".format(e))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import instaloader
|
||||||
|
|
||||||
|
L = instaloader.Instaloader()
|
||||||
|
|
||||||
|
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts()
|
||||||
|
|
||||||
|
SINCE = datetime(2020, 5, 10) # further from today, inclusive
|
||||||
|
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
|
||||||
|
|
||||||
|
k = 0 # initiate k
|
||||||
|
k_list = [] # uncomment this to tune k
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
postdate = post.date
|
||||||
|
|
||||||
|
if postdate > UNTIL:
|
||||||
|
continue
|
||||||
|
elif postdate <= SINCE:
|
||||||
|
k += 1
|
||||||
|
if k == 50:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
L.download_post(post, "#urbanphotography")
|
||||||
|
k = 0 # set k to 0
|
||||||
|
# if you want to tune k, uncomment below to get your k max
|
||||||
|
#k_list.append(k)
|
||||||
|
#max(k_list)
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from glob import glob
|
|
||||||
from os.path import expanduser
|
|
||||||
from sqlite3 import connect
|
|
||||||
|
|
||||||
from instaloader import ConnectionException, Instaloader
|
|
||||||
|
|
||||||
# FIREFOXCOOKIEFILE = "/home/alex/.mozilla/firefox/l96w6b90.default/cookies.sqlite"
|
|
||||||
FIREFOXCOOKIEFILE = glob(expanduser("~/.mozilla/firefox/*.default/cookies.sqlite"))[0]
|
|
||||||
|
|
||||||
instaloader = Instaloader(max_connection_attempts=1)
|
|
||||||
instaloader.context._session.cookies.update(connect(FIREFOXCOOKIEFILE)
|
|
||||||
.execute("SELECT name, value FROM moz_cookies "
|
|
||||||
"WHERE baseDomain='instagram.com'"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
username = instaloader.test_login()
|
|
||||||
if not username:
|
|
||||||
raise ConnectionException()
|
|
||||||
except ConnectionException:
|
|
||||||
raise SystemExit("Cookie import failed. Are you logged in successfully in Firefox?")
|
|
||||||
|
|
||||||
instaloader.context.username = username
|
|
||||||
instaloader.save_session_to_file()
|
|
||||||
+19
-8
@@ -18,6 +18,7 @@
|
|||||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
#
|
#
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
@@ -49,7 +50,23 @@ autodoc_member_order = 'bysource'
|
|||||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
||||||
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
||||||
|
|
||||||
extlinks = {'issue': ('https://github.com/instaloader/instaloader/issues/%s', 'issue #')}
|
current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1]
|
||||||
|
date_format = "%e %b %Y" if platform.system() != "Windows" else "%d %b %Y"
|
||||||
|
current_release_date = subprocess.check_output(
|
||||||
|
["git", "log", "-1", "--tags", "--format=%ad", "--date=format:"+date_format]).decode("ascii")[:-1]
|
||||||
|
|
||||||
|
html_context = {'current_release': current_release, 'current_release_date': current_release_date}
|
||||||
|
|
||||||
|
extlinks = {
|
||||||
|
'issue': (
|
||||||
|
'https://github.com/instaloader/instaloader/issues/%s',
|
||||||
|
'Issue #'
|
||||||
|
),
|
||||||
|
'example': (
|
||||||
|
'https://raw.githubusercontent.com/instaloader/instaloader/v' + current_release + '/docs/codesnippets/%s',
|
||||||
|
'Example '
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
@@ -69,7 +86,7 @@ master_doc = 'index'
|
|||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'Instaloader'
|
project = 'Instaloader'
|
||||||
copyright = '2017, Alexander Graf and Andre Koch-Kramer'
|
copyright = '2017-2020, Alexander Graf and Andre Koch-Kramer'
|
||||||
author = 'Alexander Graf and Andre Koch-Kramer'
|
author = 'Alexander Graf and Andre Koch-Kramer'
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
@@ -353,12 +370,6 @@ texinfo_documents = [
|
|||||||
# texinfo_no_detailmenu = False
|
# texinfo_no_detailmenu = False
|
||||||
|
|
||||||
|
|
||||||
current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1]
|
|
||||||
current_release_date = subprocess.check_output(["git", "log", "-1", "--tags", "--format=%ad", "--date=format:%e %b %Y"]).decode("ascii")[:-1]
|
|
||||||
|
|
||||||
html_context = {'current_release': current_release, 'current_release_date': current_release_date}
|
|
||||||
|
|
||||||
|
|
||||||
def setup(app):
|
def setup(app):
|
||||||
typing.TYPE_CHECKING = True
|
typing.TYPE_CHECKING = True
|
||||||
app.add_stylesheet("instaloader.css")
|
app.add_stylesheet("instaloader.css")
|
||||||
|
|||||||
+27
-26
@@ -35,8 +35,8 @@ reporting a problem, please keep the following in mind:
|
|||||||
- If not obvious, describe **which behavior you expected**
|
- If not obvious, describe **which behavior you expected**
|
||||||
instead of what actually happened.
|
instead of what actually happened.
|
||||||
|
|
||||||
- If you think an issue has been closed accidentally or inappropriately, feel
|
- If we have closed an issue apparently inadvertently or inappropriately, please
|
||||||
free to reopen it.
|
let us know.
|
||||||
|
|
||||||
Writing Code or Improving the Documentation
|
Writing Code or Improving the Documentation
|
||||||
-------------------------------------------
|
-------------------------------------------
|
||||||
@@ -45,28 +45,25 @@ Changes of the Instaloader source can be proposed as a
|
|||||||
`Pull Request <https://github.com/instaloader/instaloader/pulls>`__. There are only
|
`Pull Request <https://github.com/instaloader/instaloader/pulls>`__. There are only
|
||||||
few things to consider:
|
few things to consider:
|
||||||
|
|
||||||
- Sometimes, the most current code is not in the ``master`` branch. Check that
|
- Base your Pull Request on the ``master`` branch if it fixes a bug,
|
||||||
you forked from the most recent branch.
|
or the ``upcoming/v4.X`` branch (if it exists at the moment of submitting the PR)
|
||||||
|
otherwise.
|
||||||
|
|
||||||
- We use `Pylint <https://www.pylint.org/>`__ for error and syntax checking of
|
- We use `Pylint <https://www.pylint.org/>`__ for error and syntax checking of
|
||||||
the source. The file ``.travis.yml`` in the project's root directory
|
the source and `MyPy <https://github.com/python/mypy>`__ for type checking.
|
||||||
shows how it is invoked. Note that sometimes it might be better to disable a
|
Beware that sometimes it might be better to disable a warning rather than
|
||||||
warning rather than adapting the code to Pylint's desires.
|
adapting the code to a tool's desires.
|
||||||
|
|
||||||
- The documentation source is located in the ``docs`` folder. The file
|
- The documentation source is located in the ``docs`` folder. The file
|
||||||
``cli-options.rst`` is merely an RST-formatted copy of ``instaloader --help``
|
``cli-options.rst`` is merely an RST-formatted copy of ``instaloader --help``
|
||||||
output, of which the source is in ``instaloader/__main__.py``.
|
output, of which the source is in ``instaloader/__main__.py``.
|
||||||
|
|
||||||
- Feel free to contact us, even if you "only" have Proof-of-Concepts or
|
Proposing Features
|
||||||
not-fully integrated changes. They already might be an advance for the
|
------------------
|
||||||
project.
|
|
||||||
|
|
||||||
Suggesting Features
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
.. goal-start
|
.. goal-start
|
||||||
|
|
||||||
Instaloader's goal is to mimick the browser's behavior to access the data that
|
Instaloader's goal is to mimic the browser's behavior to access the data that
|
||||||
is available through the Instagram web interface, pack this data into complete
|
is available through the Instagram web interface, pack this data into complete
|
||||||
and easily-(re)usable python objects, and provide a user interface for the most
|
and easily-(re)usable python objects, and provide a user interface for the most
|
||||||
common downloading and metadata collection tasks, without changing any of the
|
common downloading and metadata collection tasks, without changing any of the
|
||||||
@@ -74,10 +71,9 @@ online data.
|
|||||||
|
|
||||||
.. goal-end
|
.. goal-end
|
||||||
|
|
||||||
If you have an idea of how Instaloader should be enhanced, but do not want to
|
Prior spending effort on implementing a new feature, it might be appropriate to
|
||||||
implement the feature yourself, feel free to open a ticket in our
|
clarify how it could fit into the project's scope or discuss implementation
|
||||||
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__.
|
details. If you feel the need to do so, please create a "feature suggestion".
|
||||||
Please consider the following:
|
|
||||||
|
|
||||||
- Instaloader already has plenty of features. **Check the documentation**
|
- Instaloader already has plenty of features. **Check the documentation**
|
||||||
beforehand to ensure your desired suggestion is not already implemented.
|
beforehand to ensure your desired suggestion is not already implemented.
|
||||||
@@ -86,16 +82,21 @@ Please consider the following:
|
|||||||
an issue suggesting the same or a similar feature, share your thoughts in a
|
an issue suggesting the same or a similar feature, share your thoughts in a
|
||||||
comment there, instead of opening a new issue.
|
comment there, instead of opening a new issue.
|
||||||
|
|
||||||
- If possible, provide us a **use case of the feature**: How could the user
|
- **Motivate the feature**, i.e.
|
||||||
invoke the new function? Which problem would it solve? If new information is
|
|
||||||
obtained, how would it be further processed?
|
|
||||||
|
|
||||||
- If not obvious, briefly motivate how your suggested feature **conforms with
|
- Provide us a **use case of the feature**: How could the user
|
||||||
Instaloader's project goal**.
|
invoke the new function? Which problem would it solve? If new information is
|
||||||
|
obtained, how would it be further processed?
|
||||||
|
|
||||||
- **Be patient**. Naturally, bugs and pull requests have a higher priority than
|
- Describe already-working **alternatives of the feature** and how they
|
||||||
feature suggestions. Keep in mind that this is a free software project, and
|
compare to your proposed feature.
|
||||||
unfortunately we only have limited time to work on it.
|
|
||||||
|
- Briefly describe how your suggested feature **conforms with Instaloader's
|
||||||
|
project goal**.
|
||||||
|
|
||||||
|
- Explain your **solution ideas**. Describe your ideas on how the feature could
|
||||||
|
be implemented and the underlying problem could be solved. Also **describe
|
||||||
|
alternatives** that you have considered.
|
||||||
|
|
||||||
Donations
|
Donations
|
||||||
---------
|
---------
|
||||||
|
|||||||
+37
-13
@@ -27,12 +27,26 @@ expired. In case the retry continuously fails for some reason, which should not
|
|||||||
happen in normal conditions, consider adjusting the
|
happen in normal conditions, consider adjusting the
|
||||||
:option:`--max-connection-attempts` option.
|
:option:`--max-connection-attempts` option.
|
||||||
|
|
||||||
|
There have been observations that services, that in their nature offer
|
||||||
|
promiscious IP addresses, such as cloud, VPN and public proxy services, might be
|
||||||
|
subject to significantly stricter limits for anonymous access. However,
|
||||||
|
logged-in accesses (see :option:`--login`) do not seem to be affected.
|
||||||
|
|
||||||
|
Too many queries in the last time
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
**"Too many queries in the last time"** is not an error. It is a notice that the
|
**"Too many queries in the last time"** is not an error. It is a notice that the
|
||||||
rate limit has almost been reached, according to Instaloader's own rate
|
rate limit has almost been reached, according to Instaloader's own rate
|
||||||
accounting mechanism. We regularly adjust this mechanism to match Instagram's
|
accounting mechanism. We regularly adjust this mechanism to match Instagram's
|
||||||
current rate limiting.
|
current rate limiting.
|
||||||
|
|
||||||
Login error
|
Private but not followed
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
You have to follow a private account to access most of its associated
|
||||||
|
information.
|
||||||
|
|
||||||
|
Login Error
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
Instaloader's login *should* work fine, both with and without
|
Instaloader's login *should* work fine, both with and without
|
||||||
@@ -40,21 +54,31 @@ Two-Factor-Authentication. It also supports handling the *checkpoint challenge*,
|
|||||||
issued when Instagram suspects authentication activity on your account, by
|
issued when Instagram suspects authentication activity on your account, by
|
||||||
pointing the user to an URL to be opened in a browser.
|
pointing the user to an URL to be opened in a browser.
|
||||||
|
|
||||||
Nevertheless, in :issue:`92` users report problems with logging in. To still use
|
Nevertheless, in :issue:`92` and :issue:`615` users reported problems with
|
||||||
Instaloader's logged-in functionality, you may use the following script to
|
logging in. We recommend to always keep the sessionfile which Instaloader
|
||||||
workaround login problems by importing the session cookies from Firefox and
|
creates when using :option:`--login`. If a sessionfile is present,
|
||||||
bypassing Instaloader's login.
|
:option:`--login` does not make make use of the failure-prone login procedure.
|
||||||
|
Also, sessionfiles usually do not expire and can be copied between different
|
||||||
|
computers without any problems.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/92_import_firefox_session.py
|
If you do not have a sessionfile present, you may use the following script
|
||||||
|
(:example:`615_import_firefox_session.py`) to workaround login problems by
|
||||||
|
importing the session cookies from Firefox and bypassing Instaloader's login and
|
||||||
|
so still use Instaloader's logged-in functionality.
|
||||||
|
|
||||||
To use this,
|
.. literalinclude:: codesnippets/615_import_firefox_session.py
|
||||||
|
|
||||||
#. login to Instagram in Firefox,
|
To use this script,
|
||||||
|
|
||||||
#. execute the snippet,
|
#. Download the script: :example:`615_import_firefox_session.py`,
|
||||||
|
|
||||||
#. then, ``instaloader -l USERNAME`` should work fine.
|
#. Login to Instagram in Firefox,
|
||||||
|
|
||||||
If you do not use your default firefox profile, or your operating system has the
|
#. Execute the snippet, e.g. with ``python 615_import_firefox_session.py``,
|
||||||
paths differently set up, you may have to alter the ``FIREFOXCOOKIEFILE``
|
|
||||||
variable first.
|
#. Then, ``instaloader -l USERNAME`` should work fine.
|
||||||
|
|
||||||
|
This script also supports specifying a cookiefile path, which may be useful if
|
||||||
|
you use multiple Firefox profiles or if your operating system has the directory
|
||||||
|
structure differently set up. Also, you can specify an alternative sessionfile
|
||||||
|
path.
|
||||||
|
|||||||
@@ -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.3.2'
|
__version__ = '4.4.5'
|
||||||
|
|
||||||
|
|
||||||
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,
|
from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation,
|
||||||
StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file)
|
Profile, Story, StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file)
|
||||||
|
|||||||
+19
-8
@@ -3,6 +3,7 @@
|
|||||||
import ast
|
import ast
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from argparse import ArgumentParser, SUPPRESS
|
from argparse import ArgumentParser, SUPPRESS
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -81,6 +82,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
instaloader.context.log('Only download storyitems with property "{}".'.format(storyitem_filter_str))
|
instaloader.context.log('Only download storyitems with property "{}".'.format(storyitem_filter_str))
|
||||||
# Login, if desired
|
# Login, if desired
|
||||||
if username is not None:
|
if username is not None:
|
||||||
|
if not re.match(r"^[A-Za-z0-9._]+$", username):
|
||||||
|
instaloader.context.error("Warning: Parameter \"{}\" for --login is not a valid username.".format(username))
|
||||||
try:
|
try:
|
||||||
instaloader.load_session_from_file(username, sessionfile)
|
instaloader.load_session_from_file(username, sessionfile)
|
||||||
except FileNotFoundError as err:
|
except FileNotFoundError as err:
|
||||||
@@ -136,18 +139,19 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
# strip '/' characters to be more shell-autocompletion-friendly
|
# strip '/' characters to be more shell-autocompletion-friendly
|
||||||
target = target.rstrip('/')
|
target = target.rstrip('/')
|
||||||
with instaloader.context.error_catcher(target):
|
with instaloader.context.error_catcher(target):
|
||||||
if target[0] == '@':
|
if re.match(r"^@[A-Za-z0-9._]+$", target):
|
||||||
instaloader.context.log("Retrieving followees of %s..." % target[1:])
|
instaloader.context.log("Retrieving followees of %s..." % target[1:])
|
||||||
profile = Profile.from_username(instaloader.context, target[1:])
|
profile = Profile.from_username(instaloader.context, target[1:])
|
||||||
for followee in profile.get_followees():
|
for followee in profile.get_followees():
|
||||||
instaloader.save_profile_id(followee)
|
instaloader.save_profile_id(followee)
|
||||||
profiles.add(followee)
|
profiles.add(followee)
|
||||||
elif target[0] == '#':
|
elif re.match(r"^#\w+$", target):
|
||||||
instaloader.download_hashtag(hashtag=target[1:], max_count=max_count, fast_update=fast_update,
|
instaloader.download_hashtag(hashtag=target[1:], max_count=max_count, fast_update=fast_update,
|
||||||
post_filter=post_filter)
|
post_filter=post_filter,
|
||||||
elif target[0] == '-':
|
profile_pic=download_profile_pic, posts=download_posts)
|
||||||
|
elif re.match(r"^-[A-Za-z0-9-_]+$", target):
|
||||||
instaloader.download_post(Post.from_shortcode(instaloader.context, target[1:]), target)
|
instaloader.download_post(Post.from_shortcode(instaloader.context, target[1:]), target)
|
||||||
elif target[0] == "%":
|
elif re.match(r"^%[0-9]+$", target):
|
||||||
instaloader.download_location(location=target[1:], max_count=max_count, fast_update=fast_update,
|
instaloader.download_location(location=target[1:], max_count=max_count, fast_update=fast_update,
|
||||||
post_filter=post_filter)
|
post_filter=post_filter)
|
||||||
elif target == ":feed":
|
elif target == ":feed":
|
||||||
@@ -158,7 +162,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
elif target == ":saved":
|
elif target == ":saved":
|
||||||
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
|
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
|
||||||
post_filter=post_filter)
|
post_filter=post_filter)
|
||||||
else:
|
elif re.match(r"^[A-Za-z0-9._]+$", target):
|
||||||
try:
|
try:
|
||||||
profile = instaloader.check_profile_id(target)
|
profile = instaloader.check_profile_id(target)
|
||||||
if instaloader.context.is_logged_in and profile.has_blocked_viewer:
|
if instaloader.context.is_logged_in and profile.has_blocked_viewer:
|
||||||
@@ -184,11 +188,18 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
.format(target, err))
|
.format(target, err))
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
else:
|
||||||
|
target_type = {
|
||||||
|
'#': 'hashtag',
|
||||||
|
'%': 'location',
|
||||||
|
'-': 'shortcode',
|
||||||
|
}.get(target[0], 'username')
|
||||||
|
raise ProfileNotExistsException('Invalid {} {}'.format(target_type, target))
|
||||||
if len(profiles) > 1:
|
if len(profiles) > 1:
|
||||||
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
|
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
|
||||||
' '.join([p.username for p in profiles])))
|
' '.join([p.username for p in profiles])))
|
||||||
if profiles and download_profile_pic and not instaloader.context.is_logged_in:
|
if profiles and (download_profile_pic or download_posts) and not instaloader.context.is_logged_in:
|
||||||
instaloader.context.error("Warning: Use --login to download HD version of profile pictures.")
|
instaloader.context.error("Warning: Use --login to download higher-quality versions of pictures.")
|
||||||
instaloader.download_profiles(profiles,
|
instaloader.download_profiles(profiles,
|
||||||
download_profile_pic, download_posts, download_tagged, download_igtv,
|
download_profile_pic, download_posts, download_tagged, download_igtv,
|
||||||
download_highlights, download_stories,
|
download_highlights, download_stories,
|
||||||
|
|||||||
+160
-157
@@ -21,12 +21,26 @@ 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,
|
from .structures import (Hashtag, Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
|
||||||
save_structure_to_file, load_structure_from_file)
|
save_structure_to_file, load_structure_from_file)
|
||||||
|
|
||||||
|
|
||||||
def get_default_session_filename(username: str) -> str:
|
def get_default_session_filename(username: str) -> str:
|
||||||
"""Returns default session filename for given username."""
|
"""Returns default session filename for given username."""
|
||||||
|
sessionfilename = "session-{}".format(username)
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
# on Windows, use %LOCALAPPDATA%\Instaloader\session-USERNAME
|
||||||
|
localappdata = os.getenv("LOCALAPPDATA")
|
||||||
|
if localappdata is not None:
|
||||||
|
return os.path.join(localappdata, "Instaloader", sessionfilename)
|
||||||
|
# legacy fallback - store in temp dir if %LOCALAPPDATA% is not set
|
||||||
|
return os.path.join(tempfile.gettempdir(), ".instaloader-" + getpass.getuser(), sessionfilename)
|
||||||
|
# on Unix, use ~/.config/instaloader/session-USERNAME
|
||||||
|
return os.path.join(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "instaloader", sessionfilename)
|
||||||
|
|
||||||
|
|
||||||
|
def get_legacy_session_filename(username: str) -> str:
|
||||||
|
"""Returns legacy (until v4.4.3) default session filename for given username."""
|
||||||
dirname = tempfile.gettempdir() + "/" + ".instaloader-" + getpass.getuser()
|
dirname = tempfile.gettempdir() + "/" + ".instaloader-" + getpass.getuser()
|
||||||
filename = dirname + "/" + "session-" + username
|
filename = dirname + "/" + "session-" + username
|
||||||
return filename.lower()
|
return filename.lower()
|
||||||
@@ -103,22 +117,20 @@ class _ArbitraryItemFormatter(string.Formatter):
|
|||||||
|
|
||||||
|
|
||||||
class _PostPathFormatter(_ArbitraryItemFormatter):
|
class _PostPathFormatter(_ArbitraryItemFormatter):
|
||||||
def vformat(self, format_string, args, kwargs):
|
|
||||||
"""Override :meth:`string.Formatter.vformat` for character substitution in paths for Windows, see issue #84."""
|
|
||||||
ret = super().vformat(format_string, args, kwargs)
|
|
||||||
if platform.system() == 'Windows':
|
|
||||||
ret = ret.replace(':', '\ua789').replace('<', '\ufe64').replace('>', '\ufe65').replace('\"', '\uff02')
|
|
||||||
ret = ret.replace('\\', '\ufe68').replace('|', '\uff5c').replace('?', '\ufe16').replace('*', '\uff0a')
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get_value(self, key, args, kwargs):
|
def get_value(self, key, args, kwargs):
|
||||||
"""Replaces '/' with similar looking Division Slash and on windows newline with space"""
|
|
||||||
ret = super().get_value(key, args, kwargs)
|
ret = super().get_value(key, args, kwargs)
|
||||||
if not isinstance(ret, str):
|
if not isinstance(ret, str):
|
||||||
return ret
|
return ret
|
||||||
|
return self.sanitize_path(ret)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_path(ret: str) -> str:
|
||||||
|
"""Replaces '/' with similar looking Division Slash and some other illegal filename characters on Windows."""
|
||||||
ret = ret.replace('/', '\u2215')
|
ret = ret.replace('/', '\u2215')
|
||||||
if platform.system() == 'Windows':
|
if platform.system() == 'Windows':
|
||||||
ret = ret.replace('\n', ' ')
|
ret = ret.replace(':', '\uff1a').replace('<', '\ufe64').replace('>', '\ufe65').replace('\"', '\uff02')
|
||||||
|
ret = ret.replace('\\', '\ufe68').replace('|', '\uff5c').replace('?', '\ufe16').replace('*', '\uff0a')
|
||||||
|
ret = ret.replace('\n', ' ').replace('\r', ' ')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -358,7 +370,7 @@ class Instaloader:
|
|||||||
self.context.log('geo', end=' ', flush=True)
|
self.context.log('geo', end=' ', flush=True)
|
||||||
|
|
||||||
@_retry_on_connection_error
|
@_retry_on_connection_error
|
||||||
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Profile,
|
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile],
|
||||||
_attempt: int = 1) -> None:
|
_attempt: int = 1) -> 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.
|
||||||
@@ -380,12 +392,13 @@ class Instaloader:
|
|||||||
pic_extension = 'jpg'
|
pic_extension = 'jpg'
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||||
filename = '{0}/{1}_{2}.{3}'.format(self.dirname_pattern.format(profile=owner_profile.username.lower(),
|
profile_str = owner_profile.username.lower() if owner_profile is not None else target
|
||||||
target=target),
|
filename = os.path.join(self.dirname_pattern.format(profile=profile_str,
|
||||||
pic_identifier, name_suffix, pic_extension)
|
target=target),
|
||||||
|
'{0}_{1}.{2}'.format(pic_identifier, name_suffix, pic_extension))
|
||||||
else:
|
else:
|
||||||
filename = '{0}/{1}_{2}_{3}.{4}'.format(self.dirname_pattern.format(), target,
|
filename = os.path.join(self.dirname_pattern.format(),
|
||||||
pic_identifier, name_suffix, pic_extension)
|
'{0}_{1}_{2}.{3}'.format(target, pic_identifier, name_suffix, pic_extension))
|
||||||
content_length = http_response.headers.get('Content-Length', None)
|
content_length = http_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
|
(content_length is not None and
|
||||||
@@ -408,6 +421,12 @@ class Instaloader:
|
|||||||
.. versionadded:: 4.3"""
|
.. versionadded:: 4.3"""
|
||||||
self.download_title_pic(highlight.cover_url, target, 'cover', highlight.owner_profile)
|
self.download_title_pic(highlight.cover_url, target, 'cover', highlight.owner_profile)
|
||||||
|
|
||||||
|
def download_hashtag_profilepic(self, hashtag: Hashtag) -> None:
|
||||||
|
"""Downloads and saves the profile picture of a Hashtag.
|
||||||
|
|
||||||
|
.. versionadded:: 4.4"""
|
||||||
|
self.download_title_pic(hashtag.profile_pic_url, '#' + hashtag.name, 'profile_pic', None)
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def save_session_to_file(self, filename: Optional[str] = None) -> None:
|
def save_session_to_file(self, filename: Optional[str] = None) -> None:
|
||||||
"""Saves internally stored :class:`requests.Session` object.
|
"""Saves internally stored :class:`requests.Session` object.
|
||||||
@@ -436,6 +455,8 @@ class Instaloader:
|
|||||||
"""
|
"""
|
||||||
if filename is None:
|
if filename is None:
|
||||||
filename = get_default_session_filename(username)
|
filename = get_default_session_filename(username)
|
||||||
|
if not os.path.exists(filename):
|
||||||
|
filename = get_legacy_session_filename(username)
|
||||||
with open(filename, 'rb') as sessionfile:
|
with open(filename, 'rb') as sessionfile:
|
||||||
self.context.load_session_from_file(username, sessionfile)
|
self.context.load_session_from_file(username, sessionfile)
|
||||||
self.context.log("Loaded session from %s." % filename)
|
self.context.log("Loaded session from %s." % filename)
|
||||||
@@ -480,7 +501,7 @@ class Instaloader:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
|
||||||
filename = dirname + '/' + self.format_filename(post, target=target)
|
filename = os.path.join(dirname, self.format_filename(post, target=target))
|
||||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
||||||
|
|
||||||
# Download the image(s) / video thumbnail and videos within sidecars if desired
|
# Download the image(s) / video thumbnail and videos within sidecars if desired
|
||||||
@@ -609,7 +630,7 @@ class Instaloader:
|
|||||||
|
|
||||||
date_local = item.date_local
|
date_local = item.date_local
|
||||||
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
|
||||||
filename = dirname + '/' + self.format_filename(item, target=target)
|
filename = os.path.join(dirname, self.format_filename(item, target=target))
|
||||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
||||||
downloaded = False
|
downloaded = False
|
||||||
if not item.is_video or self.download_video_thumbnails is True:
|
if not item.is_video or self.download_video_thumbnails is True:
|
||||||
@@ -674,7 +695,8 @@ class Instaloader:
|
|||||||
name = user_highlight.owner_username
|
name = user_highlight.owner_username
|
||||||
highlight_target = (filename_target
|
highlight_target = (filename_target
|
||||||
if filename_target
|
if filename_target
|
||||||
else Path(name) / Path(user_highlight.title)) # type: Union[str, Path]
|
else (Path(_PostPathFormatter.sanitize_path(name)) /
|
||||||
|
_PostPathFormatter.sanitize_path(user_highlight.title))) # type: Union[str, Path]
|
||||||
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
|
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
|
||||||
self.download_highlight_cover(user_highlight, highlight_target)
|
self.download_highlight_cover(user_highlight, highlight_target)
|
||||||
totalcount = user_highlight.itemcount
|
totalcount = user_highlight.itemcount
|
||||||
@@ -691,6 +713,66 @@ class Instaloader:
|
|||||||
if fast_update and not downloaded:
|
if fast_update and not downloaded:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def posts_download_loop(self,
|
||||||
|
posts: Iterator[Post],
|
||||||
|
target: Union[str, Path],
|
||||||
|
fast_update: bool = False,
|
||||||
|
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||||
|
max_count: Optional[int] = None,
|
||||||
|
total_count: Optional[int] = None) -> None:
|
||||||
|
"""
|
||||||
|
Download the Posts returned by given Post Iterator.
|
||||||
|
|
||||||
|
..versionadded:: 4.4
|
||||||
|
|
||||||
|
:param posts: Post Iterator to loop through.
|
||||||
|
:param target: Target name
|
||||||
|
:param fast_update: :option:`--fast-update`
|
||||||
|
:param post_filter: :option:`--post-filter`
|
||||||
|
:param max_count: Maximum count of Posts to download (:option:`--count`)
|
||||||
|
:param total_count: Total number of posts returned by given iterator
|
||||||
|
"""
|
||||||
|
for number, post in enumerate(posts):
|
||||||
|
if max_count is not None and number >= max_count:
|
||||||
|
break
|
||||||
|
if total_count is not None:
|
||||||
|
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number + 1, total_count,
|
||||||
|
w=len(str(total_count))),
|
||||||
|
end="", flush=True)
|
||||||
|
else:
|
||||||
|
if max_count is not None:
|
||||||
|
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number + 1, max_count,
|
||||||
|
w=len(str(max_count))),
|
||||||
|
end="", flush=True)
|
||||||
|
else:
|
||||||
|
self.context.log("[{:3d}] ".format(number + 1), end="", flush=True)
|
||||||
|
if post_filter is not None:
|
||||||
|
try:
|
||||||
|
if not post_filter(post):
|
||||||
|
self.context.log("{} skipped".format(post))
|
||||||
|
continue
|
||||||
|
except (InstaloaderException, KeyError, TypeError) as err:
|
||||||
|
self.context.error("{} skipped. Filter evaluation failed: {}".format(post, err))
|
||||||
|
continue
|
||||||
|
with self.context.error_catcher("Download {} of {}".format(post, target)):
|
||||||
|
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
|
||||||
|
# additional metadata. This is most likely the case if a HTTP redirect takes place while
|
||||||
|
# resolving the shortcode URL.
|
||||||
|
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
|
||||||
|
# obained after a redirect has probably already been downloaded as a previous Post of the
|
||||||
|
# same Profile.
|
||||||
|
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
|
||||||
|
post_changed = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
downloaded = self.download_post(post, target=target)
|
||||||
|
break
|
||||||
|
except PostChangedException:
|
||||||
|
post_changed = True
|
||||||
|
continue
|
||||||
|
if fast_update and not downloaded and not post_changed:
|
||||||
|
break
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_feed_posts(self) -> Iterator[Post]:
|
def get_feed_posts(self) -> Iterator[Post]:
|
||||||
"""Get Posts of the user's feed.
|
"""Get Posts of the user's feed.
|
||||||
@@ -735,20 +817,7 @@ class Instaloader:
|
|||||||
:raises LoginRequiredException: If called without being logged in.
|
: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
|
self.posts_download_loop(self.get_feed_posts(), ":feed", fast_update, post_filter, max_count=max_count)
|
||||||
for post in self.get_feed_posts():
|
|
||||||
if max_count is not None and count > max_count:
|
|
||||||
break
|
|
||||||
name = post.owner_username
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log("<pic by %s skipped>" % name, flush=True)
|
|
||||||
continue
|
|
||||||
self.context.log("[%3i] %s " % (count, name), end="", flush=True)
|
|
||||||
count += 1
|
|
||||||
with self.context.error_catcher('Download feed'):
|
|
||||||
downloaded = self.download_post(post, target=':feed')
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
|
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
|
||||||
@@ -761,20 +830,9 @@ class Instaloader:
|
|||||||
:raises LoginRequiredException: If called without being logged in.
|
:raises LoginRequiredException: If called without being logged in.
|
||||||
"""
|
"""
|
||||||
self.context.log("Retrieving saved posts...")
|
self.context.log("Retrieving saved posts...")
|
||||||
count = 1
|
assert self.context.username is not None # safe due to @_requires_login; required by typechecker
|
||||||
assert self.context.username is not None
|
self.posts_download_loop(Profile.from_username(self.context, self.context.username).get_saved_posts(), ":saved",
|
||||||
for post in Profile.from_username(self.context, self.context.username).get_saved_posts():
|
fast_update, post_filter, max_count=max_count)
|
||||||
if max_count is not None and count > max_count:
|
|
||||||
break
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log("<{} skipped>".format(post), flush=True)
|
|
||||||
continue
|
|
||||||
self.context.log("[{:>3}] ".format(count), end=str(), flush=True)
|
|
||||||
count += 1
|
|
||||||
with self.context.error_catcher('Download saved posts'):
|
|
||||||
downloaded = self.download_post(post, target=':saved')
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_location_posts(self, location: str) -> Iterator[Post]:
|
def get_location_posts(self, location: str) -> Iterator[Post]:
|
||||||
@@ -825,19 +883,8 @@ class Instaloader:
|
|||||||
Require being logged in (as required by Instagram)
|
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
|
self.posts_download_loop(self.get_location_posts(location), "%" + location, fast_update, post_filter,
|
||||||
for post in self.get_location_posts(location):
|
max_count=max_count)
|
||||||
if max_count is not None and count > max_count:
|
|
||||||
break
|
|
||||||
self.context.log('[{0:3d}] %{1} '.format(count, location), end='', flush=True)
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log('<skipped>')
|
|
||||||
continue
|
|
||||||
count += 1
|
|
||||||
with self.context.error_catcher('Download location {}'.format(location)):
|
|
||||||
downloaded = self.download_post(post, target='%' + location)
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_explore_posts(self) -> Iterator[Post]:
|
def get_explore_posts(self) -> Iterator[Post]:
|
||||||
@@ -854,24 +901,18 @@ class Instaloader:
|
|||||||
data.get('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.
|
||||||
has_next_page = True
|
|
||||||
end_cursor = None
|
|
||||||
while has_next_page:
|
|
||||||
if end_cursor:
|
|
||||||
params = {'__a': 1, 'max_id': end_cursor}
|
|
||||||
else:
|
|
||||||
params = {'__a': 1}
|
|
||||||
hashtag_data = self.context.get_json('explore/tags/{0}/'.format(hashtag),
|
|
||||||
params)['graphql']['hashtag']['edge_hashtag_to_media']
|
|
||||||
yield from (Post(self.context, edge['node']) for edge in hashtag_data['edges'])
|
|
||||||
has_next_page = hashtag_data['page_info']['has_next_page']
|
|
||||||
end_cursor = hashtag_data['page_info']['end_cursor']
|
|
||||||
|
|
||||||
def download_hashtag(self, hashtag: str,
|
.. deprecated:: 4.4
|
||||||
|
Use :meth:`Hashtag.get_posts`."""
|
||||||
|
return Hashtag.from_name(self.context, hashtag).get_posts()
|
||||||
|
|
||||||
|
def download_hashtag(self, hashtag: Union[Hashtag, str],
|
||||||
max_count: Optional[int] = None,
|
max_count: Optional[int] = None,
|
||||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||||
fast_update: bool = False) -> None:
|
fast_update: bool = False,
|
||||||
|
profile_pic: bool = True,
|
||||||
|
posts: bool = True) -> None:
|
||||||
"""Download pictures of one hashtag.
|
"""Download pictures of one hashtag.
|
||||||
|
|
||||||
To download the last 30 pictures with hashtag #cat, do::
|
To download the last 30 pictures with hashtag #cat, do::
|
||||||
@@ -879,26 +920,34 @@ class Instaloader:
|
|||||||
loader = Instaloader()
|
loader = Instaloader()
|
||||||
loader.download_hashtag('cat', max_count=30)
|
loader.download_hashtag('cat', max_count=30)
|
||||||
|
|
||||||
:param hashtag: Hashtag to download, without leading '#'
|
:param hashtag: Hashtag to download, as instance of :class:`Hashtag`, or string without leading '#'
|
||||||
: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
|
||||||
|
:param profile_pic: not :option:`--no-profile-pic`.
|
||||||
|
:param posts: not :option:`--no-posts`.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.4
|
||||||
|
Add parameters `profile_pic` and `posts`.
|
||||||
"""
|
"""
|
||||||
hashtag = hashtag.lower()
|
if isinstance(hashtag, str):
|
||||||
self.context.log("Retrieving pictures with hashtag {}...".format(hashtag))
|
with self.context.error_catcher("Get hashtag #{}".format(hashtag)):
|
||||||
count = 1
|
hashtag = Hashtag.from_name(self.context, hashtag)
|
||||||
for post in self.get_hashtag_posts(hashtag):
|
if not isinstance(hashtag, Hashtag):
|
||||||
if max_count is not None and count > max_count:
|
return
|
||||||
break
|
target = "#" + hashtag.name
|
||||||
self.context.log('[{0:3d}] #{1} '.format(count, hashtag), end='', flush=True)
|
if profile_pic:
|
||||||
if post_filter is not None and not post_filter(post):
|
with self.context.error_catcher("Download profile picture of {}".format(target)):
|
||||||
self.context.log('<skipped>')
|
self.download_hashtag_profilepic(hashtag)
|
||||||
continue
|
if posts:
|
||||||
count += 1
|
self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name))
|
||||||
with self.context.error_catcher('Download hashtag #{}'.format(hashtag)):
|
self.posts_download_loop(hashtag.get_all_posts(), target, fast_update, post_filter,
|
||||||
downloaded = self.download_post(post, target='#' + hashtag)
|
max_count=max_count)
|
||||||
if fast_update and not downloaded:
|
if self.save_metadata:
|
||||||
break
|
json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target,
|
||||||
|
target=target),
|
||||||
|
target)
|
||||||
|
self.save_metadata_json(json_filename, hashtag)
|
||||||
|
|
||||||
def download_tagged(self, profile: Profile, fast_update: bool = False,
|
def download_tagged(self, profile: Profile, fast_update: bool = False,
|
||||||
target: Optional[str] = None,
|
target: Optional[str] = None,
|
||||||
@@ -907,17 +956,11 @@ class Instaloader:
|
|||||||
|
|
||||||
.. versionadded:: 4.1"""
|
.. versionadded:: 4.1"""
|
||||||
self.context.log("Retrieving tagged posts for profile {}.".format(profile.username))
|
self.context.log("Retrieving tagged posts for profile {}.".format(profile.username))
|
||||||
count = 1
|
self.posts_download_loop(profile.get_tagged_posts(),
|
||||||
for post in profile.get_tagged_posts():
|
target if target
|
||||||
self.context.log("[%3i/???] " % (count), end="", flush=True)
|
else (Path(_PostPathFormatter.sanitize_path(profile.username)) /
|
||||||
count += 1
|
_PostPathFormatter.sanitize_path(':tagged')),
|
||||||
if post_filter is not None and not post_filter(post):
|
fast_update, post_filter)
|
||||||
self.context.log('<{} skipped>'.format(post))
|
|
||||||
continue
|
|
||||||
with self.context.error_catcher('Download tagged {}'.format(profile.username)):
|
|
||||||
downloaded = self.download_post(post, target if target else Path(profile.username) / Path(':tagged'))
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
def download_igtv(self, profile: Profile, fast_update: bool = False,
|
def download_igtv(self, profile: Profile, fast_update: bool = False,
|
||||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||||
@@ -925,24 +968,18 @@ class Instaloader:
|
|||||||
|
|
||||||
.. versionadded:: 4.3"""
|
.. versionadded:: 4.3"""
|
||||||
self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username))
|
self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username))
|
||||||
for number, post in enumerate(profile.get_igtv_posts()):
|
self.posts_download_loop(profile.get_igtv_posts(), profile.username, fast_update, post_filter,
|
||||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number+1, profile.igtvcount, w=len(str(profile.igtvcount))),
|
total_count=profile.igtvcount)
|
||||||
end="", flush=True)
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log('<{} skipped>'.format(post))
|
|
||||||
continue
|
|
||||||
with self.context.error_catcher('Download IGTV {}'.format(post.shortcode)):
|
|
||||||
downloaded = self.download_post(post, target=profile.username)
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
def _get_id_filename(self, profile_name: str) -> str:
|
def _get_id_filename(self, profile_name: str) -> str:
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||||
return '{0}/id'.format(self.dirname_pattern.format(profile=profile_name.lower(),
|
return os.path.join(self.dirname_pattern.format(profile=profile_name.lower(),
|
||||||
target=profile_name.lower()))
|
target=profile_name.lower()),
|
||||||
|
'id')
|
||||||
else:
|
else:
|
||||||
return '{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower())
|
return os.path.join(self.dirname_pattern.format(),
|
||||||
|
'{0}_id'.format(profile_name.lower()))
|
||||||
|
|
||||||
def save_profile_id(self, profile: Profile):
|
def save_profile_id(self, profile: Profile):
|
||||||
"""
|
"""
|
||||||
@@ -1056,9 +1093,9 @@ class Instaloader:
|
|||||||
|
|
||||||
# Save metadata as JSON if desired.
|
# Save metadata as JSON if desired.
|
||||||
if self.save_metadata:
|
if self.save_metadata:
|
||||||
json_filename = '{0}/{1}_{2}'.format(self.dirname_pattern.format(profile=profile_name,
|
json_filename = os.path.join(self.dirname_pattern.format(profile=profile_name,
|
||||||
target=profile_name),
|
target=profile_name),
|
||||||
profile_name, profile.userid)
|
'{0}_{1}'.format(profile_name, profile.userid))
|
||||||
self.save_metadata_json(json_filename, profile)
|
self.save_metadata_json(json_filename, profile)
|
||||||
|
|
||||||
# Catch some errors
|
# Catch some errors
|
||||||
@@ -1086,32 +1123,8 @@ class Instaloader:
|
|||||||
# Iterate over pictures and download them
|
# Iterate over pictures and download them
|
||||||
if posts:
|
if posts:
|
||||||
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
||||||
totalcount = profile.mediacount
|
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
||||||
count = 1
|
total_count=profile.mediacount)
|
||||||
for post in profile.get_posts():
|
|
||||||
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
|
|
||||||
count += 1
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log('<skipped>')
|
|
||||||
continue
|
|
||||||
with self.context.error_catcher("Download {} of {}".format(post, profile_name)):
|
|
||||||
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
|
|
||||||
# additional metadata. This is most likely the case if a HTTP redirect takes place while
|
|
||||||
# resolving the shortcode URL.
|
|
||||||
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
|
|
||||||
# obained after a redirect has probably already been downloaded as a previous Post of the
|
|
||||||
# same Profile.
|
|
||||||
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
|
|
||||||
post_changed = False
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
downloaded = self.download_post(post, target=profile_name)
|
|
||||||
break
|
|
||||||
except PostChangedException:
|
|
||||||
post_changed = True
|
|
||||||
continue
|
|
||||||
if fast_update and not downloaded and not post_changed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if stories and profiles:
|
if stories and profiles:
|
||||||
with self.context.error_catcher("Download stories"):
|
with self.context.error_catcher("Download stories"):
|
||||||
@@ -1190,18 +1203,8 @@ class Instaloader:
|
|||||||
|
|
||||||
# Iterate over pictures and download them
|
# Iterate over pictures and download them
|
||||||
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
||||||
totalcount = profile.mediacount
|
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
||||||
count = 1
|
total_count=profile.mediacount)
|
||||||
for post in profile.get_posts():
|
|
||||||
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
|
|
||||||
count += 1
|
|
||||||
if post_filter is not None and not post_filter(post):
|
|
||||||
self.context.log('<skipped>')
|
|
||||||
continue
|
|
||||||
with self.context.error_catcher('Download profile {}'.format(profile_name)):
|
|
||||||
downloaded = self.download_post(post, target=profile_name)
|
|
||||||
if fast_update and not downloaded:
|
|
||||||
break
|
|
||||||
|
|
||||||
def check_if_committed(self, filename: str) -> bool:
|
def check_if_committed(self, filename: str) -> bool:
|
||||||
"""Checks to see if the current post has been committed.
|
"""Checks to see if the current post has been committed.
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class InstaloaderContext:
|
|||||||
def close(self):
|
def close(self):
|
||||||
"""Print error log and close session"""
|
"""Print error log and close session"""
|
||||||
if self.error_log and not self.quiet:
|
if self.error_log and not self.quiet:
|
||||||
print("\nErrors occured:", file=sys.stderr)
|
print("\nErrors or warnings occurred:", file=sys.stderr)
|
||||||
for err in self.error_log:
|
for err in self.error_log:
|
||||||
print(err, file=sys.stderr)
|
print(err, file=sys.stderr)
|
||||||
self._session.close()
|
self._session.close()
|
||||||
@@ -215,8 +215,11 @@ class InstaloaderContext:
|
|||||||
session.headers.update({'X-CSRFToken': csrf_token})
|
session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
# Not using self.get_json() here, because we need to access csrftoken cookie
|
# Not using self.get_json() here, because we need to access csrftoken cookie
|
||||||
self.do_sleep()
|
self.do_sleep()
|
||||||
|
# Workaround credits to pgrimaud.
|
||||||
|
# See: https://github.com/pgrimaud/instagram-user-feed/commit/96ad4cf54d1ad331b337f325c73e664999a6d066
|
||||||
|
enc_password = '#PWD_INSTAGRAM_BROWSER:0:{}:{}'.format(int(datetime.now().timestamp()), passwd)
|
||||||
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
||||||
data={'password': passwd, 'username': user}, allow_redirects=True)
|
data={'enc_password': enc_password, 'username': user}, allow_redirects=True)
|
||||||
try:
|
try:
|
||||||
resp_json = login.json()
|
resp_json = login.json()
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
@@ -349,7 +352,7 @@ class InstaloaderContext:
|
|||||||
else:
|
else:
|
||||||
self._graphql_query_timestamps[query_hash].append(time.monotonic())
|
self._graphql_query_timestamps[query_hash].append(time.monotonic())
|
||||||
else:
|
else:
|
||||||
text_for_429 = ("HTTP error code 429 was returned because too many queries occured in the last time. "
|
text_for_429 = ("HTTP error code 429 was returned because too many queries occurred in the last time. "
|
||||||
"Please do not use Instagram in your browser or run multiple instances of Instaloader "
|
"Please do not use Instagram in your browser or run multiple instances of Instaloader "
|
||||||
"in parallel.")
|
"in parallel.")
|
||||||
print(textwrap.fill(text_for_429), file=sys.stderr)
|
print(textwrap.fill(text_for_429), file=sys.stderr)
|
||||||
@@ -533,8 +536,8 @@ 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:
|
||||||
tempsession.headers['User-Agent'] = 'Instagram 10.3.2 (iPhone7,2; iPhone OS 9_3_3; en_US; en-US; ' \
|
tempsession.headers['User-Agent'] = 'Instagram 123.1.0.26.115 (iPhone12,1; iOS 13_3; en_US; en-US; ' \
|
||||||
'scale=2.00; 750x1334) AppleWebKit/420+'
|
'scale=2.00; 1656x3584; 190542906)'
|
||||||
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
||||||
tempsession.headers.pop(header, None)
|
tempsession.headers.pop(header, None)
|
||||||
return self.get_json(path, params, 'i.instagram.com', tempsession)
|
return self.get_json(path, params, 'i.instagram.com', tempsession)
|
||||||
|
|||||||
+282
-49
@@ -69,8 +69,11 @@ class Post:
|
|||||||
self._node = node
|
self._node = node
|
||||||
self._owner_profile = owner_profile
|
self._owner_profile = owner_profile
|
||||||
self._full_metadata_dict = None # type: Optional[Dict[str, Any]]
|
self._full_metadata_dict = None # type: Optional[Dict[str, Any]]
|
||||||
self._rhx_gis_str = None # type: Optional[str]
|
|
||||||
self._location = None # type: Optional[PostLocation]
|
self._location = None # type: Optional[PostLocation]
|
||||||
|
self._iphone_struct_ = None
|
||||||
|
if 'iphone_struct' in node:
|
||||||
|
# if loaded from JSON with load_structure_from_file()
|
||||||
|
self._iphone_struct_ = node['iphone_struct']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_shortcode(cls, context: InstaloaderContext, shortcode: str):
|
def from_shortcode(cls, context: InstaloaderContext, shortcode: str):
|
||||||
@@ -111,6 +114,8 @@ class Post:
|
|||||||
node['owner'] = self.owner_profile._asdict()
|
node['owner'] = self.owner_profile._asdict()
|
||||||
if self._location:
|
if self._location:
|
||||||
node['location'] = self._location._asdict()
|
node['location'] = self._location._asdict()
|
||||||
|
if self._iphone_struct_:
|
||||||
|
node['iphone_struct'] = self._iphone_struct_
|
||||||
return node
|
return node
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -136,9 +141,11 @@ class Post:
|
|||||||
|
|
||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
if not self._full_metadata_dict:
|
if not self._full_metadata_dict:
|
||||||
pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={})
|
pic_json = self._context.graphql_query(
|
||||||
self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media']
|
'2b0673e0dc4580674a88d426fe00ea90',
|
||||||
self._rhx_gis_str = pic_json.get('rhx_gis')
|
{'shortcode': self.shortcode}
|
||||||
|
)
|
||||||
|
self._full_metadata_dict = pic_json['data']['shortcode_media']
|
||||||
if self._full_metadata_dict is None:
|
if self._full_metadata_dict is None:
|
||||||
# issue #449
|
# issue #449
|
||||||
self._context.error("Fetching Post metadata failed (issue #449). "
|
self._context.error("Fetching Post metadata failed (issue #449). "
|
||||||
@@ -156,9 +163,13 @@ class Post:
|
|||||||
return self._full_metadata_dict
|
return self._full_metadata_dict
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _rhx_gis(self) -> Optional[str]:
|
def _iphone_struct(self) -> Dict[str, Any]:
|
||||||
self._obtain_metadata()
|
if not self._context.is_logged_in:
|
||||||
return self._rhx_gis_str
|
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
||||||
|
if not self._iphone_struct_:
|
||||||
|
data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={})
|
||||||
|
self._iphone_struct_ = data['items'][0]
|
||||||
|
return self._iphone_struct_
|
||||||
|
|
||||||
def _field(self, *keys) -> Any:
|
def _field(self, *keys) -> Any:
|
||||||
"""Lookups given fields in _node, and if not found in _full_metadata. Raises KeyError if not found anywhere."""
|
"""Lookups given fields in _node, and if not found in _full_metadata. Raises KeyError if not found anywhere."""
|
||||||
@@ -225,6 +236,13 @@ class Post:
|
|||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
"""URL of the picture / video thumbnail of the post"""
|
"""URL of the picture / video thumbnail of the post"""
|
||||||
|
if self.typename == "GraphImage" and self._context.is_logged_in:
|
||||||
|
try:
|
||||||
|
orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url']
|
||||||
|
url = re.sub(r'&se=\d+(&?)', r'\1', orig_url)
|
||||||
|
return url
|
||||||
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
|
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
||||||
return self._node["display_url"] if "display_url" in self._node else self._node["display_src"]
|
return self._node["display_url"] if "display_url" in self._node else self._node["display_src"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -239,10 +257,18 @@ class Post:
|
|||||||
if any(edge['node']['is_video'] for edge in edges):
|
if any(edge['node']['is_video'] for edge in edges):
|
||||||
# video_url is only present in full metadata, issue #558.
|
# video_url is only present in full metadata, issue #558.
|
||||||
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
||||||
for edge in edges:
|
for idx, edge in enumerate(edges):
|
||||||
node = edge['node']
|
node = edge['node']
|
||||||
is_video = node['is_video']
|
is_video = node['is_video']
|
||||||
yield PostSidecarNode(is_video=is_video, display_url=node['display_url'],
|
display_url = node['display_url']
|
||||||
|
if not is_video and self._context.is_logged_in:
|
||||||
|
try:
|
||||||
|
carousel_media = self._iphone_struct['carousel_media']
|
||||||
|
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
||||||
|
display_url = re.sub(r'&se=\d+(&?)', r'\1', orig_url)
|
||||||
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
|
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
||||||
|
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
||||||
video_url=node['video_url'] if is_video else None)
|
video_url=node['video_url'] if is_video else None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -362,7 +388,7 @@ class Post:
|
|||||||
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'])
|
likes_count=node.get('edge_liked_by', {}).get('count', 0))
|
||||||
|
|
||||||
def _postcommentanswers(node):
|
def _postcommentanswers(node):
|
||||||
if 'edge_threaded_comments' not in node:
|
if 'edge_threaded_comments' not in node:
|
||||||
@@ -388,14 +414,9 @@ class Post:
|
|||||||
if self.comments == 0:
|
if self.comments == 0:
|
||||||
# Avoid doing additional requests if there are no comments
|
# Avoid doing additional requests if there are no comments
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
comment_edges = self._field('edge_media_to_parent_comment', 'edges')
|
comment_edges = self._field('edge_media_to_comment', 'edges')
|
||||||
answers_count = sum([edge['node']['edge_threaded_comments']['count'] for edge in comment_edges])
|
answers_count = sum([edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges])
|
||||||
threaded_comments_available = True
|
|
||||||
except KeyError:
|
|
||||||
comment_edges = self._field('edge_media_to_comment', 'edges')
|
|
||||||
answers_count = 0
|
|
||||||
threaded_comments_available = False
|
|
||||||
|
|
||||||
if self.comments == len(comment_edges) + answers_count:
|
if self.comments == len(comment_edges) + answers_count:
|
||||||
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
|
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
|
||||||
@@ -403,14 +424,10 @@ class Post:
|
|||||||
return
|
return
|
||||||
yield from (_postcomment(node) for node in
|
yield from (_postcomment(node) for node in
|
||||||
self._context.graphql_node_list(
|
self._context.graphql_node_list(
|
||||||
"97b41c52301f77ce508f55e66d17620e" if threaded_comments_available
|
"97b41c52301f77ce508f55e66d17620e",
|
||||||
else "f0986789a5c5d17c2400faebf16efd0d",
|
|
||||||
{'shortcode': self.shortcode},
|
{'shortcode': self.shortcode},
|
||||||
'https://www.instagram.com/p/' + self.shortcode + '/',
|
'https://www.instagram.com/p/' + self.shortcode + '/',
|
||||||
lambda d:
|
lambda d: d['data']['shortcode_media']['edge_media_to_parent_comment']))
|
||||||
d['data']['shortcode_media'][
|
|
||||||
'edge_media_to_parent_comment' if threaded_comments_available else 'edge_media_to_comment'],
|
|
||||||
self._rhx_gis))
|
|
||||||
|
|
||||||
def get_likes(self) -> Iterator['Profile']:
|
def get_likes(self) -> Iterator['Profile']:
|
||||||
"""Iterate over all likes of the post. A :class:`Profile` instance of each likee is yielded."""
|
"""Iterate over all likes of the post. A :class:`Profile` instance of each likee is yielded."""
|
||||||
@@ -425,8 +442,31 @@ class Post:
|
|||||||
yield from (Profile(self._context, node) for node in
|
yield from (Profile(self._context, node) for node in
|
||||||
self._context.graphql_node_list("1cb6ec562846122743b61e492c85999f", {'shortcode': self.shortcode},
|
self._context.graphql_node_list("1cb6ec562846122743b61e492c85999f", {'shortcode': self.shortcode},
|
||||||
'https://www.instagram.com/p/' + self.shortcode + '/',
|
'https://www.instagram.com/p/' + self.shortcode + '/',
|
||||||
lambda d: d['data']['shortcode_media']['edge_liked_by'],
|
lambda d: d['data']['shortcode_media']['edge_liked_by']))
|
||||||
self._rhx_gis))
|
|
||||||
|
@property
|
||||||
|
def is_sponsored(self) -> bool:
|
||||||
|
"""
|
||||||
|
Whether Post is a sponsored post, equivalent to non-empty :meth:`Post.sponsor_users`.
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sponsor_edges = self._field('edge_media_to_sponsor_user', 'edges')
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
return bool(sponsor_edges)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sponsor_users(self) -> List['Profile']:
|
||||||
|
"""
|
||||||
|
The Post's sponsors.
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
"""
|
||||||
|
return ([] if not self.is_sponsored else
|
||||||
|
[Profile(self._context, edge['node']['sponsor']) for edge in
|
||||||
|
self._field('edge_media_to_sponsor_user', 'edges')])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def location(self) -> Optional[PostLocation]:
|
def location(self) -> Optional[PostLocation]:
|
||||||
@@ -483,7 +523,6 @@ class Profile:
|
|||||||
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._has_full_metadata = False
|
||||||
self._rhx_gis = None
|
|
||||||
self._iphone_struct_ = None
|
self._iphone_struct_ = None
|
||||||
if 'iphone_struct' in node:
|
if 'iphone_struct' in node:
|
||||||
# if loaded from JSON with load_structure_from_file()
|
# if loaded from JSON with load_structure_from_file()
|
||||||
@@ -545,10 +584,9 @@ class Profile:
|
|||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
try:
|
try:
|
||||||
if not self._has_full_metadata:
|
if not self._has_full_metadata:
|
||||||
metadata = self._context.get_json('{}/'.format(self.username), params={})
|
metadata = self._context.get_json('{}/feed/'.format(self.username), params={})
|
||||||
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
|
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
|
||||||
self._has_full_metadata = True
|
self._has_full_metadata = True
|
||||||
self._rhx_gis = metadata.get('rhx_gis')
|
|
||||||
except (QueryReturnedNotFoundException, KeyError) as err:
|
except (QueryReturnedNotFoundException, KeyError) as err:
|
||||||
top_search_results = TopSearchResults(self._context, self.username)
|
top_search_results = TopSearchResults(self._context, self.username)
|
||||||
similar_profiles = [profile.username for profile in top_search_results.get_profiles()]
|
similar_profiles = [profile.username for profile in top_search_results.get_profiles()]
|
||||||
@@ -630,6 +668,16 @@ class Profile:
|
|||||||
def external_url(self) -> Optional[str]:
|
def external_url(self) -> Optional[str]:
|
||||||
return self._metadata('external_url')
|
return self._metadata('external_url')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_business_account(self) -> bool:
|
||||||
|
""".. versionadded:: 4.4"""
|
||||||
|
return self._metadata('is_business_account')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def business_category_name(self) -> str:
|
||||||
|
""".. versionadded:: 4.4"""
|
||||||
|
return self._metadata('business_category_name')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def biography(self) -> str:
|
def biography(self) -> str:
|
||||||
return self._metadata('biography')
|
return self._metadata('biography')
|
||||||
@@ -671,8 +719,7 @@ class Profile:
|
|||||||
'include_reel': False, 'include_suggested_users': False,
|
'include_reel': False, 'include_suggested_users': False,
|
||||||
'include_logged_out_extras': True,
|
'include_logged_out_extras': True,
|
||||||
'include_highlight_reels': False},
|
'include_highlight_reels': False},
|
||||||
'https://www.instagram.com/{}/'.format(self.username),
|
'https://www.instagram.com/{}/'.format(self.username))
|
||||||
self._rhx_gis)
|
|
||||||
self._has_public_story = data['data']['user']['has_public_story']
|
self._has_public_story = data['data']['user']['has_public_story']
|
||||||
assert self._has_public_story is not None
|
assert self._has_public_story is not None
|
||||||
return self._has_public_story
|
return self._has_public_story
|
||||||
@@ -731,8 +778,7 @@ class Profile:
|
|||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
||||||
self._rhx_gis,
|
first_data=self._metadata('edge_owner_to_timeline_media')))
|
||||||
self._metadata('edge_owner_to_timeline_media')))
|
|
||||||
|
|
||||||
def get_saved_posts(self) -> Iterator[Post]:
|
def get_saved_posts(self) -> Iterator[Post]:
|
||||||
"""Get Posts that are marked as saved by the user."""
|
"""Get Posts that are marked as saved by the user."""
|
||||||
@@ -746,8 +792,7 @@ class Profile:
|
|||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
lambda d: d['data']['user']['edge_saved_media'],
|
lambda d: d['data']['user']['edge_saved_media'],
|
||||||
self._rhx_gis,
|
first_data=self._metadata('edge_saved_media')))
|
||||||
self._metadata('edge_saved_media')))
|
|
||||||
|
|
||||||
def get_tagged_posts(self) -> Iterator[Post]:
|
def get_tagged_posts(self) -> Iterator[Post]:
|
||||||
"""Retrieve all posts where a profile is tagged.
|
"""Retrieve all posts where a profile is tagged.
|
||||||
@@ -758,8 +803,7 @@ class Profile:
|
|||||||
self._context.graphql_node_list("e31a871f7301132ceaab56507a66bbb7",
|
self._context.graphql_node_list("e31a871f7301132ceaab56507a66bbb7",
|
||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
lambda d: d['data']['user']['edge_user_to_photos_of_you'],
|
lambda d: d['data']['user']['edge_user_to_photos_of_you']))
|
||||||
self._rhx_gis))
|
|
||||||
|
|
||||||
def get_igtv_posts(self) -> Iterator[Post]:
|
def get_igtv_posts(self) -> Iterator[Post]:
|
||||||
"""Retrieve all IGTV posts.
|
"""Retrieve all IGTV posts.
|
||||||
@@ -771,8 +815,7 @@ class Profile:
|
|||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
||||||
lambda d: d['data']['user']['edge_felix_video_timeline'],
|
lambda d: d['data']['user']['edge_felix_video_timeline'],
|
||||||
self._rhx_gis,
|
first_data=self._metadata('edge_felix_video_timeline')))
|
||||||
self._metadata('edge_felix_video_timeline')))
|
|
||||||
|
|
||||||
def get_followers(self) -> Iterator['Profile']:
|
def get_followers(self) -> Iterator['Profile']:
|
||||||
"""
|
"""
|
||||||
@@ -786,8 +829,7 @@ class Profile:
|
|||||||
self._context.graphql_node_list("37479f2b8209594dde7facb0d904896a",
|
self._context.graphql_node_list("37479f2b8209594dde7facb0d904896a",
|
||||||
{'id': str(self.userid)},
|
{'id': str(self.userid)},
|
||||||
'https://www.instagram.com/' + self.username + '/',
|
'https://www.instagram.com/' + self.username + '/',
|
||||||
lambda d: d['data']['user']['edge_followed_by'],
|
lambda d: d['data']['user']['edge_followed_by']))
|
||||||
self._rhx_gis))
|
|
||||||
|
|
||||||
def get_followees(self) -> Iterator['Profile']:
|
def get_followees(self) -> Iterator['Profile']:
|
||||||
"""
|
"""
|
||||||
@@ -801,8 +843,23 @@ class Profile:
|
|||||||
self._context.graphql_node_list("58712303d941c6855d4e888c5f0cd22f",
|
self._context.graphql_node_list("58712303d941c6855d4e888c5f0cd22f",
|
||||||
{'id': str(self.userid)},
|
{'id': str(self.userid)},
|
||||||
'https://www.instagram.com/' + self.username + '/',
|
'https://www.instagram.com/' + self.username + '/',
|
||||||
lambda d: d['data']['user']['edge_follow'],
|
lambda d: d['data']['user']['edge_follow']))
|
||||||
self._rhx_gis))
|
|
||||||
|
def get_similar_accounts(self) -> Iterator['Profile']:
|
||||||
|
"""
|
||||||
|
Retrieve list of suggested / similar accounts for this profile.
|
||||||
|
To use this, one needs to be logged in.
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
"""
|
||||||
|
if not self._context.is_logged_in:
|
||||||
|
raise LoginRequiredException("--login required to get a profile's similar accounts.")
|
||||||
|
self._obtain_metadata()
|
||||||
|
yield from (Profile(self._context, edge["node"]) for edge in
|
||||||
|
self._context.graphql_query("ad99dd9d3646cc3c0dda65debcd266a7",
|
||||||
|
{"user_id": str(self.userid), "include_chaining": True},
|
||||||
|
"https://www.instagram.com/{0}/"
|
||||||
|
.format(self.username))["data"]["user"]["edge_chaining"]["edges"])
|
||||||
|
|
||||||
|
|
||||||
class StoryItem:
|
class StoryItem:
|
||||||
@@ -1100,6 +1157,169 @@ class Highlight(Story):
|
|||||||
yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items)
|
yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items)
|
||||||
|
|
||||||
|
|
||||||
|
class Hashtag:
|
||||||
|
"""
|
||||||
|
An Hashtag.
|
||||||
|
|
||||||
|
Analogous to :class:`Profile`, get an instance with::
|
||||||
|
|
||||||
|
L = Instaloader()
|
||||||
|
hashtag = Hashtag.from_name(L.context, HASHTAG)
|
||||||
|
|
||||||
|
To then download the Hashtag's Posts, do::
|
||||||
|
|
||||||
|
for post in hashtag.get_posts():
|
||||||
|
L.download_post(post, target="#"+hashtag.name)
|
||||||
|
|
||||||
|
Also, this class implements == and is hashable.
|
||||||
|
"""
|
||||||
|
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
|
||||||
|
assert "name" in node
|
||||||
|
self._context = context
|
||||||
|
self._node = node
|
||||||
|
self._has_full_metadata = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_name(cls, context: InstaloaderContext, name: str):
|
||||||
|
"""
|
||||||
|
Create a Hashtag instance from a given hashtag name, without preceeding '#'. Raises an Exception if there is no
|
||||||
|
hashtag with the given name.
|
||||||
|
|
||||||
|
:param context: :attr:`Instaloader.context`
|
||||||
|
:param name: Hashtag, without preceeding '#'
|
||||||
|
:raises: :class:`QueryReturnedNotFoundException`
|
||||||
|
"""
|
||||||
|
# pylint:disable=protected-access
|
||||||
|
hashtag = cls(context, {'name': name.lower()})
|
||||||
|
hashtag._obtain_metadata()
|
||||||
|
return hashtag
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Hashtag name lowercased, without preceeding '#'"""
|
||||||
|
return self._node["name"].lower()
|
||||||
|
|
||||||
|
def _query(self, params):
|
||||||
|
return self._context.get_json("explore/tags/{0}/".format(self.name),
|
||||||
|
params)["graphql"]["hashtag"]
|
||||||
|
|
||||||
|
def _obtain_metadata(self):
|
||||||
|
if not self._has_full_metadata:
|
||||||
|
self._node = self._query({"__a": 1})
|
||||||
|
self._has_full_metadata = True
|
||||||
|
|
||||||
|
def _asdict(self):
|
||||||
|
json_node = self._node.copy()
|
||||||
|
# remove posts
|
||||||
|
json_node.pop("edge_hashtag_to_top_posts", None)
|
||||||
|
json_node.pop("edge_hashtag_to_media", None)
|
||||||
|
return json_node
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "<Hashtag #{}>".format(self.name)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if isinstance(other, Hashtag):
|
||||||
|
return self.name == other.name
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash(self.name)
|
||||||
|
|
||||||
|
def _metadata(self, *keys) -> Any:
|
||||||
|
try:
|
||||||
|
d = self._node
|
||||||
|
for key in keys:
|
||||||
|
d = d[key]
|
||||||
|
return d
|
||||||
|
except KeyError:
|
||||||
|
self._obtain_metadata()
|
||||||
|
d = self._node
|
||||||
|
for key in keys:
|
||||||
|
d = d[key]
|
||||||
|
return d
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hashtagid(self) -> int:
|
||||||
|
return int(self._metadata("id"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_pic_url(self) -> str:
|
||||||
|
return self._metadata("profile_pic_url")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return self._metadata("description")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allow_following(self) -> bool:
|
||||||
|
return self._metadata("allow_following")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_following(self) -> bool:
|
||||||
|
return self._metadata("is_following")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_top_media_only(self) -> bool:
|
||||||
|
return self._metadata("is_top_media_only")
|
||||||
|
|
||||||
|
def get_related_tags(self) -> Iterator["Hashtag"]:
|
||||||
|
"""Yields similar hashtags."""
|
||||||
|
yield from (Hashtag(self._context, edge["node"])
|
||||||
|
for edge in self._metadata("edge_hashtag_to_related_tags", "edges"))
|
||||||
|
|
||||||
|
def get_top_posts(self) -> Iterator[Post]:
|
||||||
|
"""Yields the top posts of the hashtag."""
|
||||||
|
yield from (Post(self._context, edge["node"])
|
||||||
|
for edge in self._metadata("edge_hashtag_to_top_posts", "edges"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mediacount(self) -> int:
|
||||||
|
"""
|
||||||
|
The count of all media associated with this hashtag.
|
||||||
|
|
||||||
|
The number of posts with a certain hashtag may differ from the number of posts that can actually be accessed, as
|
||||||
|
the hashtag count might include private posts
|
||||||
|
"""
|
||||||
|
return self._metadata("edge_hashtag_to_media", "count")
|
||||||
|
|
||||||
|
def get_posts(self) -> Iterator[Post]:
|
||||||
|
"""Yields the posts associated with this hashtag."""
|
||||||
|
self._metadata("edge_hashtag_to_media", "edges")
|
||||||
|
self._metadata("edge_hashtag_to_media", "page_info")
|
||||||
|
conn = self._metadata("edge_hashtag_to_media")
|
||||||
|
yield from (Post(self._context, edge["node"]) for edge in conn["edges"])
|
||||||
|
while conn["page_info"]["has_next_page"]:
|
||||||
|
data = self._query({'__a': 1, 'max_id': conn["page_info"]["end_cursor"]})
|
||||||
|
conn = data["edge_hashtag_to_media"]
|
||||||
|
yield from (Post(self._context, edge["node"]) for edge in conn["edges"])
|
||||||
|
|
||||||
|
def get_all_posts(self) -> Iterator[Post]:
|
||||||
|
"""Yields all posts, i.e. all most recent posts and the top posts, in chronological order."""
|
||||||
|
sorted_top_posts = iter(sorted(self.get_top_posts(), key=lambda p: p.date_utc, reverse=True))
|
||||||
|
other_posts = self.get_posts()
|
||||||
|
next_top = next(sorted_top_posts, None)
|
||||||
|
next_other = next(other_posts, None)
|
||||||
|
while next_top is not None or next_other is not None:
|
||||||
|
if next_other is None:
|
||||||
|
yield from sorted_top_posts
|
||||||
|
break
|
||||||
|
if next_top is None:
|
||||||
|
yield from other_posts
|
||||||
|
break
|
||||||
|
if next_top == next_other:
|
||||||
|
yield next_top
|
||||||
|
next_top = next(sorted_top_posts, None)
|
||||||
|
next_other = next(other_posts, None)
|
||||||
|
continue
|
||||||
|
if next_top.date_utc > next_other.date_utc:
|
||||||
|
yield next_top
|
||||||
|
next_top = next(sorted_top_posts, None)
|
||||||
|
else:
|
||||||
|
yield next_other
|
||||||
|
next_other = next(other_posts, None)
|
||||||
|
|
||||||
|
|
||||||
class TopSearchResults:
|
class TopSearchResults:
|
||||||
"""
|
"""
|
||||||
An invocation of this class triggers a search on Instagram for the provided search string.
|
An invocation of this class triggers a search on Instagram for the provided search string.
|
||||||
@@ -1159,6 +1379,17 @@ class TopSearchResults:
|
|||||||
if name:
|
if name:
|
||||||
yield name
|
yield name
|
||||||
|
|
||||||
|
def get_hashtags(self) -> Iterator[Hashtag]:
|
||||||
|
"""
|
||||||
|
Provides the hashtags from the search result.
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
"""
|
||||||
|
for hashtag in self._node.get('hashtags', []):
|
||||||
|
node = hashtag.get('hashtag', {})
|
||||||
|
if 'name' in node:
|
||||||
|
yield Hashtag(self._context, node)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def searchstring(self) -> str:
|
def searchstring(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -1167,17 +1398,17 @@ class TopSearchResults:
|
|||||||
return self._searchstring
|
return self._searchstring
|
||||||
|
|
||||||
|
|
||||||
JsonExportable = Union[Post, Profile, StoryItem]
|
JsonExportable = Union[Post, Profile, StoryItem, Hashtag]
|
||||||
|
|
||||||
|
|
||||||
def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
||||||
"""Saves a :class:`Post`, :class:`Profile` or :class:`StoryItem` to a '.json' or '.json.xz' file such that it can
|
"""Saves a :class:`Post`, :class:`Profile`, :class:`StoryItem` or :class:`Hashtag` to a '.json' or '.json.xz' file
|
||||||
later be loaded by :func:`load_structure_from_file`.
|
such that it can later be loaded by :func:`load_structure_from_file`.
|
||||||
|
|
||||||
If the specified filename ends in '.xz', the file will be LZMA compressed. Otherwise, a pretty-printed JSON file
|
If the specified filename ends in '.xz', the file will be LZMA compressed. Otherwise, a pretty-printed JSON file
|
||||||
will be created.
|
will be created.
|
||||||
|
|
||||||
:param structure: :class:`Post`, :class:`Profile` or :class:`StoryItem`
|
:param structure: :class:`Post`, :class:`Profile`, :class:`StoryItem` or :class:`Hashtag`
|
||||||
:param filename: Filename, ends in '.json' or '.json.xz'
|
:param filename: Filename, ends in '.json' or '.json.xz'
|
||||||
"""
|
"""
|
||||||
json_structure = {'node': structure._asdict(),
|
json_structure = {'node': structure._asdict(),
|
||||||
@@ -1192,8 +1423,8 @@ def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def load_structure_from_file(context: InstaloaderContext, filename: str) -> JsonExportable:
|
def load_structure_from_file(context: InstaloaderContext, filename: str) -> JsonExportable:
|
||||||
"""Loads a :class:`Post`, :class:`Profile` or :class:`StoryItem` from a '.json' or '.json.xz' file that
|
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem` or :class:`Hashtag` from a '.json' or '.json.xz' file
|
||||||
has been saved by :func:`save_structure_to_file`.
|
that has been saved by :func:`save_structure_to_file`.
|
||||||
|
|
||||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
|
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
|
||||||
:param filename: Filename, ends in '.json' or '.json.xz'
|
:param filename: Filename, ends in '.json' or '.json.xz'
|
||||||
@@ -1214,6 +1445,8 @@ def load_structure_from_file(context: InstaloaderContext, filename: str) -> Json
|
|||||||
return Profile(context, json_structure['node'])
|
return Profile(context, json_structure['node'])
|
||||||
elif node_type == "StoryItem":
|
elif node_type == "StoryItem":
|
||||||
return StoryItem(context, json_structure['node'])
|
return StoryItem(context, json_structure['node'])
|
||||||
|
elif node_type == "Hashtag":
|
||||||
|
return Hashtag(context, json_structure['node'])
|
||||||
else:
|
else:
|
||||||
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
||||||
elif 'shortcode' in json_structure:
|
elif 'shortcode' in json_structure:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
self.L.download_hashtag(HASHTAG, NORMAL_MAX_COUNT)
|
self.L.download_hashtag(HASHTAG, NORMAL_MAX_COUNT)
|
||||||
|
|
||||||
def test_hashtag_paging(self):
|
def test_hashtag_paging(self):
|
||||||
for count, post in enumerate(self.L.get_hashtag_posts(HASHTAG)):
|
for count, post in enumerate(instaloader.Hashtag.from_name(self.L.context, HASHTAG).get_all_posts()):
|
||||||
print(post)
|
print(post)
|
||||||
if count == PAGING_MAX_COUNT:
|
if count == PAGING_MAX_COUNT:
|
||||||
break
|
break
|
||||||
@@ -94,6 +94,10 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
self.assertEqual(EMPTY_PROFILE.lower(),
|
self.assertEqual(EMPTY_PROFILE.lower(),
|
||||||
instaloader.Profile.from_id(self.L.context, EMPTY_PROFILE_ID).username)
|
instaloader.Profile.from_id(self.L.context, EMPTY_PROFILE_ID).username)
|
||||||
|
|
||||||
|
def test_get_username_by_name_empty(self):
|
||||||
|
self.assertEqual(EMPTY_PROFILE_ID,
|
||||||
|
instaloader.Profile.from_username(self.L.context, EMPTY_PROFILE).userid)
|
||||||
|
|
||||||
def test_post_from_mediaid(self):
|
def test_post_from_mediaid(self):
|
||||||
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
|
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
|
||||||
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
|
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
|
||||||
@@ -178,6 +182,11 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
|
|||||||
for f in profile.get_followers():
|
for f in profile.get_followers():
|
||||||
print(f.username)
|
print(f.username)
|
||||||
|
|
||||||
|
def test_get_similar_accounts(self):
|
||||||
|
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
|
||||||
|
for f in profile.get_similar_accounts():
|
||||||
|
print(f.username)
|
||||||
|
|
||||||
def test_get_likes(self):
|
def test_get_likes(self):
|
||||||
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
|
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
|
||||||
for like in post.get_likes():
|
for like in post.get_likes():
|
||||||
|
|||||||
Reference in New Issue
Block a user