mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-16 21:52:17 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a16a433e | ||
|
|
6145c5b383 | ||
|
|
8bc35cf3a1 | ||
|
|
e48251e75e | ||
|
|
ebdc5f1557 | ||
|
|
4c6a184390 | ||
|
|
ed9ca62a48 | ||
|
|
99c3960ad4 | ||
|
|
14124b617e | ||
|
|
b40a5aa243 | ||
|
|
cc8c9ff883 | ||
|
|
75d3ab69d1 | ||
|
|
79820bb442 | ||
|
|
f70a7eb4db | ||
|
|
2dca99b89e | ||
|
|
1074a74a59 | ||
|
|
aa59683bd1 | ||
|
|
6a3d763cf3 | ||
|
|
e2d42807a2 | ||
|
|
d07bd6a096 | ||
|
|
0462d5c34a | ||
|
|
1db182ec27 | ||
|
|
d09493e669 | ||
|
|
3cc29a4ceb | ||
|
|
ffb0aea5eb | ||
|
|
84bb60c155 | ||
|
|
7857909886 | ||
|
|
2206a7e5d7 | ||
|
|
f1b5e9cdf1 | ||
|
|
8debcbb7ab | ||
|
|
e97fd8a27c | ||
|
|
7f9d1618ad | ||
|
|
6a42906697 | ||
|
|
0570dd71d6 | ||
|
|
fb14c2699e | ||
|
|
8784ac7d9b | ||
|
|
4bb32e3c5a | ||
|
|
5e232fd4f5 | ||
|
|
da0dcb106c | ||
|
|
0c21da8c18 | ||
|
|
9b928b4679 | ||
|
|
8649a10113 | ||
|
|
d406de4f35 | ||
|
|
2d37e1dca2 | ||
|
|
37cbc5d72d | ||
|
|
862aa4e82c | ||
|
|
853e860363 | ||
|
|
90cfeaf471 | ||
|
|
5a24064012 | ||
|
|
644fab1f5e | ||
|
|
0704602e50 |
@@ -17,10 +17,10 @@ jobs:
|
||||
python-version: 3.8
|
||||
- name: "Install Dependencies"
|
||||
run: |
|
||||
python -m pip install pipenv==2022.1.8
|
||||
pipenv sync --dev
|
||||
python -m pip install pipenv==2023.2.4
|
||||
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
|
||||
- name: "Build Documentation"
|
||||
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
|
||||
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
|
||||
- name: "Deploy Documentation"
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
uses: JamesIves/github-pages-deploy-action@v4.2.5
|
||||
|
||||
@@ -7,8 +7,9 @@ jobs:
|
||||
name: PyLint and MyPy
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- name: Checkout Instaloader Repository
|
||||
uses: actions/checkout@v2
|
||||
@@ -18,9 +19,9 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install pipenv==2022.1.8
|
||||
pipenv sync --dev
|
||||
python -m pip install pipenv==2023.2.4
|
||||
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
|
||||
- name: PyLint
|
||||
run: pipenv run pylint instaloader
|
||||
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
|
||||
- name: MyPy
|
||||
run: pipenv run mypy -m instaloader
|
||||
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
stale-issue-message: 'There has been no activity on this question for an extended period of time. This issue will be closed after further 14 days of inactivity.'
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'leave open'
|
||||
days-before-stale: 15
|
||||
days-before-stale: 21
|
||||
days-before-close: -1
|
||||
remove-stale-when-updated: false
|
||||
- uses: actions/stale@v1
|
||||
@@ -30,5 +30,5 @@ jobs:
|
||||
stale-pr-label: 'stale'
|
||||
exempt-issue-label: 'leave open'
|
||||
exempt-pr-label: 'leave open'
|
||||
days-before-stale: 135
|
||||
days-before-stale: 189
|
||||
days-before-close: 14
|
||||
|
||||
@@ -14,6 +14,10 @@ psutil = "*"
|
||||
typing-extensions = "*"
|
||||
types-requests = "*"
|
||||
typed-ast = "*"
|
||||
jinja2 = "~=3.0.1"
|
||||
wrapt = "*" # Needed for Python<3.11
|
||||
tomli = "*" # Needed for Python<3.11
|
||||
dill = "*" # Needed for Python<3.11
|
||||
|
||||
[packages]
|
||||
requests = "*"
|
||||
|
||||
Generated
+570
-267
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -2,11 +2,7 @@
|
||||
|
||||
.. badges-start
|
||||
|
||||
|travis| |pypi| |pyversion| |license| |aur| |contributors| |downloads|
|
||||
|
||||
.. |travis| image:: https://img.shields.io/travis/instaloader/instaloader/master.svg
|
||||
:alt: Travis-CI Build Status
|
||||
:target: https://travis-ci.org/instaloader/instaloader
|
||||
|pypi| |pyversion| |license| |aur| |contributors| |downloads|
|
||||
|
||||
.. |pypi| image:: https://img.shields.io/pypi/v/instaloader.svg
|
||||
:alt: Instaloader PyPI Project Page
|
||||
@@ -127,6 +123,10 @@ Supporters
|
||||
|
||||
.. current-sponsors-start
|
||||
|
||||
| Instaloader is proudly sponsored by
|
||||
| `@rocketapi-io <https://github.com/rocketapi-io>`__
|
||||
| `@socialmethod <https://github.com/socialmethod>`__
|
||||
|
||||
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
|
||||
how you can sponsor the development of Instaloader!
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ pkgbase = instaloader
|
||||
arch = any
|
||||
license = MIT
|
||||
makedepends = python-setuptools
|
||||
depends = python>=3.6
|
||||
depends = python>=3.8
|
||||
depends = python-requests>=2.4
|
||||
options = !emptydirs
|
||||
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
|
||||
|
||||
@@ -8,7 +8,7 @@ arch=('any')
|
||||
url="https://instaloader.github.io/"
|
||||
license=('MIT')
|
||||
groups=()
|
||||
depends=('python>=3.6' 'python-requests>=2.4')
|
||||
depends=('python>=3.8' 'python-requests>=2.4')
|
||||
makedepends=('python-setuptools')
|
||||
options=('!emptydirs')
|
||||
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
||||
|
||||
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
# install dependencies and invoke PyInstaller
|
||||
commands = ["pip install pipenv==2022.1.8",
|
||||
commands = ["pip install pipenv==2023.2.4",
|
||||
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
|
||||
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
||||
|
||||
|
||||
@@ -329,12 +329,13 @@ Miscellaneous Options
|
||||
Read arguments from file `args.txt`, a shortcut to provide arguments from
|
||||
file rather than command-line. This provides a convenient way to hide login
|
||||
info from CLI, and can also be used to simplify management of long arguments.
|
||||
You can provide more than one file at once, e.g.: ``+args1.txt +args2.txt``.
|
||||
|
||||
.. note::
|
||||
|
||||
Text file should separate arguments with line breaks.
|
||||
|
||||
args.txt example::
|
||||
`args.txt` example::
|
||||
|
||||
--login=MYUSERNAME
|
||||
--password=MYPASSWORD
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
|
||||
autodoc_member_order = 'bysource'
|
||||
|
||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
||||
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
||||
'requests': ('https://requests.readthedocs.io/en/latest/', None)}
|
||||
|
||||
nitpick_ignore = [('py:class', 'typing.Tuple')]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Install Instaloader
|
||||
To **install Instaloader**,
|
||||
|
||||
#. Ensure that you have `Python <https://www.python.org/>`__, at least
|
||||
version 3.6, and `pip <https://pypi.python.org/pypi/pip>`__
|
||||
version 3.8, and `pip <https://pypi.python.org/pypi/pip>`__
|
||||
installed.
|
||||
|
||||
#. Then, install Instaloader using::
|
||||
|
||||
@@ -30,7 +30,7 @@ Iterator :class:`NodeIterator` and a context manager
|
||||
|
||||
It can be serialized and deserialized with :func:`save_structure_to_file`
|
||||
and :func:`load_structure_from_file`, as well as with :mod:`json` and
|
||||
:mod:`pickle` thanks to being a :func:`~collections.namedtuple`.
|
||||
:mod:`pickle` thanks to being a :class:`~typing.NamedTuple`.
|
||||
|
||||
``resumable_iteration``
|
||||
"""""""""""""""""""""""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
|
||||
|
||||
|
||||
__version__ = '4.9'
|
||||
__version__ = '4.10.1'
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -45,7 +45,6 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
|
||||
|
||||
class TransformFilterAst(ast.NodeTransformer):
|
||||
def visit_Name(self, node: ast.Name):
|
||||
# pylint:disable=no-self-use
|
||||
if not isinstance(node.ctx, ast.Load):
|
||||
raise InvalidArgumentException("Invalid filter: Modifying variables ({}) not allowed.".format(node.id))
|
||||
if node.id == "datetime":
|
||||
|
||||
+48
-20
@@ -221,8 +221,8 @@ class Instaloader:
|
||||
download_comments: bool = False,
|
||||
save_metadata: bool = True,
|
||||
compress_json: bool = True,
|
||||
post_metadata_txt_pattern: str = None,
|
||||
storyitem_metadata_txt_pattern: str = None,
|
||||
post_metadata_txt_pattern: Optional[str] = None,
|
||||
storyitem_metadata_txt_pattern: Optional[str] = None,
|
||||
max_connection_attempts: int = 3,
|
||||
request_timeout: float = 300.0,
|
||||
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
||||
@@ -447,7 +447,7 @@ class Instaloader:
|
||||
"""Updates picture caption / Post metadata info"""
|
||||
def _elliptify(caption):
|
||||
pcaption = caption.replace('\n', ' ').strip()
|
||||
return '[' + ((pcaption[:29] + u"\u2026") if len(pcaption) > 31 else pcaption) + ']'
|
||||
return '[' + ((pcaption[:29] + "\u2026") if len(pcaption) > 31 else pcaption) + ']'
|
||||
filename += '.txt'
|
||||
caption += '\n'
|
||||
pcaption = _elliptify(caption)
|
||||
@@ -478,9 +478,8 @@ class Instaloader:
|
||||
self.context.log(pcaption, end=' ', flush=True)
|
||||
except UnicodeEncodeError:
|
||||
self.context.log('txt', end=' ', flush=True)
|
||||
with open(filename, 'wb') as text_file:
|
||||
with BytesIO(bcaption) as bio:
|
||||
shutil.copyfileobj(cast(IO, bio), text_file)
|
||||
with open(filename, 'w', encoding='UTF-8') as fio:
|
||||
fio.write(caption)
|
||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||
|
||||
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
||||
@@ -525,7 +524,7 @@ class Instaloader:
|
||||
.. versionadded:: 4.3"""
|
||||
|
||||
http_response = self.context.get_raw(url)
|
||||
date_object = None # type: Optional[datetime]
|
||||
date_object: Optional[datetime] = None
|
||||
if 'Last-Modified' in http_response.headers:
|
||||
date_object = datetime.strptime(http_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT')
|
||||
date_object = date_object.replace(tzinfo=timezone.utc)
|
||||
@@ -563,7 +562,7 @@ class Instaloader:
|
||||
if latest_stamps is None:
|
||||
self.download_profilepic(profile)
|
||||
return
|
||||
profile_pic_basename = profile.profile_pic_url.split('/')[-1].split('?')[0]
|
||||
profile_pic_basename = profile.profile_pic_url_no_iphone.split('/')[-1].split('?')[0]
|
||||
saved_basename = latest_stamps.get_profile_pic(profile.username)
|
||||
if saved_basename == profile_pic_basename:
|
||||
return
|
||||
@@ -586,6 +585,23 @@ class Instaloader:
|
||||
.. versionadded:: 4.4"""
|
||||
self.download_title_pic(hashtag.profile_pic_url, '#' + hashtag.name, 'profile_pic', None)
|
||||
|
||||
@_requires_login
|
||||
def save_session(self) -> dict:
|
||||
"""Saves internally stored :class:`requests.Session` object to :class:`dict`.
|
||||
|
||||
:raises LoginRequiredException: If called without being logged in.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
return self.context.save_session()
|
||||
|
||||
def load_session(self, username: str, session_data: dict) -> None:
|
||||
"""Internally stores :class:`requests.Session` object from :class:`dict`.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
self.context.load_session(username, session_data)
|
||||
|
||||
@_requires_login
|
||||
def save_session_to_file(self, filename: Optional[str] = None) -> None:
|
||||
"""Saves internally stored :class:`requests.Session` object.
|
||||
@@ -713,7 +729,7 @@ class Instaloader:
|
||||
post.get_sidecar_nodes(self.slide_start, self.slide_end),
|
||||
start=self.slide_start % post.mediacount + 1
|
||||
):
|
||||
suffix = str(edge_number) # type: Optional[str]
|
||||
suffix: Optional[str] = str(edge_number)
|
||||
if '{filename}' in self.filename_pattern:
|
||||
suffix = None
|
||||
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
|
||||
@@ -879,13 +895,18 @@ class Instaloader:
|
||||
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
|
||||
filename = self.__prepare_filename(filename_template, lambda: item.url)
|
||||
downloaded = False
|
||||
if not item.is_video or self.download_video_thumbnails is True:
|
||||
video_url_fetch_failed = False
|
||||
if item.is_video and self.download_videos is True:
|
||||
video_url = item.video_url
|
||||
if video_url:
|
||||
filename = self.__prepare_filename(filename_template, lambda: str(video_url))
|
||||
downloaded |= (not _already_downloaded(filename + ".mp4") and
|
||||
self.download_pic(filename=filename, url=video_url, mtime=date_local))
|
||||
else:
|
||||
video_url_fetch_failed = True
|
||||
if video_url_fetch_failed or not item.is_video or self.download_video_thumbnails is True:
|
||||
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||
self.download_pic(filename=filename, url=item.url, mtime=date_local))
|
||||
if item.is_video and self.download_videos is True:
|
||||
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
|
||||
downloaded |= (not _already_downloaded(filename + ".mp4") and
|
||||
self.download_pic(filename=filename, url=item.video_url, mtime=date_local))
|
||||
# Save caption if desired
|
||||
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
|
||||
if metadata_string:
|
||||
@@ -941,11 +962,11 @@ class Instaloader:
|
||||
"""
|
||||
for user_highlight in self.get_highlights(user):
|
||||
name = user_highlight.owner_username
|
||||
highlight_target = (filename_target
|
||||
highlight_target: Union[str, Path] = (filename_target
|
||||
if filename_target
|
||||
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
|
||||
_PostPathFormatter.sanitize_path(user_highlight.title,
|
||||
self.sanitize_paths))) # type: Union[str, Path]
|
||||
self.sanitize_paths)))
|
||||
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
|
||||
self.download_highlight_cover(user_highlight, highlight_target)
|
||||
totalcount = user_highlight.itemcount
|
||||
@@ -1010,7 +1031,10 @@ class Instaloader:
|
||||
enabled=self.resume_prefix is not None
|
||||
) as (is_resuming, start_index):
|
||||
for number, post in enumerate(posts, start=start_index + 1):
|
||||
if (max_count is not None and number > max_count) or not takewhile(post):
|
||||
should_stop = not takewhile(post)
|
||||
if should_stop and post.is_pinned:
|
||||
continue
|
||||
if (max_count is not None and number > max_count) or should_stop:
|
||||
break
|
||||
if displayed_count is not None:
|
||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
|
||||
@@ -1042,7 +1066,7 @@ class Instaloader:
|
||||
except PostChangedException:
|
||||
post_changed = True
|
||||
continue
|
||||
if fast_update and not downloaded and not post_changed:
|
||||
if fast_update and not downloaded and not post_changed and not post.is_pinned:
|
||||
# disengage fast_update for first post when resuming
|
||||
if not is_resuming or number > 0:
|
||||
break
|
||||
@@ -1073,7 +1097,7 @@ class Instaloader:
|
||||
'has_stories': False})["data"]
|
||||
|
||||
@_requires_login
|
||||
def download_feed_posts(self, max_count: int = None, fast_update: bool = False,
|
||||
def download_feed_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||
"""
|
||||
Download pictures from the user's feed.
|
||||
@@ -1094,7 +1118,7 @@ class Instaloader:
|
||||
self.posts_download_loop(self.get_feed_posts(), ":feed", fast_update, post_filter, max_count=max_count)
|
||||
|
||||
@_requires_login
|
||||
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
|
||||
def download_saved_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||
"""Download user's saved pictures.
|
||||
|
||||
@@ -1334,6 +1358,10 @@ class Instaloader:
|
||||
profile_id))
|
||||
profile_from_id = Profile.from_id(self.context, profile_id)
|
||||
newname = profile_from_id.username
|
||||
if profile_name == newname:
|
||||
self.context.error(
|
||||
f"Warning: Profile {profile_name} could not be retrieved by its name, but by its ID.")
|
||||
return profile_from_id
|
||||
self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
|
||||
if latest_stamps is None:
|
||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
@@ -24,10 +25,10 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
|
||||
"""Duplicates a requests.Session."""
|
||||
new = requests.Session()
|
||||
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
|
||||
new.headers = session.headers.copy()
|
||||
new.headers = session.headers.copy() # type: ignore
|
||||
# Override default timeout behavior.
|
||||
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
|
||||
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
||||
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
||||
return new
|
||||
|
||||
|
||||
@@ -36,6 +37,34 @@ def default_user_agent() -> str:
|
||||
'(KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36'
|
||||
|
||||
|
||||
def default_iphone_headers() -> Dict[str, Any]:
|
||||
return {'User-Agent': 'Instagram 273.0.0.16.70 (iPad13,8; iOS 16_3; en_US; en-US; ' \
|
||||
'scale=2.00; 2048x2732; 452417278) AppleWebKit/420+',
|
||||
'x-ads-opt-out': '1',
|
||||
'x-bloks-is-panorama-enabled': 'true',
|
||||
'x-bloks-version-id': '01507c21540f73e2216b6f62a11a5b5e51aa85491b72475c080da35b1228ddd6',
|
||||
'x-fb-client-ip': 'True',
|
||||
'x-fb-connection-type': 'wifi',
|
||||
'x-fb-http-engine': 'Liger',
|
||||
'x-fb-server-cluster': 'True',
|
||||
'x-fb': '1',
|
||||
'x-ig-abr-connection-speed-kbps': '2',
|
||||
'x-ig-app-id': '124024574287414',
|
||||
'x-ig-app-locale': 'en-US',
|
||||
'x-ig-app-startup-country': 'US',
|
||||
'x-ig-bandwidth-speed-kbps': '0.000',
|
||||
'x-ig-capabilities': '36r/F/8=',
|
||||
'x-ig-connection-speed': '{}kbps'.format(random.randint(1000, 20000)),
|
||||
'x-ig-connection-type': 'WiFi',
|
||||
'x-ig-device-locale': 'en-US',
|
||||
'x-ig-mapped-locale': 'en-US',
|
||||
'x-ig-timezone-offset': str((datetime.now().astimezone().utcoffset() or timedelta(seconds=0)).seconds),
|
||||
'x-ig-www-claim': '0',
|
||||
'x-pigeon-session-id': str(uuid.uuid4()),
|
||||
'x-tigon-is-retry': 'False',
|
||||
'x-whatsapp': '0'}
|
||||
|
||||
|
||||
class InstaloaderContext:
|
||||
"""Class providing methods for (error) logging and low-level communication with Instagram.
|
||||
|
||||
@@ -61,6 +90,7 @@ class InstaloaderContext:
|
||||
self.request_timeout = request_timeout
|
||||
self._session = self.get_anonymous_session()
|
||||
self.username = None
|
||||
self.user_id = None
|
||||
self.sleep = sleep
|
||||
self.quiet = quiet
|
||||
self.max_connection_attempts = max_connection_attempts
|
||||
@@ -68,9 +98,10 @@ class InstaloaderContext:
|
||||
self._root_rhx_gis = None
|
||||
self.two_factor_auth_pending = None
|
||||
self.iphone_support = iphone_support
|
||||
self.iphone_headers = default_iphone_headers()
|
||||
|
||||
# error log, filled with error() and printed at the end of Instaloader.main()
|
||||
self.error_log = [] # type: List[str]
|
||||
self.error_log: List[str] = []
|
||||
|
||||
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self)
|
||||
|
||||
@@ -81,20 +112,26 @@ class InstaloaderContext:
|
||||
self.fatal_status_codes = fatal_status_codes or []
|
||||
|
||||
# Cache profile from id (mapping from id to Profile)
|
||||
self.profile_id_cache = dict() # type: Dict[int, Any]
|
||||
self.profile_id_cache: Dict[int, Any] = dict()
|
||||
|
||||
@contextmanager
|
||||
def anonymous_copy(self):
|
||||
session = self._session
|
||||
username = self.username
|
||||
user_id = self.user_id
|
||||
iphone_headers = self.iphone_headers
|
||||
self._session = self.get_anonymous_session()
|
||||
self.username = None
|
||||
self.user_id = None
|
||||
self.iphone_headers = default_iphone_headers()
|
||||
try:
|
||||
yield self
|
||||
finally:
|
||||
self._session.close()
|
||||
self.username = username
|
||||
self._session = session
|
||||
self.user_id = user_id
|
||||
self.iphone_headers = iphone_headers
|
||||
|
||||
@property
|
||||
def is_logged_in(self) -> bool:
|
||||
@@ -170,22 +207,30 @@ class InstaloaderContext:
|
||||
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
|
||||
return session
|
||||
|
||||
def save_session_to_file(self, sessionfile):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.save_session_to_file`."""
|
||||
pickle.dump(requests.utils.dict_from_cookiejar(self._session.cookies), sessionfile)
|
||||
def save_session(self):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.save_session`."""
|
||||
return requests.utils.dict_from_cookiejar(self._session.cookies)
|
||||
|
||||
def load_session_from_file(self, username, sessionfile):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.load_session_from_file`."""
|
||||
def load_session(self, username, sessiondata):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.load_session`."""
|
||||
session = requests.Session()
|
||||
session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile))
|
||||
session.cookies = requests.utils.cookiejar_from_dict(sessiondata)
|
||||
session.headers.update(self._default_http_header())
|
||||
session.headers.update({'X-CSRFToken': session.cookies.get_dict()['csrftoken']})
|
||||
# Override default timeout behavior.
|
||||
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
|
||||
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
|
||||
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
|
||||
self._session = session
|
||||
self.username = username
|
||||
|
||||
def save_session_to_file(self, sessionfile):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.save_session_to_file`."""
|
||||
pickle.dump(self.save_session(), sessionfile)
|
||||
|
||||
def load_session_from_file(self, username, sessionfile):
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.load_session_from_file`."""
|
||||
self.load_session(username, pickle.load(sessionfile))
|
||||
|
||||
def test_login(self) -> Optional[str]:
|
||||
"""Not meant to be used directly, use :meth:`Instaloader.test_login`."""
|
||||
data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})
|
||||
@@ -265,6 +310,7 @@ class InstaloaderContext:
|
||||
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
|
||||
self._session = session
|
||||
self.username = user
|
||||
self.user_id = resp_json['userId']
|
||||
|
||||
def two_factor_login(self, two_factor_code):
|
||||
"""Second step of login if 2FA is enabled.
|
||||
@@ -298,7 +344,8 @@ class InstaloaderContext:
|
||||
time.sleep(min(random.expovariate(0.6), 15.0))
|
||||
|
||||
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
|
||||
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
|
||||
session: Optional[requests.Session] = None, _attempt=1,
|
||||
response_headers: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""JSON request to Instagram.
|
||||
|
||||
:param path: URL, relative to the given domain which defaults to www.instagram.com/
|
||||
@@ -325,22 +372,29 @@ class InstaloaderContext:
|
||||
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
|
||||
if resp.status_code in self.fatal_status_codes:
|
||||
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
|
||||
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}".format(
|
||||
host, path, resp.status_code, resp.reason, redirect
|
||||
body = ""
|
||||
if resp.headers['Content-Type'].startswith('application/json'):
|
||||
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
|
||||
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
|
||||
host, path, resp.status_code, resp.reason, redirect, body
|
||||
))
|
||||
while resp.is_redirect:
|
||||
redirect_url = resp.headers['location']
|
||||
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
|
||||
if redirect_url.startswith('https://www.instagram.com/accounts/login'):
|
||||
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
|
||||
redirect_url.startswith('https://i.instagram.com/accounts/login')):
|
||||
if not self.is_logged_in:
|
||||
raise LoginRequiredException("Redirected to login page. Use --login.")
|
||||
# alternate rate limit exceeded behavior
|
||||
raise TooManyRequestsException("Redirected to login")
|
||||
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
|
||||
"some time, recreate the session and try again")
|
||||
if redirect_url.startswith('https://{}/'.format(host)):
|
||||
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
|
||||
params=params, allow_redirects=False)
|
||||
else:
|
||||
break
|
||||
if response_headers is not None:
|
||||
response_headers.clear()
|
||||
response_headers.update(resp.headers)
|
||||
if resp.status_code == 400:
|
||||
raise QueryReturnedBadRequestException("400 Bad Request")
|
||||
if resp.status_code == 404:
|
||||
@@ -351,20 +405,13 @@ class InstaloaderContext:
|
||||
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
|
||||
is_html_query = not is_graphql_query and not "__a" in params and host == "www.instagram.com"
|
||||
if is_html_query:
|
||||
match = re.search(r'window\._sharedData = (.*);</script>', resp.text)
|
||||
# Extract JSON from HTML response
|
||||
match = re.search('(?<={"raw":").*?(?<!\\\\)(?=")', resp.text)
|
||||
if match is None:
|
||||
raise QueryReturnedNotFoundException("Could not find \"window._sharedData\" in html response.")
|
||||
resp_json = json.loads(match.group(1))
|
||||
entry_data = resp_json.get('entry_data')
|
||||
post_or_profile_page = list(entry_data.values())[0] if entry_data is not None else None
|
||||
if post_or_profile_page is None:
|
||||
raise ConnectionException("\"window._sharedData\" does not contain required keys.")
|
||||
# If GraphQL data is missing in `window._sharedData`, search for it in `__additionalDataLoaded`.
|
||||
if 'graphql' not in post_or_profile_page[0]:
|
||||
match = re.search(r'window\.__additionalDataLoaded\(.*?({.*"graphql":.*})\);</script>',
|
||||
resp.text)
|
||||
if match is not None:
|
||||
post_or_profile_page[0]['graphql'] = json.loads(match.group(1))['graphql']
|
||||
raise QueryReturnedNotFoundException("Could not find JSON data in html response.")
|
||||
# Unescape escaped JSON string
|
||||
unescaped_string = match.group(0).encode("utf-8").decode("unicode_escape")
|
||||
resp_json = json.loads(unescaped_string)
|
||||
return resp_json
|
||||
else:
|
||||
resp_json = resp.json()
|
||||
@@ -391,7 +438,8 @@ class InstaloaderContext:
|
||||
self._rate_controller.handle_429('iphone')
|
||||
if is_other_query:
|
||||
self._rate_controller.handle_429('other')
|
||||
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
|
||||
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
|
||||
response_headers=response_headers)
|
||||
except KeyboardInterrupt:
|
||||
self.error("[skipped by user]", repeat_at_end=False)
|
||||
raise ConnectionException(error_string) from err
|
||||
@@ -481,11 +529,57 @@ class InstaloaderContext:
|
||||
|
||||
.. versionadded:: 4.2.1"""
|
||||
with copy_session(self._session, self.request_timeout) as tempsession:
|
||||
tempsession.headers['User-Agent'] = 'Instagram 146.0.0.27.125 (iPhone12,1; iOS 13_3; en_US; en-US; ' \
|
||||
'scale=2.00; 1656x3584; 190542906)'
|
||||
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
||||
# Set headers to simulate an API request from iPad
|
||||
tempsession.headers['ig-intended-user-id'] = str(self.user_id)
|
||||
tempsession.headers['x-pigeon-rawclienttime'] = '{:.6f}'.format(time.time())
|
||||
|
||||
# Add headers obtained from previous iPad request
|
||||
tempsession.headers.update(self.iphone_headers)
|
||||
|
||||
# Extract key information from cookies if we haven't got it already from a previous request
|
||||
header_cookies_mapping = {'x-mid': 'mid',
|
||||
'ig-u-ds-user-id': 'ds_user_id',
|
||||
'x-ig-device-id': 'ig_did',
|
||||
'x-ig-family-device-id': 'ig_did',
|
||||
'family_device_id': 'ig_did'}
|
||||
|
||||
# Map the cookie value to the matching HTTP request header
|
||||
cookies = tempsession.cookies.get_dict().copy()
|
||||
for key, value in header_cookies_mapping.items():
|
||||
if value in cookies:
|
||||
if key not in tempsession.headers:
|
||||
tempsession.headers[key] = cookies[value]
|
||||
else:
|
||||
# Remove the cookie value if it's already specified as a header
|
||||
tempsession.cookies.pop(value, None)
|
||||
|
||||
# Edge case for ig-u-rur header due to special string encoding in cookie
|
||||
if 'rur' in cookies:
|
||||
if 'ig-u-rur' not in tempsession.headers:
|
||||
tempsession.headers['ig-u-rur'] = cookies['rur'].strip('\"').encode('utf-8') \
|
||||
.decode('unicode_escape')
|
||||
else:
|
||||
tempsession.cookies.pop('rur', None)
|
||||
|
||||
# Remove headers specific to Desktop version
|
||||
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With', 'Referer']:
|
||||
tempsession.headers.pop(header, None)
|
||||
return self.get_json(path, params, 'i.instagram.com', tempsession)
|
||||
|
||||
# No need for cookies if we have a bearer token
|
||||
if 'authorization' in tempsession.headers:
|
||||
tempsession.cookies.clear()
|
||||
|
||||
response_headers = dict() # type: Dict[str, Any]
|
||||
response = self.get_json(path, params, 'i.instagram.com', tempsession, response_headers=response_headers)
|
||||
|
||||
# Extract the ig-set-* headers and use them in the next request
|
||||
for key, value in response_headers.items():
|
||||
if key.startswith('ig-set-'):
|
||||
self.iphone_headers[key.replace('ig-set-', '')] = value
|
||||
elif key.startswith('x-ig-set-'):
|
||||
self.iphone_headers[key.replace('x-ig-set-', 'x-ig-')] = value
|
||||
|
||||
return response
|
||||
|
||||
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
|
||||
"""Write raw response data into a file.
|
||||
@@ -581,7 +675,7 @@ class RateController:
|
||||
|
||||
def __init__(self, context: InstaloaderContext):
|
||||
self._context = context
|
||||
self._query_timestamps = dict() # type: Dict[str, List[float]]
|
||||
self._query_timestamps: Dict[str, List[float]] = dict()
|
||||
self._earliest_next_request_time = 0.0
|
||||
self._iphone_earliest_next_request_time = 0.0
|
||||
|
||||
@@ -589,7 +683,6 @@ class RateController:
|
||||
"""Wait given number of seconds."""
|
||||
# Not static, to allow for the behavior of this method to depend on context-inherent properties, such as
|
||||
# whether we are logged in.
|
||||
# pylint:disable=no-self-use
|
||||
time.sleep(secs)
|
||||
|
||||
def _dump_query_timestamps(self, current_time: float, failed_query_type: str):
|
||||
@@ -613,7 +706,6 @@ class RateController:
|
||||
control."""
|
||||
# Not static, to allow for the count_per_sliding_window to depend on context-inherent properties, such as
|
||||
# whether we are logged in.
|
||||
# pylint:disable=no-self-use
|
||||
return 75 if query_type == 'other' else 200
|
||||
|
||||
def _reqs_in_sliding_window(self, query_type: Optional[str], current_time: float, window: float) -> List[float]:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import configparser
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from os.path import dirname
|
||||
from os import makedirs
|
||||
|
||||
|
||||
class LatestStamps:
|
||||
@@ -25,6 +27,8 @@ class LatestStamps:
|
||||
self.data.read(latest_stamps_file)
|
||||
|
||||
def _save(self):
|
||||
if dn := dirname(self.file):
|
||||
makedirs(dn, exist_ok=True)
|
||||
with open(self.file, 'w') as f:
|
||||
self.data.write(f)
|
||||
|
||||
|
||||
+27
-15
@@ -10,15 +10,15 @@ from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional
|
||||
from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException
|
||||
from .instaloadercontext import InstaloaderContext
|
||||
|
||||
FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
|
||||
[('query_hash', str),
|
||||
('query_variables', Dict),
|
||||
('query_referer', Optional[str]),
|
||||
('context_username', Optional[str]),
|
||||
('total_index', int),
|
||||
('best_before', Optional[float]),
|
||||
('remaining_data', Optional[Dict]),
|
||||
('first_node', Optional[Dict])])
|
||||
class FrozenNodeIterator(NamedTuple):
|
||||
query_hash: str
|
||||
query_variables: Dict
|
||||
query_referer: Optional[str]
|
||||
context_username: Optional[str]
|
||||
total_index: int
|
||||
best_before: Optional[float]
|
||||
remaining_data: Optional[Dict]
|
||||
first_node: Optional[Dict]
|
||||
FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter."""
|
||||
FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter."""
|
||||
FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query."""
|
||||
@@ -76,7 +76,8 @@ class NodeIterator(Iterator[T]):
|
||||
node_wrapper: Callable[[Dict], T],
|
||||
query_variables: Optional[Dict[str, Any]] = None,
|
||||
query_referer: Optional[str] = None,
|
||||
first_data: Optional[Dict[str, Any]] = None):
|
||||
first_data: Optional[Dict[str, Any]] = None,
|
||||
is_first: Optional[Callable[[T, Optional[T]], bool]] = None):
|
||||
self._context = context
|
||||
self._query_hash = query_hash
|
||||
self._edge_extractor = edge_extractor
|
||||
@@ -91,9 +92,10 @@ class NodeIterator(Iterator[T]):
|
||||
else:
|
||||
self._data = self._query()
|
||||
self._first_node: Optional[Dict] = None
|
||||
self._is_first = is_first
|
||||
|
||||
def _query(self, after: Optional[str] = None) -> Dict:
|
||||
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
|
||||
pagination_variables: Dict[str, Any] = {'first': NodeIterator._graphql_page_length}
|
||||
if after is not None:
|
||||
pagination_variables['after'] = after
|
||||
try:
|
||||
@@ -128,10 +130,14 @@ class NodeIterator(Iterator[T]):
|
||||
self._page_index, self._total_index = page_index, total_index
|
||||
raise
|
||||
item = self._node_wrapper(node)
|
||||
if self._first_node is None:
|
||||
self._first_node = node
|
||||
if self._is_first is not None:
|
||||
if self._is_first(item, self.first_item):
|
||||
self._first_node = node
|
||||
else:
|
||||
if self._first_node is None:
|
||||
self._first_node = node
|
||||
return item
|
||||
if self._data['page_info']['has_next_page']:
|
||||
if self._data.get('page_info', {}).get('has_next_page'):
|
||||
query_response = self._query(self._data['page_info']['end_cursor'])
|
||||
if self._data['edges'] != query_response['edges'] and len(query_response['edges']) > 0:
|
||||
page_index, data = self._page_index, self._data
|
||||
@@ -168,7 +174,13 @@ class NodeIterator(Iterator[T]):
|
||||
"""
|
||||
If this iterator has produced any items, returns the first item produced.
|
||||
|
||||
It is possible to override what is considered the first item (for example, to consider the
|
||||
newest item in case items are not in strict chronological order) by passing a callback
|
||||
function as the `is_first` parameter when creating the class.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
.. versionchanged:: 4.9.2
|
||||
What is considered the first item can be overridden.
|
||||
"""
|
||||
return self._node_wrapper(self._first_node) if self._first_node is not None else None
|
||||
|
||||
@@ -277,7 +289,7 @@ def resumable_iteration(context: InstaloaderContext,
|
||||
is_resuming = True
|
||||
start_index = iterator.total_index
|
||||
context.log("Resuming from {}.".format(resume_file_path))
|
||||
except (InvalidArgumentException, LZMAError, json.decoder.JSONDecodeError) as exc:
|
||||
except (InvalidArgumentException, LZMAError, json.decoder.JSONDecodeError, EOFError) as exc:
|
||||
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
|
||||
try:
|
||||
yield is_resuming, start_index
|
||||
|
||||
@@ -29,7 +29,7 @@ class SectionIterator(Iterator[T]):
|
||||
def _query(self, max_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
pagination_variables = {"max_id": max_id} if max_id is not None else {}
|
||||
return self._sections_extractor(
|
||||
self._context.get_json(self._query_path, params={"__a": 1, **pagination_variables})
|
||||
self._context.get_json(self._query_path, params={"__a": 1, "__d": "dis", **pagination_variables})
|
||||
)
|
||||
|
||||
def __next__(self) -> T:
|
||||
|
||||
+237
-45
@@ -2,12 +2,11 @@ import json
|
||||
import lzma
|
||||
import re
|
||||
from base64 import b64decode, b64encode
|
||||
from collections import namedtuple
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Tuple, Union
|
||||
from unicodedata import normalize
|
||||
|
||||
from . import __version__
|
||||
@@ -16,25 +15,57 @@ from .instaloadercontext import InstaloaderContext
|
||||
from .nodeiterator import FrozenNodeIterator, NodeIterator
|
||||
from .sectioniterator import SectionIterator
|
||||
|
||||
PostSidecarNode = namedtuple('PostSidecarNode', ['is_video', 'display_url', 'video_url'])
|
||||
PostSidecarNode.__doc__ = "Item of a Sidecar Post."
|
||||
|
||||
class PostSidecarNode(NamedTuple):
|
||||
"""Item of a Sidecar Post."""
|
||||
is_video: bool
|
||||
display_url: str
|
||||
video_url: str
|
||||
|
||||
|
||||
PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
|
||||
PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail."
|
||||
PostSidecarNode.video_url.__doc__ = "URL of video or None."
|
||||
|
||||
PostCommentAnswer = namedtuple('PostCommentAnswer', ['id', 'created_at_utc', 'text', 'owner', 'likes_count'])
|
||||
|
||||
class PostCommentAnswer(NamedTuple):
|
||||
id: int
|
||||
created_at_utc: datetime
|
||||
text: str
|
||||
owner: 'Profile'
|
||||
likes_count: int
|
||||
|
||||
|
||||
PostCommentAnswer.id.__doc__ = "ID number of comment."
|
||||
PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)."
|
||||
PostCommentAnswer.text.__doc__ = "Comment text."
|
||||
PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment."
|
||||
PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment."
|
||||
|
||||
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
|
||||
|
||||
class PostComment(NamedTuple):
|
||||
id: int
|
||||
created_at_utc: datetime
|
||||
text: str
|
||||
owner: 'Profile'
|
||||
likes_count: int
|
||||
answers: Iterator[PostCommentAnswer]
|
||||
|
||||
|
||||
for field in PostCommentAnswer._fields:
|
||||
getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__ # pylint: disable=no-member
|
||||
PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment." # type: ignore
|
||||
PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment."
|
||||
|
||||
|
||||
class PostLocation(NamedTuple):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
has_public_page: Optional[bool]
|
||||
lat: Optional[float]
|
||||
lng: Optional[float]
|
||||
|
||||
|
||||
PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng'])
|
||||
PostLocation.id.__doc__ = "ID number of location."
|
||||
PostLocation.name.__doc__ = "Location name."
|
||||
PostLocation.slug.__doc__ = "URL friendly variant of location name."
|
||||
@@ -42,6 +73,22 @@ PostLocation.has_public_page.__doc__ = "Whether location has a public page."
|
||||
PostLocation.lat.__doc__ = "Latitude (:class:`float` or None)."
|
||||
PostLocation.lng.__doc__ = "Longitude (:class:`float` or None)."
|
||||
|
||||
# This regular expression is by MiguelX413
|
||||
_hashtag_regex = re.compile(r"(?:#)((?:\w){1,150})")
|
||||
|
||||
# This regular expression is modified from jStassen, adjusted to use Python's \w to
|
||||
# support Unicode and a word/beginning of string delimiter at the beginning to ensure
|
||||
# that no email addresses join the list of mentions.
|
||||
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
|
||||
_mention_regex = re.compile(r"(?:^|[^\w\n]|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII)
|
||||
|
||||
|
||||
def _optional_normalize(string: Optional[str]) -> Optional[str]:
|
||||
if string is not None:
|
||||
return normalize("NFC", string)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class Post:
|
||||
"""
|
||||
@@ -73,8 +120,8 @@ class Post:
|
||||
self._context = context
|
||||
self._node = node
|
||||
self._owner_profile = owner_profile
|
||||
self._full_metadata_dict = None # type: Optional[Dict[str, Any]]
|
||||
self._location = None # type: Optional[PostLocation]
|
||||
self._full_metadata_dict: Optional[Dict[str, Any]] = None
|
||||
self._location: Optional[PostLocation] = None
|
||||
self._iphone_struct_ = None
|
||||
if 'iphone_struct' in node:
|
||||
# if loaded from JSON with load_structure_from_file()
|
||||
@@ -293,7 +340,7 @@ class Post:
|
||||
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
||||
return url
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
||||
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||
return self._node["display_url"] if "display_url" in self._node else self._node["display_src"]
|
||||
|
||||
@property
|
||||
@@ -357,8 +404,7 @@ class Post:
|
||||
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
||||
display_url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(
|
||||
err, self))
|
||||
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
||||
video_url=node['video_url'] if is_video else None)
|
||||
|
||||
@@ -366,9 +412,9 @@ class Post:
|
||||
def caption(self) -> Optional[str]:
|
||||
"""Caption."""
|
||||
if "edge_media_to_caption" in self._node and self._node["edge_media_to_caption"]["edges"]:
|
||||
return normalize("NFC", self._node["edge_media_to_caption"]["edges"][0]["node"]["text"])
|
||||
return _optional_normalize(self._node["edge_media_to_caption"]["edges"][0]["node"]["text"])
|
||||
elif "caption" in self._node:
|
||||
return normalize("NFC", self._node["caption"])
|
||||
return _optional_normalize(self._node["caption"])
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -376,22 +422,14 @@ class Post:
|
||||
"""List of all lowercased hashtags (without preceeding #) that occur in the Post's caption."""
|
||||
if not self.caption:
|
||||
return []
|
||||
# This regular expression is from jStassen, adjusted to use Python's \w to support Unicode
|
||||
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
|
||||
hashtag_regex = re.compile(r"(?:#)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)")
|
||||
return re.findall(hashtag_regex, self.caption.lower())
|
||||
return _hashtag_regex.findall(self.caption.lower())
|
||||
|
||||
@property
|
||||
def caption_mentions(self) -> List[str]:
|
||||
"""List of all lowercased profiles that are mentioned in the Post's caption, without preceeding @."""
|
||||
if not self.caption:
|
||||
return []
|
||||
# This regular expression is modified from jStassen, adjusted to use Python's \w to
|
||||
# support Unicode and a word/beginning of string delimiter at the beginning to ensure
|
||||
# that no email addresses join the list of mentions.
|
||||
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
|
||||
mention_regex = re.compile(r"(?:^|\W|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII)
|
||||
return re.findall(mention_regex, self.caption.lower())
|
||||
return _mention_regex.findall(self.caption.lower())
|
||||
|
||||
@property
|
||||
def pcaption(self) -> str:
|
||||
@@ -400,7 +438,7 @@ class Post:
|
||||
.. versionadded:: 4.2.6"""
|
||||
def _elliptify(caption):
|
||||
pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip()
|
||||
return (pcaption[:30] + u"\u2026") if len(pcaption) > 31 else pcaption
|
||||
return (pcaption[:30] + "\u2026") if len(pcaption) > 31 else pcaption
|
||||
return _elliptify(self.caption) if self.caption else ''
|
||||
|
||||
@property
|
||||
@@ -510,7 +548,7 @@ class Post:
|
||||
def get_comments(self) -> Iterable[PostComment]:
|
||||
r"""Iterate over all comments of the post.
|
||||
|
||||
Each comment is represented by a PostComment namedtuple with fields text (string), created_at (datetime),
|
||||
Each comment is represented by a PostComment NamedTuple with fields text (string), created_at (datetime),
|
||||
id (int), owner (:class:`Profile`) and answers (:class:`~typing.Iterator`\ [:class:`PostCommentAnswer`])
|
||||
if available.
|
||||
|
||||
@@ -553,7 +591,7 @@ class Post:
|
||||
return []
|
||||
|
||||
comment_edges = self._field('edge_media_to_comment', 'edges')
|
||||
answers_count = sum([edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges])
|
||||
answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges)
|
||||
|
||||
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
|
||||
@@ -620,7 +658,7 @@ class Post:
|
||||
@property
|
||||
def location(self) -> Optional[PostLocation]:
|
||||
"""
|
||||
If the Post has a location, returns PostLocation namedtuple with fields 'id', 'lat' and 'lng' and 'name'.
|
||||
If the Post has a location, returns PostLocation NamedTuple with fields 'id', 'lat' and 'lng' and 'name'.
|
||||
|
||||
.. versionchanged:: 4.2.9
|
||||
Require being logged in (as required by Instagram), return None if not logged-in.
|
||||
@@ -633,11 +671,18 @@ class Post:
|
||||
location_id = int(loc['id'])
|
||||
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
|
||||
loc.update(self._context.get_json("explore/locations/{0}/".format(location_id),
|
||||
params={'__a': 1})['native_location_data']['location_info'])
|
||||
params={'__a': 1, '__d': 'dis'})['native_location_data']['location_info'])
|
||||
self._location = PostLocation(location_id, loc['name'], loc['slug'], loc['has_public_page'],
|
||||
loc.get('lat'), loc.get('lng'))
|
||||
return self._location
|
||||
|
||||
@property
|
||||
def is_pinned(self) -> bool:
|
||||
"""True if this Post has been pinned by at least one user.
|
||||
|
||||
.. versionadded: 4.9.2"""
|
||||
return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users'])
|
||||
|
||||
|
||||
class Profile:
|
||||
"""
|
||||
@@ -669,7 +714,7 @@ class Profile:
|
||||
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
|
||||
assert 'username' in node
|
||||
self._context = context
|
||||
self._has_public_story = None # type: Optional[bool]
|
||||
self._has_public_story: Optional[bool] = None
|
||||
self._node = node
|
||||
self._has_full_metadata = False
|
||||
self._iphone_struct_ = None
|
||||
@@ -758,13 +803,19 @@ class Profile:
|
||||
def _obtain_metadata(self):
|
||||
try:
|
||||
if not self._has_full_metadata:
|
||||
metadata = self._context.get_json('{}/feed/'.format(self.username), params={})
|
||||
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
|
||||
metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}',
|
||||
params={})
|
||||
if metadata['data']['user'] is None:
|
||||
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username))
|
||||
self._node = metadata['data']['user']
|
||||
self._has_full_metadata = True
|
||||
except (QueryReturnedNotFoundException, KeyError) as err:
|
||||
top_search_results = TopSearchResults(self._context, self.username)
|
||||
similar_profiles = [profile.username for profile in top_search_results.get_profiles()]
|
||||
if similar_profiles:
|
||||
if self.username in similar_profiles:
|
||||
raise ProfileNotExistsException(
|
||||
f"Profile {self.username} seems to exist, but could not be loaded.") from err
|
||||
raise ProfileNotExistsException('Profile {} does not exist.\nThe most similar profile{}: {}.'
|
||||
.format(self.username,
|
||||
's are' if len(similar_profiles) > 1 else ' is',
|
||||
@@ -856,7 +907,29 @@ class Profile:
|
||||
|
||||
@property
|
||||
def biography(self) -> str:
|
||||
return self._metadata('biography')
|
||||
return normalize("NFC", self._metadata('biography'))
|
||||
|
||||
@property
|
||||
def biography_hashtags(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased hashtags (without preceeding #) that occur in the Profile's biography.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if not self.biography:
|
||||
return []
|
||||
return _hashtag_regex.findall(self.biography.lower())
|
||||
|
||||
@property
|
||||
def biography_mentions(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased profiles that are mentioned in the Profile's biography, without preceeding @.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if not self.biography:
|
||||
return []
|
||||
return _mention_regex.findall(self.biography.lower())
|
||||
|
||||
@property
|
||||
def blocked_by_viewer(self) -> bool:
|
||||
@@ -934,11 +1007,18 @@ class Profile:
|
||||
try:
|
||||
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
||||
except (InstaloaderException, KeyError) as err:
|
||||
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
|
||||
self._context.error(f"Unable to fetch high quality profile pic: {err}")
|
||||
return self._metadata("profile_pic_url_hd")
|
||||
else:
|
||||
return self._metadata("profile_pic_url_hd")
|
||||
|
||||
@property
|
||||
def profile_pic_url_no_iphone(self) -> str:
|
||||
"""Return URL of lower-quality profile picture.
|
||||
|
||||
.. versionadded:: 4.9.3"""
|
||||
return self._metadata("profile_pic_url_hd")
|
||||
|
||||
def get_profile_pic_url(self) -> str:
|
||||
""".. deprecated:: 4.0.3
|
||||
|
||||
@@ -958,6 +1038,7 @@ class Profile:
|
||||
{'id': self.userid},
|
||||
'https://www.instagram.com/{0}/'.format(self.username),
|
||||
self._metadata('edge_owner_to_timeline_media'),
|
||||
Profile._make_is_newest_checker()
|
||||
)
|
||||
|
||||
def get_saved_posts(self) -> NodeIterator[Post]:
|
||||
@@ -991,6 +1072,7 @@ class Profile:
|
||||
lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None),
|
||||
{'id': self.userid},
|
||||
'https://www.instagram.com/{0}/'.format(self.username),
|
||||
is_first=Profile._make_is_newest_checker()
|
||||
)
|
||||
|
||||
def get_igtv_posts(self) -> NodeIterator[Post]:
|
||||
@@ -1008,6 +1090,32 @@ class Profile:
|
||||
{'id': self.userid},
|
||||
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
||||
self._metadata('edge_felix_video_timeline'),
|
||||
Profile._make_is_newest_checker()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_is_newest_checker() -> Callable[[Post, Optional[Post]], bool]:
|
||||
return lambda post, first: first is None or post.date_local > first.date_local
|
||||
|
||||
def get_followed_hashtags(self) -> NodeIterator['Hashtag']:
|
||||
"""
|
||||
Retrieve list of hashtags followed by given profile.
|
||||
To use this, one needs to be logged in and private profiles has to be followed.
|
||||
|
||||
:rtype: NodeIterator[Hashtag]
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if not self._context.is_logged_in:
|
||||
raise LoginRequiredException("--login required to get a profile's followers.")
|
||||
self._obtain_metadata()
|
||||
return NodeIterator(
|
||||
self._context,
|
||||
'e6306cc3dbe69d6a82ef8b5f8654c50b',
|
||||
lambda d: d["data"]["user"]["edge_following_hashtag"],
|
||||
lambda n: Hashtag(self._context, n),
|
||||
{'id': str(self.userid)},
|
||||
'https://www.instagram.com/{0}/'.format(self.username),
|
||||
)
|
||||
|
||||
def get_followers(self) -> NodeIterator['Profile']:
|
||||
@@ -1137,8 +1245,14 @@ class StoryItem:
|
||||
if not self._context.is_logged_in:
|
||||
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]
|
||||
data = self._context.get_iphone_json(
|
||||
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
|
||||
)
|
||||
self._iphone_struct_ = {}
|
||||
for item in data['reels'][str(self.owner_id)]['items']:
|
||||
if item['pk'] == self.mediaid:
|
||||
self._iphone_struct_ = item
|
||||
break
|
||||
return self._iphone_struct_
|
||||
|
||||
@property
|
||||
@@ -1195,13 +1309,14 @@ class StoryItem:
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""URL of the picture / video thumbnail of the StoryItem"""
|
||||
if self.typename == "GraphStoryImage" and self._context.iphone_support and self._context.is_logged_in:
|
||||
if self.typename in ["GraphStoryImage", "StoryImage"] and \
|
||||
self._context.iphone_support 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).rstrip('&')
|
||||
return url
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
||||
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||
return self._node['display_resources'][-1]['src']
|
||||
|
||||
@property
|
||||
@@ -1209,6 +1324,53 @@ class StoryItem:
|
||||
"""Type of post, GraphStoryImage or GraphStoryVideo"""
|
||||
return self._node['__typename']
|
||||
|
||||
@property
|
||||
def caption(self) -> Optional[str]:
|
||||
"""
|
||||
Caption.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if "edge_media_to_caption" in self._node and self._node["edge_media_to_caption"]["edges"]:
|
||||
return _optional_normalize(self._node["edge_media_to_caption"]["edges"][0]["node"]["text"])
|
||||
elif "caption" in self._node:
|
||||
return _optional_normalize(self._node["caption"])
|
||||
return None
|
||||
|
||||
@property
|
||||
def caption_hashtags(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased hashtags (without preceeding #) that occur in the StoryItem's caption.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if not self.caption:
|
||||
return []
|
||||
return _hashtag_regex.findall(self.caption.lower())
|
||||
|
||||
@property
|
||||
def caption_mentions(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased profiles that are mentioned in the StoryItem's caption, without preceeding @.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
if not self.caption:
|
||||
return []
|
||||
return _mention_regex.findall(self.caption.lower())
|
||||
|
||||
@property
|
||||
def pcaption(self) -> str:
|
||||
"""
|
||||
Printable caption, useful as a format specifier for --filename-pattern.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
def _elliptify(caption):
|
||||
pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip()
|
||||
return (pcaption[:30] + "\u2026") if len(pcaption) > 31 else pcaption
|
||||
return _elliptify(self.caption) if self.caption else ''
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
"""True if the StoryItem is a video."""
|
||||
@@ -1274,8 +1436,9 @@ class Story:
|
||||
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
|
||||
self._context = context
|
||||
self._node = node
|
||||
self._unique_id = None # type: Optional[str]
|
||||
self._owner_profile = None # type: Optional[Profile]
|
||||
self._unique_id: Optional[str] = None
|
||||
self._owner_profile: Optional[Profile] = None
|
||||
self._iphone_struct_: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc)
|
||||
@@ -1346,9 +1509,23 @@ class Story:
|
||||
"""The story owner's ID."""
|
||||
return self.owner_profile.userid
|
||||
|
||||
def _fetch_iphone_struct(self) -> None:
|
||||
if self._context.iphone_support and self._context.is_logged_in and not self._iphone_struct_:
|
||||
data = self._context.get_iphone_json(
|
||||
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
|
||||
)
|
||||
self._iphone_struct_ = data['reels'][str(self.owner_id)]
|
||||
|
||||
def get_items(self) -> Iterator[StoryItem]:
|
||||
"""Retrieve all items from a story."""
|
||||
yield from (StoryItem(self._context, item, self.owner_profile) for item in reversed(self._node['items']))
|
||||
self._fetch_iphone_struct()
|
||||
for item in reversed(self._node['items']):
|
||||
if self._iphone_struct_ is not None:
|
||||
for iphone_struct_item in self._iphone_struct_['items']:
|
||||
if iphone_struct_item['pk'] == int(item['id']):
|
||||
item['iphone_struct'] = iphone_struct_item
|
||||
break
|
||||
yield StoryItem(self._context, item, self.owner_profile)
|
||||
|
||||
|
||||
class Highlight(Story):
|
||||
@@ -1377,7 +1554,8 @@ class Highlight(Story):
|
||||
def __init__(self, context: InstaloaderContext, node: Dict[str, Any], owner: Optional[Profile] = None):
|
||||
super().__init__(context, node)
|
||||
self._owner_profile = owner
|
||||
self._items = None # type: Optional[List[Dict[str, Any]]]
|
||||
self._items: Optional[List[Dict[str, Any]]] = None
|
||||
self._iphone_struct_: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<Highlight by {}: {}>'.format(self.owner_username, self.title)
|
||||
@@ -1416,6 +1594,13 @@ class Highlight(Story):
|
||||
"highlight_reel_ids": [str(self.unique_id)],
|
||||
"precomposed_overlay": False})['data']['reels_media'][0]['items']
|
||||
|
||||
def _fetch_iphone_struct(self) -> None:
|
||||
if self._context.iphone_support and self._context.is_logged_in and not self._iphone_struct_:
|
||||
data = self._context.get_iphone_json(
|
||||
path='api/v1/feed/reels_media/?reel_ids=highlight:{}'.format(self.unique_id), params={}
|
||||
)
|
||||
self._iphone_struct_ = data['reels']['highlight:{}'.format(self.unique_id)]
|
||||
|
||||
@property
|
||||
def itemcount(self) -> int:
|
||||
"""Count of items associated with the :class:`Highlight` instance."""
|
||||
@@ -1426,8 +1611,15 @@ class Highlight(Story):
|
||||
def get_items(self) -> Iterator[StoryItem]:
|
||||
"""Retrieve all associated highlight items."""
|
||||
self._fetch_items()
|
||||
self._fetch_iphone_struct()
|
||||
assert self._items is not None
|
||||
yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items)
|
||||
for item in self._items:
|
||||
if self._iphone_struct_ is not None:
|
||||
for iphone_struct_item in self._iphone_struct_['items']:
|
||||
if iphone_struct_item['pk'] == int(item['id']):
|
||||
item['iphone_struct'] = iphone_struct_item
|
||||
break
|
||||
yield StoryItem(self._context, item, self.owner_profile)
|
||||
|
||||
|
||||
class Hashtag:
|
||||
@@ -1481,7 +1673,7 @@ class Hashtag:
|
||||
|
||||
def _obtain_metadata(self):
|
||||
if not self._has_full_metadata:
|
||||
self._node = self._query({"__a": 1})
|
||||
self._node = self._query({"__a": 1, "__d": "dis"})
|
||||
self._has_full_metadata = True
|
||||
|
||||
def _asdict(self):
|
||||
|
||||
@@ -18,8 +18,8 @@ def get_version():
|
||||
raise SystemExit("Could not find version string.")
|
||||
|
||||
|
||||
if sys.version_info < (3, 6):
|
||||
sys.exit('Instaloader requires Python >= 3.6.')
|
||||
if sys.version_info < (3, 8):
|
||||
sys.exit('Instaloader requires Python >= 3.8.')
|
||||
|
||||
requirements = ['requests>=2.4']
|
||||
|
||||
@@ -43,7 +43,7 @@ setup(
|
||||
'from Instagram.',
|
||||
long_description=open(os.path.join(SRC, 'README.rst')).read(),
|
||||
install_requires=requirements,
|
||||
python_requires='>=3.6',
|
||||
python_requires='>=3.8',
|
||||
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
|
||||
zip_safe=False,
|
||||
keywords=keywords,
|
||||
@@ -54,10 +54,10 @@ setup(
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: Internet',
|
||||
'Topic :: Multimedia :: Graphics'
|
||||
|
||||
@@ -23,7 +23,7 @@ PRIVATE_PROFILE_ID = 1706625676
|
||||
EMPTY_PROFILE = "not_public"
|
||||
EMPTY_PROFILE_ID = 1928659031
|
||||
|
||||
ratecontroller = None # type: Optional[instaloader.RateController]
|
||||
ratecontroller: Optional[instaloader.RateController] = None
|
||||
|
||||
|
||||
class TestInstaloaderAnonymously(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user