Compare commits

...
18 Commits
Author SHA1 Message Date
Alexander Graf 5e232fd4f5 Release of Version 4.9.2 2022-07-04 20:18:22 +02:00
Eduardo Kalinowski da0dcb106c Handle pinned posts with --fast-update and --latest-stamps (#1586)
Instead of hard-coding a check for pinned items when storing the first
item, allows the creater of NodeIterator to specify how the comparison
should be made.

This also allows storing the newest post (whatever it's status is),
instead of simply storing the newst non-pinned post, which prevents
redownloads in case a user adds only a pinned post between runs.
2022-07-04 20:02:41 +02:00
Alexander Graf 8649a10113 docs: fix link to requests intersphinx inventory 2022-06-23 20:50:05 +02:00
Ziemowit Zabawa 2d37e1dca2 Make docs explicit about importing multiple argument files (#1548) 2022-06-16 10:25:43 +02:00
fireattack 37cbc5d72d Handle StoryImage in StoryItem.url (#1558) 2022-06-16 10:18:59 +02:00
Alexander Graf 853e860363 Release of Version 4.9.1 2022-06-08 18:13:38 +02:00
twitter-79 90cfeaf471 Fixed fetching a users profile information (#1561)
* fixed fetching a users profile information

switched to new api/v1/users/web_profile_info/?username=xy endpoint

* fixed non existing users

thanks to @Dibort for fixing this!
2022-06-08 18:12:07 +02:00
Nicolai Weitkemper 5a24064012 Fix incorrect error messages when certain requests fail (#1536)
- handle case where `Profile.from_id` works, but `Profile.from_username` doesn't

- Handle case where `QueryReturnedNotFoundException` lies and `similar_profiles` contains the exact username

- Still raises `ProfileNotExistsException` for compatibility.
2022-05-30 21:29:34 +02:00
MiguelX413 0704602e50 Checks if caption is not None before normalization (#1475) 2022-04-18 09:49:34 +02:00
Alexander Graf 218d88fc6d Release of Version 4.9 2022-03-26 17:18:56 +01:00
Alexander Graf 385c6c8a35 Merge branch 'upcoming/v4.9' 2022-03-26 17:15:09 +01:00
Alexander Graf c0e5d0475b StoryItem.from_mediaid()
Addresses #1238.
2022-03-26 17:03:57 +01:00
Alexander Graf fbd7df2a90 Use resumable iteration to download hashtags
Closes #1457.
2022-03-26 17:02:06 +01:00
JeremyKj87 569808f7a4 Fix for infinity loading if next page is empty (#1465) 2022-03-26 15:57:45 +01:00
Alexander Graf b808843efe Actions: Optimize Publish Package workflow 2022-03-19 17:27:22 +01:00
Alexander Graf f66cdd3e07 Release of Version 4.8.5 2022-03-19 17:15:52 +01:00
Alexander Graf 83dc5f749d Attempt to fix Documentation deployment 2022-03-19 17:02:24 +01:00
Alexander Graf 41de9e5a7c Attempt to fix AUR deployment 2022-03-19 16:53:55 +01:00
9 changed files with 99 additions and 27 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ jobs:
branch: master branch: master
repository-name: instaloader/instaloader.github.io repository-name: instaloader/instaloader.github.io
folder: docs/_build/html folder: docs/_build/html
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.PAGES_DEPLOYMENT_TOKEN }}
-4
View File
@@ -22,10 +22,6 @@ jobs:
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/} run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
shell: bash shell: bash
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2022.1.8
pipenv sync --dev
- name: "Generate Python Package" - name: "Generate Python Package"
run: python setup.py sdist run: python setup.py sdist
- name: "Publish Distribution to PyPI" - name: "Publish Distribution to PyPI"
+2 -1
View File
@@ -3,7 +3,7 @@
VERSION=${1:1} VERSION=${1:1}
# do not deploy pre-releases # do not deploy pre-releases
#echo $VERSION | grep -qe "[abc]" && exit 0 echo $VERSION | grep -qe "[abc]" && exit 0
cd $(dirname $0) cd $(dirname $0)
@@ -12,6 +12,7 @@ openssl aes-256-cbc -K $AUR_KEY -iv $AUR_IV -in id_rsa_AUR.enc -out /tmp/AUR_ope
eval "$(ssh-agent -s)" eval "$(ssh-agent -s)"
chmod 600 /tmp/AUR_openssh chmod 600 /tmp/AUR_openssh
ssh-add /tmp/AUR_openssh ssh-add /tmp/AUR_openssh
mkdir -p ~/.ssh
ssh-keyscan -t rsa aur.archlinux.org >> ~/.ssh/known_hosts ssh-keyscan -t rsa aur.archlinux.org >> ~/.ssh/known_hosts
# clone and modify AUR repo # clone and modify AUR repo
+4 -2
View File
@@ -258,7 +258,8 @@ How to Download
- Profile posts, - Profile posts,
- Profile IGTV posts (:option:`--igtv`), - Profile IGTV posts (:option:`--igtv`),
- Profile tagged posts (:option:`--tagged`), - Profile tagged posts (:option:`--tagged`),
- Saved posts (``:saved``). - Saved posts (``:saved``),
- Hashtags.
This feature is enabled by default for targets where it is supported; This feature is enabled by default for targets where it is supported;
:option:`--resume-prefix` only changes the name of the iterator files. :option:`--resume-prefix` only changes the name of the iterator files.
@@ -328,12 +329,13 @@ Miscellaneous Options
Read arguments from file `args.txt`, a shortcut to provide arguments from 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 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. 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:: .. note::
Text file should separate arguments with line breaks. Text file should separate arguments with line breaks.
args.txt example:: `args.txt` example::
--login=MYUSERNAME --login=MYUSERNAME
--password=MYPASSWORD --password=MYPASSWORD
+1 -1
View File
@@ -48,7 +48,7 @@ autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
autodoc_member_order = 'bysource' 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.readthedocs.io/en/latest/', None)}
nitpick_ignore = [('py:class', 'typing.Tuple')] nitpick_ignore = [('py:class', 'typing.Tuple')]
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram.""" """Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.9b3' __version__ = '4.9.2'
try: try:
+12 -5
View File
@@ -1010,7 +1010,10 @@ class Instaloader:
enabled=self.resume_prefix is not None enabled=self.resume_prefix is not None
) as (is_resuming, start_index): ) as (is_resuming, start_index):
for number, post in enumerate(posts, start=start_index + 1): 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 break
if displayed_count is not None: if displayed_count is not None:
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count, self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
@@ -1042,7 +1045,7 @@ class Instaloader:
except PostChangedException: except PostChangedException:
post_changed = True post_changed = True
continue 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 # disengage fast_update for first post when resuming
if not is_resuming or number > 0: if not is_resuming or number > 0:
break break
@@ -1176,8 +1179,8 @@ class Instaloader:
"""Get Posts associated with a #hashtag. """Get Posts associated with a #hashtag.
.. deprecated:: 4.4 .. deprecated:: 4.4
Use :meth:`Hashtag.get_posts`.""" Use :meth:`Hashtag.get_posts_resumable`."""
return Hashtag.from_name(self.context, hashtag).get_posts() return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
def download_hashtag(self, hashtag: Union[Hashtag, str], def download_hashtag(self, hashtag: Union[Hashtag, str],
max_count: Optional[int] = None, max_count: Optional[int] = None,
@@ -1213,7 +1216,7 @@ class Instaloader:
self.download_hashtag_profilepic(hashtag) self.download_hashtag_profilepic(hashtag)
if posts: if posts:
self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name)) self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name))
self.posts_download_loop(hashtag.get_all_posts(), target, fast_update, post_filter, self.posts_download_loop(hashtag.get_posts_resumable(), target, fast_update, post_filter,
max_count=max_count) max_count=max_count)
if self.save_metadata: if self.save_metadata:
json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target, json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target,
@@ -1334,6 +1337,10 @@ class Instaloader:
profile_id)) profile_id))
profile_from_id = Profile.from_id(self.context, profile_id) profile_from_id = Profile.from_id(self.context, profile_id)
newname = profile_from_id.username 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)) self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
if latest_stamps is None: if latest_stamps is None:
if ((format_string_contains_key(self.dirname_pattern, 'profile') or if ((format_string_contains_key(self.dirname_pattern, 'profile') or
+16 -4
View File
@@ -76,7 +76,8 @@ class NodeIterator(Iterator[T]):
node_wrapper: Callable[[Dict], T], node_wrapper: Callable[[Dict], T],
query_variables: Optional[Dict[str, Any]] = None, query_variables: Optional[Dict[str, Any]] = None,
query_referer: Optional[str] = None, query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None): first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T], bool]] = None):
self._context = context self._context = context
self._query_hash = query_hash self._query_hash = query_hash
self._edge_extractor = edge_extractor self._edge_extractor = edge_extractor
@@ -91,6 +92,7 @@ class NodeIterator(Iterator[T]):
else: else:
self._data = self._query() self._data = self._query()
self._first_node: Optional[Dict] = None self._first_node: Optional[Dict] = None
self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict: def _query(self, after: Optional[str] = None) -> Dict:
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any] pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
@@ -128,12 +130,16 @@ class NodeIterator(Iterator[T]):
self._page_index, self._total_index = page_index, total_index self._page_index, self._total_index = page_index, total_index
raise raise
item = self._node_wrapper(node) item = self._node_wrapper(node)
if self._first_node is None: if self._is_first is not None:
self._first_node = node if self._is_first(item):
self._first_node = node
else:
if self._first_node is None:
self._first_node = node
return item return item
if self._data['page_info']['has_next_page']: if self._data['page_info']['has_next_page']:
query_response = self._query(self._data['page_info']['end_cursor']) query_response = self._query(self._data['page_info']['end_cursor'])
if self._data['edges'] != query_response['edges']: if self._data['edges'] != query_response['edges'] and len(query_response['edges']) > 0:
page_index, data = self._page_index, self._data page_index, data = self._page_index, self._data
try: try:
self._page_index = 0 self._page_index = 0
@@ -168,7 +174,13 @@ class NodeIterator(Iterator[T]):
""" """
If this iterator has produced any items, returns the first item produced. 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 .. 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 return self._node_wrapper(self._first_node) if self._first_node is not None else None
+62 -8
View File
@@ -7,7 +7,7 @@ from contextlib import suppress
from datetime import datetime from datetime import datetime
from itertools import islice from itertools import islice
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
from unicodedata import normalize from unicodedata import normalize
from . import __version__ from . import __version__
@@ -365,10 +365,16 @@ class Post:
@property @property
def caption(self) -> Optional[str]: def caption(self) -> Optional[str]:
"""Caption.""" """Caption."""
def _normalize(string: Optional[str]) -> Optional[str]:
if string is not None:
return normalize("NFC", string)
else:
return None
if "edge_media_to_caption" in self._node and self._node["edge_media_to_caption"]["edges"]: 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 _normalize(self._node["edge_media_to_caption"]["edges"][0]["node"]["text"])
elif "caption" in self._node: elif "caption" in self._node:
return normalize("NFC", self._node["caption"]) return _normalize(self._node["caption"])
return None return None
@property @property
@@ -638,6 +644,13 @@ class Post:
loc.get('lat'), loc.get('lng')) loc.get('lat'), loc.get('lng'))
return self._location 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: class Profile:
""" """
@@ -758,13 +771,19 @@ 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('{}/feed/'.format(self.username), params={}) metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}',
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user'] 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 self._has_full_metadata = True
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()]
if similar_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{}: {}.' raise ProfileNotExistsException('Profile {} does not exist.\nThe most similar profile{}: {}.'
.format(self.username, .format(self.username,
's are' if len(similar_profiles) > 1 else ' is', 's are' if len(similar_profiles) > 1 else ' is',
@@ -958,6 +977,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),
self._metadata('edge_owner_to_timeline_media'), self._metadata('edge_owner_to_timeline_media'),
Profile._make_is_newest_checker()
) )
def get_saved_posts(self) -> NodeIterator[Post]: def get_saved_posts(self) -> NodeIterator[Post]:
@@ -991,6 +1011,7 @@ class Profile:
lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None), lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None),
{'id': self.userid}, {'id': self.userid},
'https://www.instagram.com/{0}/'.format(self.username), 'https://www.instagram.com/{0}/'.format(self.username),
is_first=Profile._make_is_newest_checker()
) )
def get_igtv_posts(self) -> NodeIterator[Post]: def get_igtv_posts(self) -> NodeIterator[Post]:
@@ -1008,8 +1029,22 @@ 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),
self._metadata('edge_felix_video_timeline'), self._metadata('edge_felix_video_timeline'),
Profile._make_is_newest_checker()
) )
@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 get_followers(self) -> NodeIterator['Profile']: def get_followers(self) -> NodeIterator['Profile']:
""" """
Retrieve list of followers of given profile. Retrieve list of followers of given profile.
@@ -1115,6 +1150,21 @@ class StoryItem:
def __hash__(self) -> int: def __hash__(self) -> int:
return hash(self.mediaid) return hash(self.mediaid)
@classmethod
def from_mediaid(cls, context: InstaloaderContext, mediaid: int):
"""Create a StoryItem object from a given mediaid.
.. versionadded:: 4.9
"""
pic_json = context.graphql_query(
'2b0673e0dc4580674a88d426fe00ea90',
{'shortcode': Post.mediaid_to_shortcode(mediaid)}
)
shortcode_media = pic_json['data']['shortcode_media']
if shortcode_media is None:
raise BadResponseException("Fetching StoryItem metadata failed.")
return cls(context, shortcode_media)
@property @property
def _iphone_struct(self) -> Dict[str, Any]: def _iphone_struct(self) -> Dict[str, Any]:
if not self._context.iphone_support: if not self._context.iphone_support:
@@ -1180,7 +1230,8 @@ class StoryItem:
@property @property
def url(self) -> str: def url(self) -> str:
"""URL of the picture / video thumbnail of the StoryItem""" """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: try:
orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url'] orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url']
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&') url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
@@ -1553,7 +1604,10 @@ class Hashtag:
return self._metadata("media_count") return self._metadata("media_count")
def get_posts(self) -> Iterator[Post]: def get_posts(self) -> Iterator[Post]:
"""Yields the recent posts associated with this hashtag.""" """Yields the recent posts associated with this hashtag.
.. deprecated:: 4.9
Use :meth:`Hashtag.get_posts_resumable` as this method may return incorrect results (:issue:`1457`)"""
try: try:
self._metadata("edge_hashtag_to_media", "edges") self._metadata("edge_hashtag_to_media", "edges")
self._metadata("edge_hashtag_to_media", "page_info") self._metadata("edge_hashtag_to_media", "page_info")
@@ -1575,7 +1629,7 @@ class Hashtag:
def get_all_posts(self) -> Iterator[Post]: def get_all_posts(self) -> Iterator[Post]:
"""Yields all posts, i.e. all most recent posts and the top posts, in almost-chronological order.""" """Yields all posts, i.e. all most recent posts and the top posts, in almost-chronological order."""
sorted_top_posts = iter(sorted(islice(self.get_top_posts(), 9), key=lambda p: p.date_utc, reverse=True)) sorted_top_posts = iter(sorted(islice(self.get_top_posts(), 9), key=lambda p: p.date_utc, reverse=True))
other_posts = self.get_posts() other_posts = self.get_posts_resumable()
next_top = next(sorted_top_posts, None) next_top = next(sorted_top_posts, None)
next_other = next(other_posts, None) next_other = next(other_posts, None)
while next_top is not None or next_other is not None: while next_top is not None or next_other is not None: