Compare commits

..
15 Commits
Author SHA1 Message Date
Alexander Graf 6a3d763cf3 Release of Version 4.9.6 2023-02-11 13:21:07 +01:00
SayanthD e2d42807a2 Fixed downloading with location id 2023-02-11 13:13:27 +01:00
Eduardo M KALINOWSKI d07bd6a096 Detect redirects to https://i.instagram.com/accounts/login 2023-02-11 13:00:52 +01:00
Alexander Graf 0462d5c34a update CI dependencies, require Python>=3.8 2023-02-11 12:45:38 +01:00
Alexander Graf 3cc29a4ceb Release of Version 4.9.5 2022-09-26 21:38:51 +02:00
ILikeToCode ffb0aea5eb Fixed downloading hashtags (#1749) 2022-09-26 21:37:02 +02:00
Alexander Graf 84bb60c155 Release of Version 4.9.4 2022-09-16 20:58:52 +02:00
fireattack 7857909886 Handle empty video resource in some old stories in highlights (#1732) 2022-09-16 20:56:01 +02:00
Alexander Graf 2206a7e5d7 Add new sponsor to README.rst
We are proud to announce @socialmethod as our new sponsor.
2022-09-16 19:03:24 +02:00
Alexander Graf f1b5e9cdf1 Increase stale period for pull requests and issues 2022-09-07 21:09:46 +02:00
Eduardo Kalinowski 8debcbb7ab Fixes incorrect latest stamp when download is resumed (#1717) 2022-09-07 21:01:48 +02:00
Alexander Graf e97fd8a27c Release of Version 4.9.3 2022-08-11 20:00:42 +02:00
fireattack 7f9d1618ad Use api/v1/feed/reels_media endpoint to get story info (#1675) 2022-08-11 19:59:02 +02:00
fireattack 6a42906697 don't try to call iPhone api when comparing profile_pic URL (#1681) 2022-08-11 19:56:52 +02:00
fireattack 4bb32e3c5a write caption in text mode (#1632) 2022-07-14 21:00:24 +02:00
19 changed files with 1147 additions and 739 deletions
+3 -3
View File
@@ -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
+6 -5
View File
@@ -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", "3.11"]
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
+2 -2
View File
@@ -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
+467 -405
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -127,6 +127,9 @@ Supporters
.. current-sponsors-start
| Instaloader is proudly sponsored by
| `@socialmethod <https://github.com/socialmethod>`__
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader!
+1 -1
View File
@@ -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}}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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::
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.9.2'
__version__ = '4.9.6'
try:
-1
View File
@@ -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":
+18 -14
View File
@@ -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:
@@ -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
@@ -879,13 +878,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:
@@ -1076,7 +1080,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.
@@ -1097,7 +1101,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.
+4 -5
View File
@@ -24,10 +24,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
@@ -331,7 +331,8 @@ class InstaloaderContext:
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
@@ -589,7 +590,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 +613,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]:
+2 -2
View File
@@ -77,7 +77,7 @@ class NodeIterator(Iterator[T]):
query_variables: Optional[Dict[str, Any]] = None,
query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T], bool]] = None):
is_first: Optional[Callable[[T, Optional[T]], bool]] = None):
self._context = context
self._query_hash = query_hash
self._edge_extractor = edge_extractor
@@ -131,7 +131,7 @@ class NodeIterator(Iterator[T]):
raise
item = self._node_wrapper(node)
if self._is_first is not None:
if self._is_first(item):
if self._is_first(item, self.first_item):
self._first_node = node
else:
if self._first_node is None:
+1 -1
View File
@@ -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:
+57 -24
View File
@@ -293,7 +293,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 +357,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)
@@ -406,7 +405,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
@@ -559,7 +558,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
@@ -639,7 +638,7 @@ 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
@@ -953,11 +952,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
@@ -1033,17 +1039,8 @@ class Profile:
)
@staticmethod
def _make_is_newest_checker() -> Callable[[Post], bool]:
newest_date: Optional[datetime] = None
def is_newest(p: Post) -> bool:
nonlocal newest_date
post_date = p.date_local
if newest_date is None or post_date > newest_date:
newest_date = post_date
return True
else:
return False
return is_newest
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_followers(self) -> NodeIterator['Profile']:
"""
@@ -1172,8 +1169,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
@@ -1237,7 +1240,7 @@ class StoryItem:
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
@@ -1312,6 +1315,7 @@ class Story:
self._node = node
self._unique_id = None # type: Optional[str]
self._owner_profile = None # type: Optional[Profile]
self._iphone_struct_ = None # type: Optional[Dict[str, Any]]
def __repr__(self):
return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc)
@@ -1382,9 +1386,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):
@@ -1414,6 +1432,7 @@ class Highlight(Story):
super().__init__(context, node)
self._owner_profile = owner
self._items = None # type: Optional[List[Dict[str, Any]]]
self._iphone_struct_ = None # type: Optional[Dict[str, Any]]
def __repr__(self):
return '<Highlight by {}: {}>'.format(self.owner_username, self.title)
@@ -1452,6 +1471,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."""
@@ -1462,8 +1488,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:
@@ -1517,7 +1550,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):
+5 -5
View File
@@ -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'