From f5fa1b7f8e480af725994ae18e0ac1d435e689c5 Mon Sep 17 00:00:00 2001 From: jhd3197 Date: Sat, 26 Dec 2020 01:49:33 -0500 Subject: [PATCH 1/6] Adding storyUpload to photo.py --- README.md | 87 ++++++++++++------------------- examples/photos/example.jpg | Bin 0 -> 14300 bytes examples/photos/upload_photos.py | 6 +++ instagrapi/mixins/photo.py | 3 ++ 4 files changed, 43 insertions(+), 53 deletions(-) create mode 100644 examples/photos/example.jpg create mode 100644 examples/photos/upload_photos.py diff --git a/README.md b/README.md index 45e8f5c1..432d23e5 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Fast and effective Instagram Private API wrapper (public+private requests and ch Support **Python >= 3.6** -Instagram API valid for 17 December 2020 (last reverse-engineering check) +Instagram API valid for 7 November 2020 (last reverse-engineering check) [Support Chat in Telegram](https://t.me/instagrapi) -![](https://gist.githubusercontent.com/m8rge/4c2b36369c9f936c02ee883ca8ec89f1/raw/c03fd44ee2b63d7a2a195ff44e9bb071e87b4a40/telegram-single-path-24px.svg) and [GitHub Discussions](https://github.com/adw0rd/instagrapi/discussions) +![](https://gist.githubusercontent.com/m8rge/4c2b36369c9f936c02ee883ca8ec89f1/raw/c03fd44ee2b63d7a2a195ff44e9bb071e87b4a40/telegram-single-path-24px.svg) and [Discord](https://discord.gg/vM9SJAD4) ### Authors @@ -14,14 +14,12 @@ Instagram API valid for 17 December 2020 (last reverse-engineering check) ### Features -1. Performs Public API (web, anonymous) or Private API (mobile app, authorized) requests depending on the situation (to avoid Instagram limits) +1. Performs public (`_gql` or `_a1` suffix methods) or private/auth (`_v1` suffix methods) requests depending on the situation (to avoid Instagram limits) 2. Challenge Resolver have [Email](/examples/challenge_resolvers.py) (as well as recipes for automating receive a code from email) and [SMS handlers](/examples/challenge_resolvers.py) 3. Support upload a Photo, Video, IGTV, Albums and Stories -4. Support work with User, Media, Insights, Collections, Location (Place), Hashtag and Direct objects -5. Like, Follow, Edit account (Bio) and much more else -6. Insights by account, posts and stories -7. Build stories with custom background, font animation, swipe up link and mention users -8. In the next release, account registration and captcha passing will appear +4. Support work with User, Media, Insights, Collections and Direct objects +5. Insights by posts and stories +6. Build stories with custom background and font animation ### Install @@ -34,13 +32,13 @@ Instagram API valid for 17 December 2020 (last reverse-engineering check) ### Requests -* `Public` (anonymous request via web api) methods have a suffix `_gql` (Instagram `GraphQL`) or `_a1` (example `https://www.instagram.com/adw0rd/?__a=1`) -* `Private` (authorized request via mobile api) methods have `_v1` suffix +* `Public` (anonymous) methods had suffix `_gql` (Instagram `GraphQL`) or `_a1` (example `https://www.instagram.com/adw0rd/?__a=1`) +* `Private` (authorized request) methods have `_v1` suffix The first request to fetch media/user is `public` (anonymous), if instagram raise exception, then use `private` (authorized). Example (pseudo-code): -``` python +``` def media_info(media_pk): try: return self.media_info_gql(media_pk) @@ -52,7 +50,7 @@ def media_info(media_pk): ### Usage -``` python +``` from instagrapi import Client cl = Client() @@ -76,7 +74,7 @@ The current types are in [types.py](/instagrapi/types.py): | UserShort | Short public user data (used in Usertag, Comment, Media, Direct) | | Usertag | Tag user in Media (coordinates + UserShort) | | Location | GEO location (GEO coordinates, name, address) | -| Hashtag | Hashtag object (id, name, picture) | +| Hashtag | Hashtag object (id, name, picture) | Collection | Collection of medias (name, picture and list of medias) | | Comment | Comments to Media | | StoryMention | Mention users in Story (user, coordinates and dimensions) | @@ -108,7 +106,7 @@ This is your authorized account Example: -``` python +``` cl.login("instagrapi", "42") # cl.login_by_sessionid("peiWooShooghahdi2Eip7phohph0eeng") cl.set_proxy("socks5://127.0.0.1:30235") @@ -120,7 +118,7 @@ print(cl.user_info(cl.user_id)) You can pass settings to the Client (and save cookies), it has the following format: -``` python +``` settings = { "uuids": { "phone_id": "57d64c41-a916-3fa5-bd7a-3796c1dab122", @@ -168,15 +166,15 @@ Viewing and editing publications (medias) | media_pk_from_url(url: str) | int | Return media_pk | | media_info(media_pk: int) | Media | Return media info | | media_delete(media_pk: int) | bool | Delete media | -| media_edit(media_pk: int, caption: str, title: str, usertags: List[Usertag], location: Location) | dict | Change caption for media | +| media_edit(media_pk: int, caption: str, title: str, usertags: List[Usertag], location: Location) | dict | Change caption for media | | media_user(media_pk: int) | User | Get user info for media | | media_oembed(url: str) | MediaOembed | Return short media info by media URL | -| media_like(media_id: str) | bool | Like media | -| media_unlike(media_id: str) | bool | Unlike media | +| media_comment(media_id: str, message: str) | bool | Write message to media | +| media_comments(media_id: str) | List\[Comment] | Get all comments | Example: -``` python +``` >>> cl.media_pk_from_code("B-fKL9qpeab") 2278584739065882267 @@ -246,16 +244,6 @@ Example: ``` -#### Comment - -| Method | Return | Description | -| -------------------------------------------------- | ------------------ | ------------------------------------------------------------- | -| media_comment(media_id: str, message: str) | bool | Add new comment to media | -| media_comments(media_id: str) | List\[Comment] | Get all comments for media | -| comment_like(comment_pk: int) | bool | Like comment | -| comment_unlike(comment_pk: int) | bool | Unlike comment | - - #### User View a list of a user's medias, following and followers @@ -276,18 +264,18 @@ View a list of a user's medias, following and followers Example: -``` python +``` >>> cl.user_followers(cl.user_id).keys() dict_keys([5563084402, 43848984510, 1498977320, ...]) >>> cl.user_following(cl.user_id) { - 8530498223: UserShort( - pk=8530498223, - username="something", - full_name="Example description", + 8530598273: UserShort( + pk=8530598273, + username="dhbastards", + full_name="The Best DH Skaters Ever", profile_pic_url=HttpUrl( - 'https://instagram.frix7-1.fna.fbcdn.net/v/t5...9217617140_n.jpg', + 'https://instagram.frix7-1.fna.fbcdn.net/v/t5...9318717440_n.jpg', scheme='https', host='instagram.frix7-1.fna.fbcdn.net', ... @@ -317,7 +305,7 @@ dict_keys([5563084402, 43848984510, 1498977320, ...]) 'media_count': 102, 'follower_count': 576, 'following_count': 538, - 'biography': 'Engineer: Python, JavaScript, Erlang', + 'biography': 'Engineer: Python, JavaScript, Erlang\n@dhbastards \n@bestskatetrick \n@best_drift_daily \n@best_rally_mag \n@asphalt_kings_lb \n@surferyone \n@bmxtravel', 'external_url': HttpUrl('https://adw0rd.com/', scheme='https', host='adw0rd.com', tld='com', host_type='domain', path='/'), 'is_business': False} @@ -342,12 +330,13 @@ Upload medias to your feed. Common arguments: * `path` - Path to source file * `caption` - Text for you post +* `storyUpload` - If True will create a story from image (default: False) * `usertags` - List[Usertag] of mention users (see `Usertag` in [types.py](/instagrapi/types.py)) * `location` - Location (e.g. `Location(lat=42.0, lng=42.0)`) | Method | Return | Description | | ------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------- | -| photo_upload(path: Path, caption: str, upload_id: str, usertags: List[Usertag], location: Location) | Media | Upload photo (Support JPG files) | +| photo_upload(path: Path, caption: str, storyUpload:bool , upload_id: str, usertags: List[Usertag], location: Location) | Media | Upload photo (Support JPG files) | | video_upload(path: Path, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location) | Media | Upload video (Support MP4 files) | | igtv_upload(path: Path, title: str, caption: str, thumbnail: Path, usertags: List[Usertag], location: Location) | Media | Upload IGTV (Support MP4 files) | | album_upload(paths: List[Path], caption: str, usertags: List[Usertag], location: Location) | Media | Upload Album (Support JPG and MP4) | @@ -370,7 +359,7 @@ Upload medias to your stories. Common arguments: Examples: -``` python +``` media_path = cl.video_download( cl.media_pk_from_url('https://www.instagram.com/p/CGgDsi7JQdS/') ) @@ -394,7 +383,7 @@ cl.video_upload_to_story( Example: -``` python +``` from instagrapi.story import StoryBuilder media_path = cl.video_download( @@ -417,12 +406,6 @@ cl.video_upload_to_story( ) ``` -Result: - -![](https://github.com/adw0rd/instagrapi/blob/master/examples/dhb.gif) - -More stories here https://www.instagram.com/surferyone/ - #### Collections @@ -459,14 +442,12 @@ Get statistics by medias. Common arguments: #### Location -| Method | Return | Description | -| ---------------------------------------------------------- | -------------- | ----------------------------------------------------------------------- | -| location_search(lat: float, lng: float) | List[Location] | Search Location by GEO coordinates -| location_complete(location: Location) | Location | Complete blank fields -| location_build(location: Location) | String | Serialized JSON -| location_info(location_pk: int) | Location | Return Location info (pk, name, address, lng, lat, external_id, external_id_source) -| location_medias_top(location_pk: int, amount: int = 9) | List[Media] | Return Top posts by Location -| location_medias_recent(location_pk: int, amount: int = 24) | List[Media] | Return Most recent posts by Location +| Method | Return | Description | +| ----------------------------------------- | -------------- | ----------------------------------------------------------------------- | +| location_search(lat: float, lng: float) | List[Location] | Search Location by GEO coordinates +| location_complete(location: Location) | Location | Complete blank fields +| location_build(location: Location) | String | Serialized JSON +| location_info(location_pk: int) | Location | Return Location info (pk, name, address, lng, lat, external_id, external_id_source) #### Hashtag diff --git a/examples/photos/example.jpg b/examples/photos/example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..46beb5475933d7601d1ceb5f58056bb6c7165eaf GIT binary patch literal 14300 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 Date: Sat, 26 Dec 2020 02:43:05 -0500 Subject: [PATCH 2/6] Adding blur to story Adding blur and adjusting some default parameters --- examples/photos/example.jpg_STORIES.jpg | Bin 0 -> 70375 bytes instagrapi/mixins/photo.py | 64 +++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 examples/photos/example.jpg_STORIES.jpg diff --git a/examples/photos/example.jpg_STORIES.jpg b/examples/photos/example.jpg_STORIES.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dd532f99c1f579255a877aa0a9cd06a2c49aa222 GIT binary patch literal 70375 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.2.0") @@ -113,6 +113,7 @@ def photo_upload( self, path: Path, caption: str, + storyUpload:bool=False, upload_id: str = "", usertags: List[Usertag] = [], location: Location = None, @@ -197,10 +198,67 @@ def photo_configure( } return self.private_request("media/configure/", self.with_default_data(data)) + def stories_shaper(self,path): + img = Image.open(path) + if (img.size[0], img.size[1]) == (1080, 1920): + print("Image is already 1080x1920. Just converting image.") + new_path = "{path}_STORIES.jpg".format(path=path) + new = Image.new("RGB", (img.size[0], img.size[1]), (255, 255, 255)) + new.paste(img, (0, 0, img.size[0], img.size[1])) + new.save(new_path) + return new_path + else: + min_width = 1080 + min_height = 1920 + if img.size[1] != 1920: + height_percent = min_height / float(img.size[1]) + width_size = int(float(img.size[0]) * float(height_percent)) + img = img.resize((width_size, min_height), Image.ANTIALIAS) + else: + pass + if img.size[0] < 1080: + width_percent = min_width / float(img.size[0]) + height_size = int(float(img.size[1]) * float(width_percent)) + img_bg = img.resize((min_width, height_size), Image.ANTIALIAS) + else: + pass + img_bg = img.crop( + ( + int((img.size[0] - 1080) / 2), + int((img.size[1] - 1920) / 2), + int(1080 + ((img.size[0] - 1080) / 2)), + int(1920 + ((img.size[1] - 1920) / 2)), + ) + ).filter(ImageFilter.GaussianBlur(100)) + if img.size[1] > img.size[0]: + height_percent = min_height / float(img.size[1]) + width_size = int(float(img.size[0]) * float(height_percent)) + img = img.resize((width_size, min_height), Image.ANTIALIAS) + if img.size[0] > 1080: + width_percent = min_width / float(img.size[0]) + height_size = int(float(img.size[1]) * float(width_percent)) + img = img.resize((min_width, height_size), Image.ANTIALIAS) + img_bg.paste( + img, (int(540 - img.size[0] / 2), int(960 - img.size[1] / 2)) + ) + else: + img_bg.paste(img, (int(540 - img.size[0] / 2), 0)) + else: + width_percent = min_width / float(img.size[0]) + height_size = int(float(img.size[1]) * float(width_percent)) + img = img.resize((min_width, height_size), Image.ANTIALIAS) + img_bg.paste(img, (int(540 - img.size[0] / 2), int(960 - img.size[1] / 2))) + new_path = "{path}_STORIES.jpg".format(path=path) + new = Image.new("RGB", (img_bg.size[0], img_bg.size[1]), (255, 255, 255)) + new.paste(img_bg, (0, 0, img_bg.size[0], img_bg.size[1])) + new.save(new_path) + return new_path + def photo_upload_to_story( self, path: Path, caption: str, + blur:bool=True, upload_id: str = "", mentions: List[StoryMention] = [], links: List[StoryLink] = [], @@ -218,6 +276,9 @@ def photo_upload_to_story( :return: Media """ + if blur: + path=self.stories_shaper(path) + return self.photo_upload( path, caption, upload_id, mentions, links=links, @@ -226,6 +287,7 @@ def photo_upload_to_story( configure_exception=PhotoConfigureStoryError ) + def photo_configure_to_story( self, upload_id: str, From 038133790a5723568b5f9861a753e8ba726c7259 Mon Sep 17 00:00:00 2001 From: jhd3197 Date: Sat, 26 Dec 2020 02:51:56 -0500 Subject: [PATCH 3/6] Update README.md --- README.md | 89 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 432d23e5..9b38d925 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Fast and effective Instagram Private API wrapper (public+private requests and ch Support **Python >= 3.6** -Instagram API valid for 7 November 2020 (last reverse-engineering check) +Instagram API valid for 17 December 2020 (last reverse-engineering check) [Support Chat in Telegram](https://t.me/instagrapi) -![](https://gist.githubusercontent.com/m8rge/4c2b36369c9f936c02ee883ca8ec89f1/raw/c03fd44ee2b63d7a2a195ff44e9bb071e87b4a40/telegram-single-path-24px.svg) and [Discord](https://discord.gg/vM9SJAD4) +![](https://gist.githubusercontent.com/m8rge/4c2b36369c9f936c02ee883ca8ec89f1/raw/c03fd44ee2b63d7a2a195ff44e9bb071e87b4a40/telegram-single-path-24px.svg) and [GitHub Discussions](https://github.com/adw0rd/instagrapi/discussions) ### Authors @@ -14,12 +14,14 @@ Instagram API valid for 7 November 2020 (last reverse-engineering check) ### Features -1. Performs public (`_gql` or `_a1` suffix methods) or private/auth (`_v1` suffix methods) requests depending on the situation (to avoid Instagram limits) +1. Performs Public API (web, anonymous) or Private API (mobile app, authorized) requests depending on the situation (to avoid Instagram limits) 2. Challenge Resolver have [Email](/examples/challenge_resolvers.py) (as well as recipes for automating receive a code from email) and [SMS handlers](/examples/challenge_resolvers.py) 3. Support upload a Photo, Video, IGTV, Albums and Stories -4. Support work with User, Media, Insights, Collections and Direct objects -5. Insights by posts and stories -6. Build stories with custom background and font animation +4. Support work with User, Media, Insights, Collections, Location (Place), Hashtag and Direct objects +5. Like, Follow, Edit account (Bio) and much more else +6. Insights by account, posts and stories +7. Build stories with custom background, font animation, swipe up link and mention users +8. In the next release, account registration and captcha passing will appear ### Install @@ -32,13 +34,13 @@ Instagram API valid for 7 November 2020 (last reverse-engineering check) ### Requests -* `Public` (anonymous) methods had suffix `_gql` (Instagram `GraphQL`) or `_a1` (example `https://www.instagram.com/adw0rd/?__a=1`) -* `Private` (authorized request) methods have `_v1` suffix +* `Public` (anonymous request via web api) methods have a suffix `_gql` (Instagram `GraphQL`) or `_a1` (example `https://www.instagram.com/adw0rd/?__a=1`) +* `Private` (authorized request via mobile api) methods have `_v1` suffix The first request to fetch media/user is `public` (anonymous), if instagram raise exception, then use `private` (authorized). Example (pseudo-code): -``` +``` python def media_info(media_pk): try: return self.media_info_gql(media_pk) @@ -50,7 +52,7 @@ def media_info(media_pk): ### Usage -``` +``` python from instagrapi import Client cl = Client() @@ -74,7 +76,7 @@ The current types are in [types.py](/instagrapi/types.py): | UserShort | Short public user data (used in Usertag, Comment, Media, Direct) | | Usertag | Tag user in Media (coordinates + UserShort) | | Location | GEO location (GEO coordinates, name, address) | -| Hashtag | Hashtag object (id, name, picture) +| Hashtag | Hashtag object (id, name, picture) | | Collection | Collection of medias (name, picture and list of medias) | | Comment | Comments to Media | | StoryMention | Mention users in Story (user, coordinates and dimensions) | @@ -106,7 +108,7 @@ This is your authorized account Example: -``` +``` python cl.login("instagrapi", "42") # cl.login_by_sessionid("peiWooShooghahdi2Eip7phohph0eeng") cl.set_proxy("socks5://127.0.0.1:30235") @@ -118,7 +120,7 @@ print(cl.user_info(cl.user_id)) You can pass settings to the Client (and save cookies), it has the following format: -``` +``` python settings = { "uuids": { "phone_id": "57d64c41-a916-3fa5-bd7a-3796c1dab122", @@ -166,15 +168,15 @@ Viewing and editing publications (medias) | media_pk_from_url(url: str) | int | Return media_pk | | media_info(media_pk: int) | Media | Return media info | | media_delete(media_pk: int) | bool | Delete media | -| media_edit(media_pk: int, caption: str, title: str, usertags: List[Usertag], location: Location) | dict | Change caption for media | +| media_edit(media_pk: int, caption: str, title: str, usertags: List[Usertag], location: Location) | dict | Change caption for media | | media_user(media_pk: int) | User | Get user info for media | | media_oembed(url: str) | MediaOembed | Return short media info by media URL | -| media_comment(media_id: str, message: str) | bool | Write message to media | -| media_comments(media_id: str) | List\[Comment] | Get all comments | +| media_like(media_id: str) | bool | Like media | +| media_unlike(media_id: str) | bool | Unlike media | Example: -``` +``` python >>> cl.media_pk_from_code("B-fKL9qpeab") 2278584739065882267 @@ -244,6 +246,16 @@ Example: ``` +#### Comment + +| Method | Return | Description | +| -------------------------------------------------- | ------------------ | ------------------------------------------------------------- | +| media_comment(media_id: str, message: str) | bool | Add new comment to media | +| media_comments(media_id: str) | List\[Comment] | Get all comments for media | +| comment_like(comment_pk: int) | bool | Like comment | +| comment_unlike(comment_pk: int) | bool | Unlike comment | + + #### User View a list of a user's medias, following and followers @@ -264,18 +276,18 @@ View a list of a user's medias, following and followers Example: -``` +``` python >>> cl.user_followers(cl.user_id).keys() dict_keys([5563084402, 43848984510, 1498977320, ...]) >>> cl.user_following(cl.user_id) { - 8530598273: UserShort( - pk=8530598273, - username="dhbastards", - full_name="The Best DH Skaters Ever", + 8530498223: UserShort( + pk=8530498223, + username="something", + full_name="Example description", profile_pic_url=HttpUrl( - 'https://instagram.frix7-1.fna.fbcdn.net/v/t5...9318717440_n.jpg', + 'https://instagram.frix7-1.fna.fbcdn.net/v/t5...9217617140_n.jpg', scheme='https', host='instagram.frix7-1.fna.fbcdn.net', ... @@ -305,7 +317,7 @@ dict_keys([5563084402, 43848984510, 1498977320, ...]) 'media_count': 102, 'follower_count': 576, 'following_count': 538, - 'biography': 'Engineer: Python, JavaScript, Erlang\n@dhbastards \n@bestskatetrick \n@best_drift_daily \n@best_rally_mag \n@asphalt_kings_lb \n@surferyone \n@bmxtravel', + 'biography': 'Engineer: Python, JavaScript, Erlang', 'external_url': HttpUrl('https://adw0rd.com/', scheme='https', host='adw0rd.com', tld='com', host_type='domain', path='/'), 'is_business': False} @@ -347,6 +359,7 @@ Upload medias to your stories. Common arguments: * `path` - Path to media file * `caption` - Caption for story (now use to fetch mentions) +* `blur` - Fill Story Background with Image Blur (Default: True) * `thumbnail` - Thumbnail instead capture from source file * `usertags` - Specify usertags for mention users in story * `configure_timeout` - How long to wait in seconds for a response from Instagram when publishing a story @@ -354,12 +367,12 @@ Upload medias to your stories. Common arguments: | Method | Return | Description | | --------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | -| photo_upload_to_story(path: Path, caption: str, upload_id: str, mentions: List[Usertag]) | Media | Upload photo (Support JPG files) | +| photo_upload_to_story(path: Path, caption: str, blur: bool, upload_id: str, mentions: List[Usertag]) | Media | Upload photo (Support JPG files) | | video_upload_to_story(path: Path, caption: str, thumbnail: Path, mentions: List[Usertag], links: List[StoryLink]) | Media | Upload video (Support MP4 files) | Examples: -``` +``` python media_path = cl.video_download( cl.media_pk_from_url('https://www.instagram.com/p/CGgDsi7JQdS/') ) @@ -383,7 +396,7 @@ cl.video_upload_to_story( Example: -``` +``` python from instagrapi.story import StoryBuilder media_path = cl.video_download( @@ -406,6 +419,12 @@ cl.video_upload_to_story( ) ``` +Result: + +![](https://github.com/adw0rd/instagrapi/blob/master/examples/dhb.gif) + +More stories here https://www.instagram.com/surferyone/ + #### Collections @@ -442,12 +461,14 @@ Get statistics by medias. Common arguments: #### Location -| Method | Return | Description | -| ----------------------------------------- | -------------- | ----------------------------------------------------------------------- | -| location_search(lat: float, lng: float) | List[Location] | Search Location by GEO coordinates -| location_complete(location: Location) | Location | Complete blank fields -| location_build(location: Location) | String | Serialized JSON -| location_info(location_pk: int) | Location | Return Location info (pk, name, address, lng, lat, external_id, external_id_source) +| Method | Return | Description | +| ---------------------------------------------------------- | -------------- | ----------------------------------------------------------------------- | +| location_search(lat: float, lng: float) | List[Location] | Search Location by GEO coordinates +| location_complete(location: Location) | Location | Complete blank fields +| location_build(location: Location) | String | Serialized JSON +| location_info(location_pk: int) | Location | Return Location info (pk, name, address, lng, lat, external_id, external_id_source) +| location_medias_top(location_pk: int, amount: int = 9) | List[Media] | Return Top posts by Location +| location_medias_recent(location_pk: int, amount: int = 24) | List[Media] | Return Most recent posts by Location #### Hashtag @@ -569,4 +590,4 @@ Automatic submission code from SMS/Email in examples [here](/examples/challenge_ | ------------------------ | ------------ |----------------------------------------------- | | AlbumNotDownload | PrivateError | Raise when album not found | | AlbumUnknownFormat | PrivateError | Raise when format of media not MP4 or JPG | -| AlbumConfigureError | PrivateError | Raise when album not configured | +| AlbumConfigureError | PrivateError | Raise when album not configured | \ No newline at end of file From 85048564d451bacd78b6938f7f838b22db10f03e Mon Sep 17 00:00:00 2001 From: jhd3197 Date: Sun, 31 Jan 2021 12:09:21 -0500 Subject: [PATCH 4/6] Fixing Location post Issue When location is set in post its doing a location search for the lat and picking the first one sometimes works sometimes it doesn't so i added a patch for that and also the TopSearchesPublicMixin.top_search was broken because it was doing a public request when it should be a private request --- instagrapi/__init__.py | 38 +-- instagrapi/config.py | 2 - instagrapi/exceptions.py | 40 ++- instagrapi/extractors.py | 275 +++++++++++------- instagrapi/mixins/account.py | 88 ++++-- instagrapi/mixins/album.py | 207 ++++++++----- instagrapi/mixins/auth.py | 335 ++++++++++++++++++--- instagrapi/mixins/challenge.py | 147 +++++++--- instagrapi/mixins/collection.py | 132 ++++++++- instagrapi/mixins/comment.py | 79 ++++- instagrapi/mixins/direct.py | 120 ++++++-- instagrapi/mixins/hashtag.py | 315 +++++++++++++++----- instagrapi/mixins/igtv.py | 178 +++++++++--- instagrapi/mixins/insights.py | 96 ++++-- instagrapi/mixins/location.py | 289 ++++++++++++++---- instagrapi/mixins/media.py | 458 +++++++++++++++++++++++++---- instagrapi/mixins/photo.py | 453 ++++++++++++++++++----------- instagrapi/mixins/private.py | 85 ++++-- instagrapi/mixins/public.py | 52 ++-- instagrapi/mixins/story.py | 213 ++++++++++++++ instagrapi/mixins/user.py | 438 ++++++++++++++++++---------- instagrapi/mixins/video.py | 500 +++++++++++++++++++++++--------- instagrapi/story.py | 134 +++++++-- instagrapi/types.py | 79 ++++- instagrapi/utils.py | 11 +- instagrapi/zones.py | 2 +- 26 files changed, 3589 insertions(+), 1177 deletions(-) create mode 100644 instagrapi/mixins/story.py diff --git a/instagrapi/__init__.py b/instagrapi/__init__.py index 0f573e3b..300120f6 100644 --- a/instagrapi/__init__.py +++ b/instagrapi/__init__.py @@ -1,27 +1,25 @@ import logging from urllib.parse import urlparse +from instagrapi.mixins.account import AccountMixin +from instagrapi.mixins.album import DownloadAlbumMixin, UploadAlbumMixin from instagrapi.mixins.auth import LoginMixin -from instagrapi.mixins.public import ( - PublicRequestMixin, - TopSearchesPublicMixin, - ProfilePublicMixin -) -from instagrapi.mixins.private import PrivateRequestMixin from instagrapi.mixins.challenge import ChallengeResolveMixin -from instagrapi.mixins.photo import DownloadPhotoMixin, UploadPhotoMixin -from instagrapi.mixins.video import DownloadVideoMixin, UploadVideoMixin -from instagrapi.mixins.album import DownloadAlbumMixin, UploadAlbumMixin -from instagrapi.mixins.igtv import DownloadIGTVMixin, UploadIGTVMixin -from instagrapi.mixins.media import MediaMixin -from instagrapi.mixins.user import UserMixin -from instagrapi.mixins.insights import InsightsMixin from instagrapi.mixins.collection import CollectionMixin -from instagrapi.mixins.account import AccountMixin +from instagrapi.mixins.comment import CommentMixin from instagrapi.mixins.direct import DirectMixin -from instagrapi.mixins.location import LocationMixin from instagrapi.mixins.hashtag import HashtagMixin -from instagrapi.mixins.comment import CommentMixin +from instagrapi.mixins.igtv import DownloadIGTVMixin, UploadIGTVMixin +from instagrapi.mixins.insights import InsightsMixin +from instagrapi.mixins.location import LocationMixin +from instagrapi.mixins.media import MediaMixin +from instagrapi.mixins.photo import DownloadPhotoMixin, UploadPhotoMixin +from instagrapi.mixins.private import PrivateRequestMixin +from instagrapi.mixins.public import (ProfilePublicMixin, PublicRequestMixin, + TopSearchesPublicMixin) +from instagrapi.mixins.story import StoryMixin +from instagrapi.mixins.user import UserMixin +from instagrapi.mixins.video import DownloadVideoMixin, UploadVideoMixin class Client( @@ -47,7 +45,8 @@ class Client( DirectMixin, LocationMixin, HashtagMixin, - CommentMixin + CommentMixin, + StoryMixin, ): proxy = None logger = logging.getLogger("instagrapi") @@ -60,8 +59,9 @@ def __init__(self, settings: dict = {}, proxy: str = None, **kwargs): def set_proxy(self, dsn: str): if dsn: - assert isinstance(dsn, str),\ - f'Proxy must been string (URL), but now "{dsn}" ({type(dsn)})' + assert isinstance( + dsn, str + ), f'Proxy must been string (URL), but now "{dsn}" ({type(dsn)})' self.proxy = dsn proxy_href = "{scheme}{href}".format( scheme="http://" if not urlparse(self.proxy).scheme else "", diff --git a/instagrapi/config.py b/instagrapi/config.py index 02fe686b..e87d6c55 100644 --- a/instagrapi/config.py +++ b/instagrapi/config.py @@ -1,8 +1,6 @@ SIG_KEY_VERSION = "4" IG_SIG_KEY = "a86109795736d73c9a94172cd9b736917d7d94ca61c9101164894b3f0d43bef4" - API_DOMAIN = "i.instagram.com" -API_URL = "https://{domain}/api/v1/".format(domain=API_DOMAIN) # Instagram 134.0.0.26.121 # Android (26/8.0.0; diff --git a/instagrapi/exceptions.py b/instagrapi/exceptions.py index 6e777092..503066b4 100644 --- a/instagrapi/exceptions.py +++ b/instagrapi/exceptions.py @@ -55,8 +55,7 @@ class ClientIncompleteReadError(ClientError): class ClientLoginRequired(ClientError): - """Instagram redirect to https://www.instagram.com/accounts/login/ - """ + """Instagram redirect to https://www.instagram.com/accounts/login/""" class ReloginAttemptExceeded(ClientError): @@ -64,8 +63,7 @@ class ReloginAttemptExceeded(ClientError): class PrivateError(ClientError): - """For Private API and last_json logic - """ + """For Private API and last_json logic""" class FeedbackRequired(PrivateError): @@ -149,7 +147,11 @@ class CollectionError(PrivateError): class CollectionNotFound(CollectionError): - pass + def __init__(self, *args, **kwargs): + super().__init__( + f"Collection \"{kwargs.get('name')}\" not found", + *args, **kwargs + ) class DirectError(PrivateError): @@ -214,3 +216,31 @@ class AlbumUnknownFormat(PrivateError): class AlbumConfigureError(PrivateError): pass + + +class StoryNotFound(MediaNotFound): + pass + + +class HashtagError(PrivateError): + pass + + +class HashtagNotFound(HashtagError): + def __init__(self, *args, **kwargs): + super().__init__( + f"Hashtag \"{kwargs.get('name')}\" not found", + *args, **kwargs + ) + + +class LocationError(PrivateError): + pass + + +class LocationNotFound(LocationError): + def __init__(self, *args, **kwargs): + super().__init__( + f"Location \"{kwargs.get('location_pk')}\" not found", + *args, **kwargs + ) diff --git a/instagrapi/extractors.py b/instagrapi/extractors.py index 19586e8b..05c21a1c 100644 --- a/instagrapi/extractors.py +++ b/instagrapi/extractors.py @@ -1,115 +1,115 @@ from copy import deepcopy -from .utils import json_value -from .types import ( - Media, Resource, User, UserShort, Usertag, - Location, Collection, Comment, MediaOembed, - DirectThread, DirectMessage, Account, - Hashtag -) +from .types import (Account, Collection, Comment, DirectMessage, DirectThread, + Hashtag, Location, Media, MediaOembed, Resource, Story, + StoryLink, StoryMention, User, UserShort, Usertag) +from .utils import InstagramIdCodec, json_value - -MEDIA_TYPES_GQL = { - "GraphImage": 1, - "GraphVideo": 2, - "GraphSidecar": 8, - "StoryVideo": 2 -} +MEDIA_TYPES_GQL = {"GraphImage": 1, "GraphVideo": 2, "GraphSidecar": 8, "StoryVideo": 2} def extract_media_v1(data): - """Extract media from Private API - """ + """Extract media from Private API""" media = deepcopy(data) if "video_versions" in media: # Select Best Quality by Resolutiuon - media['video_url'] = sorted( + media["video_url"] = sorted( media["video_versions"], key=lambda o: o["height"] * o["width"] )[-1]["url"] if media["media_type"] == 2 and not media.get("product_type"): media["product_type"] = "feed" - if 'image_versions2' in media: - media['thumbnail_url'] = sorted( + if "image_versions2" in media: + media["thumbnail_url"] = sorted( media["image_versions2"]["candidates"], key=lambda o: o["height"] * o["width"], )[-1]["url"] if media["media_type"] == 8: # remove thumbnail_url and video_url for albums # see resources - media.pop('thumbnail_url', '') - media.pop('video_url', '') - location = media.pop("location", None) + media.pop("thumbnail_url", "") + media.pop("video_url", "") + location = media.get("location") + media["location"] = location and extract_location(location) + media["user"] = extract_user_short(media.get("user")) + media["usertags"] = sorted( + [ + extract_usertag(usertag) + for usertag in media.get("usertags", {}).get("in", []) + ], + key=lambda tag: tag.user.pk, + ) + media["like_count"] = media.get("like_count", 0) return Media( - location=extract_location(location) if location else None, - user=extract_user_short(media.pop("user")), caption_text=(media.get("caption") or {}).get("text", ""), - usertags=sorted([ - extract_usertag(usertag) - for usertag in media.pop("usertags", {}).get("in", []) - ], key=lambda tag: tag.user.pk), resources=[ - extract_resource_v1(edge) - for edge in media.get('carousel_media', []) + extract_resource_v1(edge) for edge in media.get("carousel_media", []) ], - like_count=media.pop('like_count', 0), - **media + **media, ) def extract_media_gql(data): - """Extract media from GraphQL - """ + """Extract media from GraphQL""" media = deepcopy(data) user = extract_user_short(media["owner"]) # if "full_name" in user: # user = extract_user_short(user) # else: # user["pk"] = user.pop("id") - media['media_type'] = MEDIA_TYPES_GQL[media["__typename"]] - if media['media_type'] == 2 and not media.get('product_type'): - media['product_type'] = "feed" + try: + media["media_type"] = MEDIA_TYPES_GQL[media["__typename"]] + except KeyError: + pass + if media["media_type"] == 2 and not media.get("product_type"): + media["product_type"] = "feed" media["thumbnail_url"] = sorted( # display_resources - user feed, thumbnail_resources - hashtag feed - media.get("display_resources", media.get('thumbnail_resources')), + media.get("display_resources", media.get("thumbnail_resources")), key=lambda o: o["config_width"] * o["config_height"], )[-1]["src"] - if media['media_type'] == 8: + if media["media_type"] == 8: # remove thumbnail_url and video_url for albums # see resources - media.pop('thumbnail_url', '') - media.pop('video_url', '') + media.pop("thumbnail_url", "") + media.pop("video_url", "") location = media.pop("location", None) + media_id = media.get("id") + media["pk"] = media_id + media["id"] = f"{media_id}_{user.pk}" return Media( - pk=media['id'], - id=f"{media.pop('id')}_{user.pk}", code=media.get("shortcode"), taken_at=media["taken_at_timestamp"], location=extract_location(location) if location else None, user=user, - view_count=media.get('video_view_count', 0), + view_count=media.get("video_view_count", 0), comment_count=json_value(media, "edge_media_to_comment", "count"), like_count=json_value(media, "edge_media_preview_like", "count"), caption_text=json_value( media, "edge_media_to_caption", "edges", 0, "node", "text", default="" ), - usertags=sorted([ - extract_usertag(usertag['node']) - for usertag in media.get("edge_media_to_tagged_user", {}).get("edges", []) - ], key=lambda tag: tag.user.pk), + usertags=sorted( + [ + extract_usertag(usertag["node"]) + for usertag in media.get("edge_media_to_tagged_user", {}).get( + "edges", [] + ) + ], + key=lambda tag: tag.user.pk, + ), resources=[ - extract_resource_gql(edge['node']) - for edge in media.get('edge_sidecar_to_children', {}).get('edges', []) + extract_resource_gql(edge["node"]) + for edge in media.get("edge_sidecar_to_children", {}).get("edges", []) ], - **media + **media, ) def extract_resource_v1(data): - if 'video_versions' in data: - data['video_url'] = sorted( + if "video_versions" in data: + data["video_url"] = sorted( data["video_versions"], key=lambda o: o["height"] * o["width"] )[-1]["url"] - data['thumbnail_url'] = sorted( + data["thumbnail_url"] = sorted( data["image_versions2"]["candidates"], key=lambda o: o["height"] * o["width"], )[-1]["url"] @@ -117,63 +117,69 @@ def extract_resource_v1(data): def extract_resource_gql(data): - data['media_type'] = MEDIA_TYPES_GQL[data["__typename"]] - return Resource( - pk=data["id"], - thumbnail_url=data["display_url"], - **data - ) + data["media_type"] = MEDIA_TYPES_GQL[data["__typename"]] + return Resource(pk=data["id"], thumbnail_url=data["display_url"], **data) def extract_usertag(data): - """Extract user tag - """ - x, y = data.get('position', [ - data.get('x'), - data.get('y') - ]) - return Usertag( - user=extract_user_short(data['user']), - x=x, y=y - ) + """Extract user tag""" + x, y = data.get("position", [data.get("x"), data.get("y")]) + return Usertag(user=extract_user_short(data["user"]), x=x, y=y) def extract_user_short(data): - """Extract User Short info - """ - data['pk'] = data.get("id", data.get("pk", None)) - assert data['pk'], f'User without pk "{data}"' + """Extract User Short info""" + data["pk"] = data.get("id", data.get("pk", None)) + assert data["pk"], f'User without pk "{data}"' return UserShort(**data) def extract_user_gql(data): - """For Public GraphQL API - """ + """For Public GraphQL API""" return User( pk=data["id"], media_count=data["edge_owner_to_timeline_media"]["count"], follower_count=data["edge_followed_by"]["count"], following_count=data["edge_follow"]["count"], is_business=data["is_business_account"], - **data + **data, ) def extract_user_v1(data): - """For Private API - """ - data['external_url'] = data.get('external_url') or None + """For Private API""" + data["external_url"] = data.get("external_url") or None return User(**data) def extract_location(data): - """Extract location info - """ + """Extract location info""" if not data: return None - data['pk'] = data.get("id", data.get("pk", None)) - data['external_id'] = data.get('external_id', data.get('facebook_places_id')) - data['external_id_source'] = data.get('external_id_source', data.get('external_source')) + data["pk"] = data.get("id", data.get("pk")) + data["external_id"] = data.get("external_id", data.get("facebook_places_id")) + data["external_id_source"] = data.get( + "external_id_source", data.get("external_source") + ) + + # address_json = data.get("address_json", "{}") + # if isinstance(address_json, str): + # address_json = json.loads(address_json) + # data['address_json'] = address_json + return Location(**data) + + + +def extract_locationV2(data): + """Extract location info""" + if not data: + return None + data=data["place"]["location"] + data["pk"] = data.get("id", data.get("pk")) + data["external_id"] = data.get("external_id", data.get("facebook_places_id")) + data["external_id_source"] = data.get( + "external_id_source", data.get("external_source") + ) # address_json = data.get("address_json", "{}") # if isinstance(address_json, str): # address_json = json.loads(address_json) @@ -182,10 +188,9 @@ def extract_location(data): def extract_comment(data): - """Extract comment - """ - data['has_liked'] = data.get('has_liked_comment') - data['like_count'] = data.get('comment_like_count') + """Extract comment""" + data["has_liked"] = data.get("has_liked_comment") + data["like_count"] = data.get("comment_like_count") return Comment(**data) @@ -198,46 +203,104 @@ def extract_collection(data): 'collection_media_count': 1, 'cover_media': {...} """ - data = { - key.replace('collection_', ''): val - for key, val in data.items() - } + data = {key.replace("collection_", ""): val for key, val in data.items()} # data['pk'] = data.get('id') return Collection(**data) def extract_media_oembed(data): - """Return short version of Media - """ + """Return short version of Media""" return MediaOembed(**data) def extract_direct_thread(data): - data['messages'] = [extract_direct_message(item) for item in data['items']] - data['users'] = [extract_user_short(u) for u in data['users']] - data['inviter'] = extract_user_short(data['inviter']) - data['pk'] = data.get('thread_v2_id') - data['id'] = data.get('thread_id') + data["messages"] = [extract_direct_message(item) for item in data["items"]] + data["users"] = [extract_user_short(u) for u in data["users"]] + data["inviter"] = extract_user_short(data["inviter"]) + data["pk"] = data.get("thread_v2_id") + data["id"] = data.get("thread_id") return DirectThread(**data) def extract_direct_message(data): - data['id'] = data.get('item_id') - if 'media_share' in data: - data['media_share'] = extract_media_v1(data['media_share']) + data["id"] = data.get("item_id") + if "media_share" in data: + data["media_share"] = extract_media_v1(data["media_share"]) return DirectMessage(**data) def extract_account(data): - data['external_url'] = data.get('external_url') or None + data["external_url"] = data.get("external_url") or None return Account(**data) def extract_hashtag_gql(data): - data['media_count'] = data.get('edge_hashtag_to_media', {}).get('count') + data["media_count"] = data.get("edge_hashtag_to_media", {}).get("count") return Hashtag(**data) def extract_hashtag_v1(data): - data['allow_following'] = data.get('allow_following') == 1 + data["allow_following"] = data.get("allow_following") == 1 return Hashtag(**data) + + +def extract_story_v1(data): + """Extract story from Private API""" + story = deepcopy(data) + if "video_versions" in story: + # Select Best Quality by Resolutiuon + story["video_url"] = sorted( + story["video_versions"], key=lambda o: o["height"] * o["width"] + )[-1]["url"] + if story["media_type"] == 2 and not story.get("product_type"): + story["product_type"] = "feed" + if "image_versions2" in story: + story["thumbnail_url"] = sorted( + story["image_versions2"]["candidates"], + key=lambda o: o["height"] * o["width"], + )[-1]["url"] + story["mentions"] = [ + StoryMention(**mention) for mention in story.get("reel_mentions", []) + ] + story["locations"] = [] + story["hashtags"] = [] + story["stickers"] = [] + story["links"] = [] + for cta in story.get("story_cta", []): + for link in cta.get("links", []): + story["links"].append(StoryLink(**link)) + story["user"] = extract_user_short(story.get("user")) + return Story(**story) + + +def extract_story_gql(data): + """Extract story from Public API""" + story = deepcopy(data) + if "video_resources" in story: + # Select Best Quality by Resolutiuon + story["video_url"] = sorted( + story["video_resources"], key=lambda o: o["config_height"] * o["config_width"] + )[-1]["src"] + # if story["tappable_objects"] and "GraphTappableFeedMedia" in [x["__typename"] for x in story["tappable_objects"]]: + story["product_type"] = "feed" + story["thumbnail_url"] = story.get("display_url") + story["mentions"] = [] + for mention in story.get("tappable_objects", []): + if mention["__typename"] == "GraphTappableMention": + mention["id"] = 1 + mention["user"] = extract_user_short(mention) + story["mentions"].append(StoryMention(**mention)) + story["locations"] = [] + story["hashtags"] = [] + story["stickers"] = [] + story["links"] = [] + story_cta_url = story.get("story_cta_url", []) + if story_cta_url: + story["links"] = [StoryLink(**{'webUri': story_cta_url})] + story["user"] = extract_user_short(story.get("owner")) + story["pk"] = int(story["id"]) + story["id"] = f"{story['id']}_{story['owner']['id']}" + story["code"] = InstagramIdCodec.encode(story["pk"]) + story["taken_at"] = story["taken_at_timestamp"] + story["media_type"] = 2 if story["is_video"] else 1 + return Story(**story) diff --git a/instagrapi/mixins/account.py b/instagrapi/mixins/account.py index ce2a7a98..582453df 100644 --- a/instagrapi/mixins/account.py +++ b/instagrapi/mixins/account.py @@ -1,22 +1,32 @@ -import requests -from pathlib import Path from json.decoder import JSONDecodeError +from pathlib import Path +from typing import Dict + +import requests -from instagrapi.exceptions import ClientLoginRequired, ClientError +from instagrapi.exceptions import ClientError, ClientLoginRequired from instagrapi.extractors import extract_account, extract_user_short from instagrapi.types import Account, UserShort from instagrapi.utils import gen_csrftoken class AccountMixin: + """ + Helper class to manage your account + """ - def reset_password(self, username): + def reset_password(self, username: str) -> Dict: + """ + Reset your password + + Returns + ------- + Dict + Jsonified response from Instagram + """ response = requests.post( "https://www.instagram.com/accounts/account_recovery_send_ajax/", - data={ - "email_or_username": username, - "recaptcha_challenge_field": "" - }, + data={"email_or_username": username, "recaptcha_challenge_field": ""}, headers={ "x-requested-with": "XMLHttpRequest", "x-csrftoken": gen_csrftoken(), @@ -24,9 +34,9 @@ def reset_password(self, username): "Accept": "*/*", "Accept-Encoding": "gzip,deflate", "Accept-Language": "en-US", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15" + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15", }, - proxies=self.public.proxies + proxies=self.public.proxies, ) try: return response.json() @@ -36,33 +46,69 @@ def reset_password(self, username): raise ClientError(e, response=response) def account_info(self) -> Account: - result = self.private_request('accounts/current_user/?edit=true') - return extract_account(result['user']) + """ + Fetch your account info - def account_edit(self, **data) -> Account: - """Edit your profile (authorized account) + Returns + ------- + Account + An object of Account class """ - fields = ("external_url", "phone_number", "username", "full_name", "biography", "email") + result = self.private_request("accounts/current_user/?edit=true") + return extract_account(result["user"]) + + def account_edit(self, **data: Dict) -> Account: + """ + Edit your profile (authorized account) + + Parameters + ---------- + data: Dict + Fields you want to edit in your account as key and value pairs + + Returns + ------- + Account + An object of Account class + """ + fields = ( + "external_url", + "phone_number", + "username", + "full_name", + "biography", + "email", + ) data = {key: val for key, val in data.items() if key in fields} - if 'email' not in data and 'phone_number' not in data: + if "email" not in data and "phone_number" not in data: # Instagram Error: You need an email or confirmed phone number. user_data = self.account_info().dict() user_data = {field: user_data[field] for field in fields} data = dict(user_data, **data) # Instagram original field-name for full user name is "first_name" - data['first_name'] = data.pop('full_name') + data["first_name"] = data.pop("full_name") result = self.private_request( - "accounts/edit_profile/", - self.with_default_data(data) + "accounts/edit_profile/", self.with_default_data(data) ) return extract_account(result["user"]) def account_change_picture(self, path: Path) -> UserShort: - """Change photo for your profile (authorized account) + """ + Change photo for your profile (authorized account) + + Parameters + ---------- + path: Path + Path to the image you want to update as your profile picture + + Returns + ------- + UserShort + An object of UserShort class """ upload_id, _, _ = self.photo_rupload(Path(path)) result = self.private_request( "accounts/change_profile_picture/", - self.with_default_data({'use_fbuploader': True, 'upload_id': upload_id}) + self.with_default_data({"use_fbuploader": True, "upload_id": upload_id}), ) return extract_user_short(result["user"]) diff --git a/instagrapi/mixins/album.py b/instagrapi/mixins/album.py index fd369ae0..c127cf48 100644 --- a/instagrapi/mixins/album.py +++ b/instagrapi/mixins/album.py @@ -1,20 +1,36 @@ import time from pathlib import Path -from typing import List +from typing import Dict, List from urllib.parse import urlparse +from instagrapi.exceptions import (AlbumConfigureError, AlbumNotDownload, + AlbumUnknownFormat) from instagrapi.extractors import extract_media_v1 -from instagrapi.exceptions import ( - AlbumNotDownload, AlbumUnknownFormat, - AlbumConfigureError -) -from instagrapi.types import Usertag, Location, Media +from instagrapi.types import Location, Media, Usertag from instagrapi.utils import dumps class DownloadAlbumMixin: + """ + Helper class to download album + """ def album_download(self, media_pk: int, folder: Path = "") -> List[Path]: + """ + Download your album + + Parameters + ---------- + media_pk: int + PK for the album you want to download + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working directory. + + Returns + ------- + List[Path] + List of path for all the files downloaded + """ media = self.media_info(media_pk) assert media.media_type == 8, "Must been album" paths = [] @@ -22,15 +38,11 @@ def album_download(self, media_pk: int, folder: Path = "") -> List[Path]: filename = f"{media.user.username}_{resource.pk}" if resource.media_type == 1: paths.append( - self.photo_download_by_url( - resource.thumbnail_url, filename, folder - ) + self.photo_download_by_url(resource.thumbnail_url, filename, folder) ) elif resource.media_type == 2: paths.append( - self.video_download_by_url( - resource.video_url, filename, folder - ) + self.video_download_by_url(resource.video_url, filename, folder) ) else: raise AlbumNotDownload( @@ -39,20 +51,34 @@ def album_download(self, media_pk: int, folder: Path = "") -> List[Path]: return paths def album_download_by_urls(self, urls: List[str], folder: Path = "") -> List[Path]: + """ + Download your album using specified URLs + + Parameters + ---------- + urls: List[str] + List of URLs to download media from + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working directory. + + Returns + ------- + List[Path] + List of path for all the files downloaded + """ paths = [] for url in urls: - fname = urlparse(url).path.rsplit('/', 1)[1] - if fname.endswith('.jpg'): - paths.append(self.photo_download_by_url(url, fname, folder)) - elif fname.endswith('.mp4'): - paths.append(self.video_download_by_url(url, fname, folder)) + file_name = urlparse(url).path.rsplit("/", 1)[1] + if file_name.endswith(".jpg"): + paths.append(self.photo_download_by_url(url, file_name, folder)) + elif file_name.endswith(".mp4"): + paths.append(self.video_download_by_url(url, file_name, folder)) else: raise AlbumUnknownFormat() return paths class UploadAlbumMixin: - def album_upload( self, paths: List[Path], @@ -62,45 +88,78 @@ def album_upload( configure_timeout: int = 3, configure_handler=None, configure_exception=None, - to_story=False + to_story=False, ) -> Media: - """Upload album to feed + """ + Upload album to feed - :param paths: Path to files (List) - :param caption: Media description (String) - :param usertags: Mentioned users (List of Usertag) - :param location: Location - :param configure_timeout: Timeout between attempt to configure media (set caption, etc) - :param configure_handler: Configure handler method - :param configure_exception: Configure exception class + Parameters + ---------- + paths: List[Path] + List of paths for media to upload + caption: str + Media caption + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is none + configure_timeout: int + Timeout between attempt to configure media (set caption, etc), default is 3 + configure_handler + Configure handler method, default is None + configure_exception + Configure exception class, default is None + to_story: bool + Currently not used, default is False - :return: Media + Returns + ------- + Media + An object of Media class """ - childs = [] + children = [] for path in paths: path = Path(path) - if path.suffix == '.jpg': + if path.suffix == ".jpg": upload_id, width, height = self.photo_rupload(path, to_album=True) - childs.append({ - "upload_id": upload_id, - "edits": dumps({"crop_original_size": [width, height], "crop_center": [0.0, -0.0], "crop_zoom": 1.0}), - "extra": dumps({"source_width": width, "source_height": height}), - "scene_capture_type": "", - "scene_type": None - }) - elif path.suffix == '.mp4': - upload_id, width, height, duration, thumbnail = self.video_rupload(path, to_album=True) - childs.append({ - "upload_id": upload_id, - "clips": dumps([{"length": duration, "source_type": "4"}]), - "extra": dumps({"source_width": width, "source_height": height}), - "length": duration, - "poster_frame_index": "0", - "filter_type": "0", - "video_result": "", - "date_time_original": time.strftime("%Y%m%dT%H%M%S.000Z", time.localtime()), - "audio_muted": "false" - }) + children.append( + { + "upload_id": upload_id, + "edits": dumps( + { + "crop_original_size": [width, height], + "crop_center": [0.0, -0.0], + "crop_zoom": 1.0, + } + ), + "extra": dumps( + {"source_width": width, "source_height": height} + ), + "scene_capture_type": "", + "scene_type": None, + } + ) + elif path.suffix == ".mp4": + upload_id, width, height, duration, thumbnail = self.video_rupload( + path, to_album=True + ) + children.append( + { + "upload_id": upload_id, + "clips": dumps([{"length": duration, "source_type": "4"}]), + "extra": dumps( + {"source_width": width, "source_height": height} + ), + "length": duration, + "poster_frame_index": "0", + "filter_type": "0", + "video_result": "", + "date_time_original": time.strftime( + "%Y%m%dT%H%M%S.000Z", time.localtime() + ), + "audio_muted": "false", + } + ) self.photo_rupload(thumbnail, upload_id) else: raise AlbumUnknownFormat() @@ -110,7 +169,8 @@ def album_upload( time.sleep(configure_timeout) try: configured = (configure_handler or self.album_configure)( - childs, caption, usertags, location) + children, caption, usertags, location + ) except Exception as e: if "Transcode not finished yet" in str(e): """ @@ -126,29 +186,39 @@ def album_upload( self.expose() return extract_media_v1(media) raise (configure_exception or AlbumConfigureError)( - response=self.last_response, **self.last_json) + response=self.last_response, **self.last_json + ) def album_configure( self, - childs: list, + childs: List, caption: str, usertags: List[Usertag] = [], - location: Location = None - ) -> dict: - """Post Configure Album + location: Location = None, + ) -> Dict: + """ + Post Configure Album - :param childs: Childs of album (List) - :param caption: Media description (String) - :param usertags: Mentioned users (List of Usertag) - :param location: Location + Parameters + ---------- + childs: List + List of media/resources of an album + caption: str + Media caption + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None - :return: Media (Dict) + Returns + ------- + Dict + A dictionary of response from the call """ upload_id = str(int(time.time() * 1000)) if usertags: usertags = [ - {"user_id": tag.user.pk, "position": [tag.x, tag.y]} - for tag in usertags + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags ] childs[0]["usertags"] = dumps({"in": usertags}) data = { @@ -168,8 +238,11 @@ def album_configure( "source_type": "4", "timezone_offset": "10800", "device": dumps(self.device), - **child - } for child in childs - ] + **child, + } + for child in childs + ], } - return self.private_request("media/configure_sidecar/", self.with_default_data(data)) + return self.private_request( + "media/configure_sidecar/", self.with_default_data(data) + ) diff --git a/instagrapi/mixins/auth.py b/instagrapi/mixins/auth.py index e42a5599..83d04d01 100644 --- a/instagrapi/mixins/auth.py +++ b/instagrapi/mixins/auth.py @@ -1,12 +1,14 @@ -import re -import json import base64 -import time -import uuid -import hmac +import datetime import hashlib +import hmac +import json import random -import datetime +import re +import time +import uuid +from typing import Dict, List + import requests from instagrapi import config @@ -15,9 +17,18 @@ class PreLoginFlowMixin: + """ + Helpers for pre login flow + """ def pre_login_flow(self) -> bool: - """Emulation mobile app behaivor before login + """ + Emulation mobile app behavior before login + + Returns + ------- + bool + A boolean value """ # /api/v1/accounts/get_prefill_candidates self.get_prefill_candidates(True) @@ -29,7 +40,20 @@ def pre_login_flow(self) -> bool: self.set_contact_point_prefill("prefill") return True - def get_prefill_candidates(self, login: bool = False) -> dict: + def get_prefill_candidates(self, login: bool = False) -> Dict: + """ + Get prefill candidates value from Instagram + + Parameters + ---------- + login: bool, optional + Whether to login or not + + Returns + ------- + bool + A boolean value + """ # "android_device_id":"android-f14b9731e4869eb", # "phone_id":"b4bd7978-ca2b-4ea0-a728-deb4180bd6ca", # "usages":"[\"account_recovery_omnibox\"]", @@ -47,7 +71,20 @@ def get_prefill_candidates(self, login: bool = False) -> dict: "accounts/get_prefill_candidates/", data, login=login ) - def sync_device_features(self, login: bool = False) -> dict: + def sync_device_features(self, login: bool = False) -> Dict: + """ + Sync device features to your Instagram account + + Parameters + ---------- + login: bool, optional + Whether to login or not + + Returns + ------- + Dict + A dictionary of response from the call + """ data = { "id": self.uuid, "server_config_retrieval": "1", @@ -61,7 +98,20 @@ def sync_device_features(self, login: bool = False) -> dict: "qe/sync/", data, login=login, headers={"X-DEVICE-ID": self.uuid} ) - def sync_launcher(self, login: bool = False) -> dict: + def sync_launcher(self, login: bool = False) -> Dict: + """ + Sync Launcher + + Parameters + ---------- + login: bool, optional + Whether to login or not + + Returns + ------- + Dict + A dictionary of response from the call + """ data = { "id": self.uuid, "server_config_retrieval": "1", @@ -72,20 +122,41 @@ def sync_launcher(self, login: bool = False) -> dict: data["_csrftoken"] = self.token return self.private_request("launcher/sync/", data, login=login) - def set_contact_point_prefill(self, usage: str = "prefill") -> dict: + def set_contact_point_prefill(self, usage: str = "prefill") -> Dict: + """ + Sync Launcher + + Parameters + ---------- + usage: str, optional + Default "prefill" + + Returns + ------- + Dict + A dictionary of response from the call + """ data = {"phone_id": self.phone_id, "usage": usage} return self.private_request("accounts/contact_point_prefill/", data, login=True) class PostLoginFlowMixin: + """ + Helpers for post login flow + """ def login_flow(self) -> bool: - """Emulation mobile app behaivor after login + """ + Emulation mobile app behaivor after login + + Returns + ------- + bool + A boolean value """ check_flow = [] chance = random.randint(1, 100) % 2 == 0 - check_flow.append(self.get_timeline_feed( - [chance and "is_pull_to_refresh"])) + check_flow.append(self.get_timeline_feed([chance and "is_pull_to_refresh"])) check_flow.append( self.get_reels_tray_feed( reason="pull_to_refresh" if chance else "cold_start" @@ -93,7 +164,20 @@ def login_flow(self) -> bool: ) return all(check_flow) - def get_timeline_feed(self, options: list = []) -> dict: + def get_timeline_feed(self, options: List[Dict] = []) -> Dict: + """ + Get your timeline feed + + Parameters + ---------- + options: List, optional + Configurable options + + Returns + ------- + Dict + A dictionary of response from the call + """ headers = { "X-Ads-Opt-Out": "0", "X-DEVICE-ID": self.uuid, @@ -104,9 +188,7 @@ def get_timeline_feed(self, options: list = []) -> dict: "feed_view_info": "", "phone_id": self.phone_id, "battery_level": random.randint(25, 100), - "timezone_offset": datetime.datetime.now(CET()).strftime( - "%z" - ), + "timezone_offset": datetime.datetime.now(CET()).strftime("%z"), "_csrftoken": self.token, "device_id": self.uuid, "request_id": self.device_id, @@ -134,9 +216,19 @@ def get_timeline_feed(self, options: list = []) -> dict: "feed/timeline/", json.dumps(data), with_signature=False, headers=headers ) - def get_reels_tray_feed(self, reason: str = "pull_to_refresh") -> dict: + def get_reels_tray_feed(self, reason: str = "pull_to_refresh") -> Dict: """ - :param reason: can be = cold_start, pull_to_refresh + Get your reels tray feed + + Parameters + ---------- + reason: str, optional + Default "pull_to_refresh" + + Returns + ------- + Dict + A dictionary of response from the call """ data = { "supported_capabilities_new": config.SUPPORTED_CAPABILITIES, @@ -159,7 +251,19 @@ class LoginMixin(PreLoginFlowMixin, PostLoginFlowMixin): phone_id = "" uuid = "" + def __init__(self): + self.user_agent = None + self.settings = None + def init(self) -> bool: + """ + Initialize Login helpers + + Returns + ------- + bool + A boolean value + """ if "cookies" in self.settings: self.private.cookies = requests.utils.cookiejar_from_dict( self.settings["cookies"] @@ -171,16 +275,47 @@ def init(self) -> bool: return True def login_by_sessionid(self, sessionid: str) -> bool: - assert isinstance(sessionid, str) and len( - sessionid) > 30, 'Invalid sessionid' - self.settings = {'cookies': {'sessionid': sessionid}} + """ + Login using session id + + Parameters + ---------- + sessionid: str + Session ID + + Returns + ------- + bool + A boolean value + """ + assert isinstance(sessionid, str) and len(sessionid) > 30, "Invalid sessionid" + self.settings = {"cookies": {"sessionid": sessionid}} self.init() - user_id = re.search(r'^\d+', sessionid).group() + user_id = re.search(r"^\d+", sessionid).group() user = self.user_info_v1(int(user_id)) self.username = user.username return True def login(self, username: str, password: str, relogin: bool = False) -> bool: + """ + Login + + Parameters + ---------- + username: str + Instagram Username + + password: str + Instagram Password + + relogin: bool + Whether or not to re login, default False + + Returns + ------- + bool + A boolean value + """ self.username = username self.password = password self.init() @@ -211,7 +346,13 @@ def login(self, username: str, password: str, relogin: bool = False) -> bool: return False def relogin(self) -> bool: - """Relogin shortcut + """ + Relogin helper + + Returns + ------- + bool + A boolean value """ return self.login(self.username, self.password, relogin=True) @@ -250,7 +391,15 @@ def device(self) -> dict: if key in ["manufacturer", "model", "android_version", "android_release"] } - def get_settings(self) -> dict: + def get_settings(self) -> Dict: + """ + Get current session settings + + Returns + ------- + Dict + Current session settings as a Dict + """ return { "uuids": { "phone_id": self.phone_id, @@ -265,7 +414,20 @@ def get_settings(self) -> dict: "user_agent": self.user_agent, } - def set_device(self, device: dict = {}) -> bool: + def set_device(self, device: Dict = None) -> bool: + """ + Helper to set a device for login + + Parameters + ---------- + device: Dict, optional + Dict of device settings, default is None + + Returns + ------- + bool + A boolean value + """ self.device_settings = device or { "app_version": "105.0.0.18.119", "android_version": 28, @@ -282,6 +444,19 @@ def set_device(self, device: dict = {}) -> bool: return True def set_user_agent(self, user_agent: str = "") -> bool: + """ + Helper to set user agent + + Parameters + ---------- + user_agent: str, optional + User agent, default is "" + + Returns + ------- + bool + A boolean value + """ self.user_agent = user_agent or config.USER_AGENT_BASE.format( **self.device_settings ) @@ -289,31 +464,72 @@ def set_user_agent(self, user_agent: str = "") -> bool: self.set_uuids({}) return True - def set_uuids(self, uuids: dict = {}) -> bool: + def set_uuids(self, uuids: Dict = None) -> bool: + """ + Helper to set uuids + + Parameters + ---------- + uuids: Dict, optional + UUIDs, default is None + + Returns + ------- + bool + A boolean value + """ self.phone_id = uuids.get("phone_id", self.generate_uuid()) self.uuid = uuids.get("uuid", self.generate_uuid()) - self.client_session_id = uuids.get( - "client_session_id", self.generate_uuid()) + self.client_session_id = uuids.get("client_session_id", self.generate_uuid()) self.advertising_id = uuids.get("advertising_id", self.generate_uuid()) self.device_id = uuids.get("device_id", self.generate_device_id()) return True def generate_uuid(self) -> str: + """ + Helper to generate uuids + + Returns + ------- + str + A stringified UUID + """ return str(uuid.uuid4()) def generate_device_id(self) -> str: - return "android-%s" % hashlib.md5( - bytes(random.randint(1, 1000)) - ).hexdigest()[:16] + """ + Helper to generate Device ID - def expose(self) -> dict: - data = { - "id": self.uuid, - "experiment": "ig_android_profile_contextual_feed" - } + Returns + ------- + str + A random android device id + """ + return ( + "android-%s" % hashlib.md5(bytes(random.randint(1, 1000))).hexdigest()[:16] + ) + + def expose(self) -> Dict: + """ + Helper to expose + + Returns + ------- + Dict + A dictionary of response from the call + """ + data = {"id": self.uuid, "experiment": "ig_android_profile_contextual_feed"} return self.private_request("qe/expose/", self.with_default_data(data)) - def with_default_data(self, data: dict) -> dict: + def with_default_data(self, data: Dict) -> Dict: + """ + Helper to get default data + + Returns + ------- + Dict + A dictionary of default data + """ return dict( { "_uuid": self.uuid, @@ -321,13 +537,34 @@ def with_default_data(self, data: dict) -> dict: "_csrftoken": self.token, "device_id": self.device_id, }, - **data + **data, ) - def with_action_data(self, data: dict) -> dict: + def with_action_data(self, data: Dict) -> Dict: + """ + Helper to get action data + + Returns + ------- + Dict + A dictionary of action data + """ return dict(self.with_default_data({"radio_type": "wifi-none"}), **data) def gen_user_breadcrumb(self, size: int) -> str: + """ + Helper to generate user breadcrumbs + + Parameters + ---------- + size: int + Integer value + + Returns + ------- + Str + A string + """ key = "iN4$aGr0m" dt = int(time.time() * 1000) time_elapsed = random.randint(500, 1500) + size * random.randint(500, 1500) @@ -349,11 +586,17 @@ def gen_user_breadcrumb(self, size: int) -> str: base64.b64encode(data.encode("ascii")), ) - def inject_sessionid_to_public(self): - """Inject sessionid from private session to public session + def inject_sessionid_to_public(self) -> bool: + """ + Inject sessionid from private session to public session + + Returns + ------- + bool + A boolean value """ - sessionid = self.private.cookies.get('sessionid') - if sessionid: - self.public.cookies.set('sessionid', sessionid) + session_id = self.private.cookies.get_dict().get("sessionid") + if session_id: + self.public.cookies.set("sessionid", session_id) return True return False diff --git a/instagrapi/mixins/challenge.py b/instagrapi/mixins/challenge.py index a5fa706b..dcbef0f8 100644 --- a/instagrapi/mixins/challenge.py +++ b/instagrapi/mixins/challenge.py @@ -1,13 +1,15 @@ +import hashlib import json import time -import hashlib -import requests from datetime import datetime +from typing import Dict -from instagrapi.exceptions import ( - ChallengeRequired, SelectContactPointRecoveryForm, RecaptchaChallengeForm, - ChallengeError, ChallengeRedirection, SubmitPhoneNumberForm -) +import requests + +from instagrapi.exceptions import (ChallengeError, ChallengeRedirection, + ChallengeRequired, RecaptchaChallengeForm, + SelectContactPointRecoveryForm, + SubmitPhoneNumberForm) CHOICE_SMS = 0 CHOICE_EMAIL = 1 @@ -15,9 +17,18 @@ class ChallengeResolveMixin: + """ + Helpers for resolving login challenge + """ + + def challenge_resolve(self, last_json: Dict) -> bool: + """ + Start challenge resolve - def challenge_resolve(self, last_json): - """Start challenge resolve + Returns + ------- + bool + A boolean value """ # START GET REQUEST to challenge_url challenge_url = last_json["challenge"]["api_path"] @@ -35,25 +46,46 @@ def challenge_resolve(self, last_json): params = {} try: self._send_private_request( - challenge_url, None, params=params, with_signature=False, + challenge_url, + None, + params=params, + with_signature=False, ) except ChallengeRequired: assert self.last_json["message"] == "challenge_required", self.last_json return self.challenge_resolve_contact_form(challenge_url) return self.challenge_resolve_simple(challenge_url) - def challenge_resolve_contact_form(self, challenge_url): + def challenge_resolve_contact_form(self, challenge_url: str) -> bool: """ + Start challenge resolve + Помогите нам удостовериться, что вы владеете этим аккаунтом > CODE Верна ли информация вашего профиля? Мы заметили подозрительные действия в вашем аккаунте. В целях безопасности сообщите, верна ли информация вашего профиля. > I AGREE + + Help us make sure you own this account + > CODE + Is your profile information correct? + We have noticed suspicious activity on your account. + For security reasons, please let us know if your profile information is correct. + > I AGREE + + Parameters + ---------- + challenge_url: str + Challenge URL + + Returns + ------- + bool + A boolean value """ result = self.last_json challenge_url = "https://i.instagram.com%s" % challenge_url - print("challenge_resolve_contact_form for %s" % challenge_url) enc_password = "#PWD_INSTAGRAM_BROWSER:0:%s:" % datetime.now().strftime("%s") instagram_ajax = hashlib.md5(enc_password.encode()).hexdigest()[:12] session = requests.Session() @@ -134,8 +166,8 @@ def challenge_resolve_contact_form(self, challenge_url): result = session.post( challenge_url, { - "phone_number": e.challenge['fields']['phone_number'], - "challenge_context": e.challenge['challenge_context'] + "phone_number": e.challenge["fields"]["phone_number"], + "challenge_context": e.challenge["challenge_context"], }, ) result = result.json() @@ -143,7 +175,10 @@ def challenge_resolve_contact_form(self, challenge_url): except ChallengeRedirection: return True # instagram redirect assert result["challengeType"] in ( - 'VerifyEmailCodeForm', 'VerifySMSCodeForm', 'VerifySMSCodeFormForSMSCaptcha'), result + "VerifyEmailCodeForm", + "VerifySMSCodeForm", + "VerifySMSCodeFormForSMSCaptcha", + ), result wait_seconds = 5 for retry_code in range(5): for attempt in range(1, 11): @@ -151,23 +186,26 @@ def challenge_resolve_contact_form(self, challenge_url): if code: break time.sleep(wait_seconds * attempt) - print('Enter code "%s" for %s (%d attempts, by %d seconds)' % - (code, self.username, attempt, wait_seconds)) # SEND CODE time.sleep(WAIT_SECONDS) - result = session.post(challenge_url, { - "security_code": code, - "enc_new_password1": enc_password, - "new_password1": "", - "enc_new_password2": enc_password, - "new_password2": "", - }).json() - result = result.get('challenge', result) - if 'Please check the code we sent you and try again' not in (result.get("errors") or [''])[0]: + result = session.post( + challenge_url, + { + "security_code": code, + "enc_new_password1": enc_password, + "new_password1": "", + "enc_new_password2": enc_password, + "new_password2": "", + }, + ).json() + result = result.get("challenge", result) + if ( + "Please check the code we sent you and try again" + not in (result.get("errors") or [""])[0] + ): break # FORM TO APPROVE CONTACT DATA - assert result.get( - "challengeType") == "ReviewContactPointChangeForm", result + assert result.get("challengeType") == "ReviewContactPointChangeForm", result details = [] for data in result["extraData"]["content"]: for entry in data.get("labeled_list_entries", []): @@ -182,8 +220,7 @@ def challenge_resolve_contact_form(self, challenge_url): ), 'ChallengeResolve: Data invalid: "%s" not in %s' % (detail, details) time.sleep(WAIT_SECONDS) result = session.post( - "https://i.instagram.com%s" % result.get( - "navigation").get("forward"), + "https://i.instagram.com%s" % result.get("navigation").get("forward"), { "choice": 0, # I AGREE "enc_new_password1": enc_password, @@ -196,13 +233,29 @@ def challenge_resolve_contact_form(self, challenge_url): assert result.get("status") == "ok", result return True - def handle_challenge_result(self, challenge): + def handle_challenge_result(self, challenge: Dict): + """ + Handle challenge result + + Parameters + ---------- + challenge: Dict + Dict + + Returns + ------- + bool + A boolean value + """ messages = [] if "challenge" in challenge: """ Иногда в JSON есть вложенность, вместо {challege_object} приходит {"challenge": {challenge_object}} + Sometimes there is nesting in JSON, + instead of {challege_object} + comes {"challenge": {challenge_object}} """ challenge = challenge["challenge"] challenge_type = challenge.get("challengeType") @@ -245,7 +298,8 @@ def handle_challenge_result(self, challenge): for error in challenge["errors"]: messages.append(error) raise SelectContactPointRecoveryForm( - " ".join(messages), challenge=challenge) + " ".join(messages), challenge=challenge + ) elif challenge_type == "RecaptchaChallengeForm": """ Example: @@ -265,12 +319,11 @@ def handle_challenge_result(self, challenge): 'type': 'CHALLENGE'}, 'status': 'fail'} """ - raise RecaptchaChallengeForm( - ". ".join(challenge.get("errors", []))) - elif challenge_type in ('VerifyEmailCodeForm', 'VerifySMSCodeForm'): + raise RecaptchaChallengeForm(". ".join(challenge.get("errors", []))) + elif challenge_type in ("VerifyEmailCodeForm", "VerifySMSCodeForm"): # Success. Next step return challenge - elif challenge_type == 'SubmitPhoneNumberForm': + elif challenge_type == "SubmitPhoneNumberForm": raise SubmitPhoneNumberForm(challenge=challenge) elif challenge_type: # Unknown challenge_type @@ -289,12 +342,22 @@ def handle_challenge_result(self, challenge): raise ChallengeRedirection() return challenge - def challenge_resolve_simple(self, challenge_url): - """Old type (through private api) challenge resolver + def challenge_resolve_simple(self, challenge_url: str) -> bool: + """ + Old type (through private api) challenge resolver Помогите нам удостовериться, что вы владеете этим аккаунтом + + Parameters + ---------- + challenge_url : str + Challenge URL + + Returns + ------- + bool + A boolean value """ step_name = self.last_json.get("step_name", "") - print("challenge_resolve_simple for %s" % challenge_url) if step_name == "delta_login_review": # IT WAS ME (by GEO) self._send_private_request(challenge_url, {"choice": "0"}) @@ -316,13 +379,9 @@ def challenge_resolve_simple(self, challenge_url): """ steps = self.last_json["step_data"].keys() if "email" in steps: - self._send_private_request( - challenge_url, {"choice": CHOICE_EMAIL} - ) + self._send_private_request(challenge_url, {"choice": CHOICE_EMAIL}) elif "phone_number" in steps: - self._send_private_request( - challenge_url, {"choice": CHOICE_SMS} - ) + self._send_private_request(challenge_url, {"choice": CHOICE_SMS}) else: raise ChallengeError( 'ChallengeResolve: Choice "email" or "phone_number" (sms) not available to this account %s' diff --git a/instagrapi/mixins/collection.py b/instagrapi/mixins/collection.py index f179651c..785d5fb7 100644 --- a/instagrapi/mixins/collection.py +++ b/instagrapi/mixins/collection.py @@ -1,21 +1,33 @@ +from typing import List + from instagrapi.exceptions import CollectionNotFound -from instagrapi.extractors import extract_media_v1, extract_collection +from instagrapi.extractors import extract_collection, extract_media_v1 +from instagrapi.types import Collection, Media class CollectionMixin: + """ + Helpers for collection + """ + + def collections(self) -> List[Collection]: + """ + Get collections - def collections(self) -> list: - """Return list of collections + Returns + ------- + List[Collection] + A list of objects of Collection """ next_max_id = "" total_items = [] while True: try: result = self.private_request( - "/collections/list/", + "collections/list/", params={ "collection_types": '["ALL_MEDIA_AUTO_COLLECTION","PRODUCT_AUTO_COLLECTION","MEDIA"]', - "max_id": next_max_id + "max_id": next_max_id, }, ) except Exception as e: @@ -28,16 +40,60 @@ def collections(self) -> list: next_max_id = result.get("next_max_id", "") return total_items - def collection_medias_by_name(self, name: str) -> list: - """Helper return medias by collection name + def collection_pk_by_name(self, name: str) -> int: + """ + Get collection_pk by name + + Parameters + ---------- + name: str + Name of the collection + + Returns + ------- + List[Collection] + A list of objects of Collection """ for item in self.collections(): - if item.name.lower() == name.lower(): - return self.collection_medias(item.id) - raise CollectionNotFound() + if item.name == name: + return item.id + raise CollectionNotFound(name=name) + + def collection_medias_by_name(self, name: str) -> List[Collection]: + """ + Get medias by collection name + + Parameters + ---------- + name: str + Name of the collection + + Returns + ------- + List[Collection] + A list of collections + """ + return self.collection_medias(self.collection_pk_by_name(name)) + + def collection_medias( + self, collection_pk: int, amount: int = 21, last_media_pk: int = 0 + ) -> List[Media]: + """ + Get media in a collection by collection_pk + + Parameters + ---------- + collection_pk: int + Unique identifier of a Collection + amount: int, optional + Maximum number of media to return, default is 21 + last_media_pk: int, optional + Last PK user has seen, function will return medias after this pk. Default is 0 - def collection_medias(self, collection_pk: int, amount: int = 21, last_media_pk: int = 0) -> list: - """Return medias in collection + Returns + ------- + List[Media] + A list of objects of Media """ collection_pk = int(collection_pk) last_media_pk = last_media_pk and int(last_media_pk) @@ -55,10 +111,60 @@ def collection_medias(self, collection_pk: int, amount: int = 21, last_media_pk: self.logger.exception(e) return total_items for item in result["items"]: - if last_media_pk and last_media_pk == item['media']['pk']: + if last_media_pk and last_media_pk == item["media"]["pk"]: return total_items total_items.append(extract_media_v1(item["media"])) if not result.get("more_available"): return total_items next_max_id = result.get("next_max_id", "") return total_items + + def media_save(self, media_id: str, collection_pk: int = None, revert: bool = False) -> bool: + """ + Save a media to collection + + Parameters + ---------- + media_id: str + Unique identifier of a Media + collection_pk: int + Unique identifier of a Collection + revert: bool, optional + If True then save to collection, otherwise unsave + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + media_id = self.media_id(media_id) + data = { + "module_name": "feed_timeline", + "radio_type": "wifi-none", + } + if collection_pk: + data["added_collection_ids"] = f"[{int(collection_pk)}]" + name = "unsave" if revert else "save" + result = self.private_request( + f"media/{media_id}/{name}/", self.with_action_data(data) + ) + return result["status"] == "ok" + + def media_unsave(self, media_id: str, collection_pk: int = None) -> bool: + """ + Unsave a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + collection_pk: int + Unique identifier of a Collection + + Returns + ------- + bool + A boolean value + """ + return self.media_save(media_id, collection_pk, revert=True) diff --git a/instagrapi/mixins/comment.py b/instagrapi/mixins/comment.py index 99d88520..ddc3c072 100644 --- a/instagrapi/mixins/comment.py +++ b/instagrapi/mixins/comment.py @@ -1,19 +1,30 @@ import random from typing import List -from instagrapi.exceptions import ( - ClientError, - ClientNotFoundError, - MediaNotFound, -) +from instagrapi.exceptions import (ClientError, ClientNotFoundError, + MediaNotFound) from instagrapi.extractors import extract_comment from instagrapi.types import Comment class CommentMixin: + """ + Helpers for managing comments on a Media + """ def media_comments(self, media_id: str) -> List[Comment]: - """Get list of comments for media + """ + Get comments on a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + + Returns + ------- + List[Comment] + A list of objects of Comment """ # TODO: to public or private media_id = self.media_id(media_id) @@ -38,7 +49,20 @@ def media_comments(self, media_id: str) -> List[Comment]: return comments def media_comment(self, media_id: str, text: str) -> Comment: - """Comment media + """ + Post a comment on a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + text: str + String to be posted on the media + + Returns + ------- + Comment + An object of Comment type """ assert self.user_id, "Login required" media_id = self.media_id(media_id) @@ -51,30 +75,53 @@ def media_comment(self, media_id: str, text: str) -> Comment: "container_module": "self_comments_v2_feed_contextual_self_profile", # "comments_v2", "user_breadcrumb": self.gen_user_breadcrumb(len(text)), "idempotence_token": self.generate_uuid(), - "comment_text": text + "comment_text": text, } - ) + ), ) return extract_comment(result["comment"]) def comment_like(self, comment_pk: int, revert: bool = False) -> bool: - """Like comment + """ + Like a comment on a media + + Parameters + ---------- + comment_pk: str + Unique identifier of a Comment + revert: bool, optional + If liked, whether or not to unlike. Default is False + + Returns + ------- + bool + A boolean value """ assert self.user_id, "Login required" comment_pk = int(comment_pk) data = { "is_carousel_bumped_post": "false", "container_module": "feed_contextual_self_profile", - "feed_position": str(random.randint(0, 6)) + "feed_position": str(random.randint(0, 6)), } - name = 'unlike' if revert else 'like' + name = "unlike" if revert else "like" result = self.private_request( - f"media/{comment_pk}/comment_{name}/", - self.with_action_data(data) + f"media/{comment_pk}/comment_{name}/", self.with_action_data(data) ) - return result['status'] == 'ok' + return result["status"] == "ok" def comment_unlike(self, comment_pk: str) -> bool: - """Unlike comment + """ + Unlike a comment on a media + + Parameters + ---------- + comment_pk: str + Unique identifier of a Comment + + Returns + ------- + bool + A boolean value """ return self.comment_like(comment_pk, revert=True) diff --git a/instagrapi/mixins/direct.py b/instagrapi/mixins/direct.py index 74170ab3..904786eb 100644 --- a/instagrapi/mixins/direct.py +++ b/instagrapi/mixins/direct.py @@ -1,16 +1,30 @@ import re from typing import List -from instagrapi.utils import dumps -from instagrapi.types import DirectThread, DirectMessage from instagrapi.exceptions import ClientNotFoundError, DirectThreadNotFound -from instagrapi.extractors import extract_direct_thread, extract_direct_message +from instagrapi.extractors import extract_direct_message, extract_direct_thread +from instagrapi.types import DirectMessage, DirectThread +from instagrapi.utils import dumps class DirectMixin: + """ + Helpers for managing Direct Messaging + """ def direct_threads(self, amount: int = 20) -> List[DirectThread]: - """Return last threads + """ + Get direct message threads + + Parameters + ---------- + amount: int, optional + Maximum number of media to return, default is 20 + + Returns + ------- + List[DirectThread] + A list of objects of DirectThread """ assert self.user_id, "Login required" params = { @@ -24,7 +38,7 @@ def direct_threads(self, amount: int = 20) -> List[DirectThread]: self.private_request("direct_v2/get_presence/") while True: if cursor: - params['cursor'] = cursor + params["cursor"] = cursor result = self.private_request("direct_v2/inbox/", params=params) inbox = result.get("inbox", {}) for thread in inbox.get("threads", []): @@ -37,7 +51,21 @@ def direct_threads(self, amount: int = 20) -> List[DirectThread]: return threads def direct_thread(self, thread_id: int, amount: int = 20) -> DirectThread: - """Return full information by thread + """ + Get all the information about a Direct Message thread + + Parameters + ---------- + thread_id: int + Unique identifier of a Direct Message thread + + amount: int, optional + Maximum number of media to return, default is 20 + + Returns + ------- + DirectThread + An object of DirectThread """ assert self.user_id, "Login required" params = { @@ -50,59 +78,103 @@ def direct_thread(self, thread_id: int, amount: int = 20) -> DirectThread: items = [] while True: if cursor: - params['cursor'] = cursor + params["cursor"] = cursor try: - result = self.private_request(f"direct_v2/threads/{thread_id}/", params=params) + result = self.private_request( + f"direct_v2/threads/{thread_id}/", params=params + ) except ClientNotFoundError as e: raise DirectThreadNotFound(e, thread_id=thread_id, **self.last_json) - thread = result['thread'] - for item in thread['items']: + thread = result["thread"] + for item in thread["items"]: items.append(item) cursor = thread.get("oldest_cursor") if not cursor or (amount and len(items) >= amount): break if amount: items = items[:amount] - thread['items'] = items + thread["items"] = items return extract_direct_thread(thread) def direct_messages(self, thread_id: int, amount: int = 20) -> List[DirectMessage]: - """Fetch list of messages by thread (helper) + """ + Get all the messages from a thread + + Parameters + ---------- + thread_id: int + Unique identifier of a Direct Message thread + + amount: int, optional + Maximum number of media to return, default is 20 + + Returns + ------- + List[DirectMessage] + A list of objects of DirectMessage """ assert self.user_id, "Login required" return self.direct_thread(thread_id, amount).messages def direct_answer(self, thread_id: int, text: str) -> DirectMessage: - """Send message + """ + Post a message on a Direct Message thread + + Parameters + ---------- + thread_id: int + Unique identifier of a Direct Message thread + + text: str + String to be posted on the thread + + Returns + ------- + DirectMessage + An object of DirectMessage """ assert self.user_id, "Login required" return self.direct_send(text, [], [int(thread_id)]) - def direct_send(self, text: str, user_ids: List[int] = [], thread_ids: List[int] = []) -> DirectMessage: - """Send message + def direct_send( + self, text: str, user_ids: List[int] = [], thread_ids: List[int] = [] + ) -> DirectMessage: + """ + Send a direct message to list of users or threads + + Parameters + ---------- + text: str + String to be posted on the thread + + user_ids: List[int] + List of unique identifier of Users thread + + thread_ids: List[int] + List of unique identifier of Direct Message thread + + Returns + ------- + DirectMessage + An object of DirectMessage """ assert self.user_id, "Login required" method = "text" kwargs = {} - if 'http' in text: + if "http" in text: method = "link" kwargs["link_text"] = text - kwargs["link_urls"] = dumps( - re.findall(r"(https?://[^\s]+)", text)) + kwargs["link_urls"] = dumps(re.findall(r"(https?://[^\s]+)", text)) else: kwargs["text"] = text if thread_ids: kwargs["thread_ids"] = dumps([int(tid) for tid in thread_ids]) if user_ids: kwargs["recipient_users"] = dumps([[int(uid) for uid in user_ids]]) - data = { - "client_context": self.generate_uuid(), - "action": "send_item", - **kwargs - } + data = {"client_context": self.generate_uuid(), "action": "send_item", **kwargs} result = self.private_request( "direct_v2/threads/broadcast/%s/" % method, data=self.with_default_data(data), - with_signature=False + with_signature=False, ) return extract_direct_message(result["payload"]) diff --git a/instagrapi/mixins/hashtag.py b/instagrapi/mixins/hashtag.py index 207c0f2d..632b2610 100644 --- a/instagrapi/mixins/hashtag.py +++ b/instagrapi/mixins/hashtag.py @@ -1,50 +1,103 @@ from typing import List -from instagrapi.extractors import ( - extract_hashtag_gql, - extract_hashtag_v1, - extract_media_gql, - extract_media_v1 -) -from instagrapi.exceptions import ClientError, ClientLoginRequired +from instagrapi.exceptions import (ClientError, ClientLoginRequired, + HashtagNotFound) +from instagrapi.extractors import (extract_hashtag_gql, extract_hashtag_v1, + extract_media_gql, extract_media_v1) from instagrapi.types import Hashtag, Media from instagrapi.utils import dumps class HashtagMixin: + """ + Helpers for managing Hashtag + """ def hashtag_info_a1(self, name: str, max_id: str = None) -> Hashtag: - """Get info (id, name, profile_pic_url) + """ + Get information about a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + max_id: str + Max ID, default value is None + + Returns + ------- + Hashtag + An object of Hashtag """ params = {"max_id": max_id} if max_id else None - data = self.public_a1_request( - f"/explore/tags/{name}/", params=params - ) + data = self.public_a1_request(f"/explore/tags/{name}/", params=params) + if not data.get("hashtag"): + raise HashtagNotFound(name=name, **data) return extract_hashtag_gql(data["hashtag"]) - def hashtag_info_gql(self, name: str, amount: int = 12, end_cursor: str = None) -> Hashtag: - """Get info (id, name, profile_pic_url) + def hashtag_info_gql( + self, name: str, amount: int = 12, end_cursor: str = None + ) -> Hashtag: """ - variables = { - "tag_name": name, - "show_ranked": False, - "first": int(amount) - } + Get information about a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + amount: int, optional + Maximum number of media to return, default is 12 + + end_cursor: str, optional + End Cursor, default value is None + + Returns + ------- + Hashtag + An object of Hashtag + """ + variables = {"tag_name": name, "show_ranked": False, "first": int(amount)} if end_cursor: variables["after"] = end_cursor data = self.public_graphql_request( variables, query_hash="f92f56d47dc7a55b606908374b43a314" ) + if not data.get("hashtag"): + raise HashtagNotFound(name=name, **data) return extract_hashtag_gql(data["hashtag"]) def hashtag_info_v1(self, name: str) -> Hashtag: - """Get info (id, name, profile_pic_url) """ - result = self.private_request(f'tags/{name}/info/') + Get information about a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + Returns + ------- + Hashtag + An object of Hashtag + """ + result = self.private_request(f"tags/{name}/info/") return extract_hashtag_v1(result) def hashtag_info(self, name: str) -> Hashtag: - """Get info (id, name, profile_pic_url) + """ + Get information about a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + Returns + ------- + Hashtag + An object of Hashtag """ try: hashtag = self.hashtag_info_a1(name) @@ -55,25 +108,55 @@ def hashtag_info(self, name: str) -> Hashtag: return hashtag def hashtag_related_hashtags(self, name: str) -> List[Hashtag]: - """Get related hashtags + """ + Get related hashtags from a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + Returns + ------- + List[Hashtag] + List of objects of Hashtag """ data = self.public_a1_request(f"/explore/tags/{name}/") + if not data.get("hashtag"): + raise HashtagNotFound(name=name, **data) return [ extract_hashtag_gql(item["node"]) - for item in data['hashtag']['edge_hashtag_to_related_tags']["edges"] + for item in data["hashtag"]["edge_hashtag_to_related_tags"]["edges"] ] - def hashtag_medias_a1(self, name: str, amount: int = 27, tab_key: str = '') -> List[Media]: - """Receive medias by hashtag name + def hashtag_medias_a1( + self, name: str, amount: int = 27, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 27 + tab_key: str, optional + Tab Key, default value is "" + + Returns + ------- + List[Media] + List of objects of Media """ - uniqs = set() + unique_set = set() medias = [] end_cursor = None while True: data = self.public_a1_request( - f'/explore/tags/{name}/', - params={"max_id": end_cursor} if end_cursor else {} - )['hashtag'] + f"/explore/tags/{name}/", + params={"max_id": end_cursor} if end_cursor else {}, + )["hashtag"] page_info = data["edge_hashtag_to_media"]["page_info"] end_cursor = page_info["end_cursor"] edges = data[tab_key]["edges"] @@ -81,51 +164,77 @@ def hashtag_medias_a1(self, name: str, amount: int = 27, tab_key: str = '') -> L if amount and len(medias) >= amount: break # check uniq - media_pk = edge['node']['id'] - if media_pk in uniqs: + media_pk = edge["node"]["id"] + if media_pk in unique_set: continue - uniqs.add(media_pk) + unique_set.add(media_pk) # check contains hashtag in caption - media = extract_media_gql(edge['node']) - if f'#{name}' not in media.caption_text: + media = extract_media_gql(edge["node"]) + if f"#{name}" not in media.caption_text: continue - # fetch full media with User, Usertags, video_url + # Enrich media: Full user, usertags and video_url medias.append(self.media_info_gql(media_pk)) - if not page_info["has_next_page"] or not end_cursor: - break - if amount and len(medias) >= amount: - break + ###################################################### + # infinity loop in hashtag_medias_top_a1 + # https://github.com/adw0rd/instagrapi/issues/52 + ###################################################### + # Mikhail Andreev, [30.12.20 02:17]: + # Instagram always returns the same 9 medias for top + # I think we should return them without a loop + ###################################################### + # if not page_info["has_next_page"] or not end_cursor: + # break + # if amount and len(medias) >= amount: + # break + break if amount: medias = medias[:amount] return medias - def hashtag_medias_v1(self, name: str, amount: int = 27, tab_key: str = '') -> List[Media]: - """Receive medias by hashtag name + def hashtag_medias_v1( + self, name: str, amount: int = 27, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 27 + tab_key: str, optional + Tab Key, default value is "" + + Returns + ------- + List[Media] + List of objects of Media """ data = { - 'supported_tabs': dumps([tab_key]), + "supported_tabs": dumps([tab_key]), # 'lat': 59.8626416, # 'lng': 30.5126682, - 'include_persistent': 'true', - 'rank_token': self.rank_token, + "include_persistent": "true", + "rank_token": self.rank_token, } max_id = None medias = [] while True: result = self.private_request( - f'tags/{name}/sections/', + f"tags/{name}/sections/", params={"max_id": max_id} if max_id else {}, - data=self.with_default_data(data) + data=self.with_default_data(data), ) - for section in result['sections']: - layout_content = section.get('layout_content') or {} - nodes = layout_content.get('medias') or [] + for section in result["sections"]: + layout_content = section.get("layout_content") or {} + nodes = layout_content.get("medias") or [] for node in nodes: if amount and len(medias) >= amount: break - media = extract_media_v1(node['media']) + media = extract_media_v1(node["media"]) # check contains hashtag in caption - if f'#{name}' not in media.caption_text: + if f"#{name}" not in media.caption_text: continue medias.append(media) if not result["more_available"]: @@ -138,20 +247,56 @@ def hashtag_medias_v1(self, name: str, amount: int = 27, tab_key: str = '') -> L return medias def hashtag_medias_top_a1(self, name: str, amount: int = 9) -> List[Media]: - """Top medias by public API """ - return self.hashtag_medias_a1( - name, amount, - tab_key='edge_hashtag_to_top_posts' - ) + Get top medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 9 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.hashtag_medias_a1(name, amount, tab_key="edge_hashtag_to_top_posts") def hashtag_medias_top_v1(self, name: str, amount: int = 9) -> List[Media]: - """Top medias by private API """ - return self.hashtag_medias_v1(name, amount, tab_key='top') + Get top medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 9 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.hashtag_medias_v1(name, amount, tab_key="top") def hashtag_medias_top(self, name: str, amount: int = 9) -> List[Media]: - """Top medias + """ + Get top medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 9 + + Returns + ------- + List[Media] + List of objects of Media """ try: try: @@ -167,20 +312,56 @@ def hashtag_medias_top(self, name: str, amount: int = 9) -> List[Media]: return medias def hashtag_medias_recent_a1(self, name: str, amount: int = 71) -> List[Media]: - """Recent medias by public API """ - return self.hashtag_medias_a1( - name, amount, - tab_key='edge_hashtag_to_media' - ) + Get recent medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 71 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.hashtag_medias_a1(name, amount, tab_key="edge_hashtag_to_media") def hashtag_medias_recent_v1(self, name: str, amount: int = 27) -> List[Media]: - """Recent medias by private API """ - return self.hashtag_medias_v1(name, amount, tab_key='recent') + Get recent medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 71 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.hashtag_medias_v1(name, amount, tab_key="recent") def hashtag_medias_recent(self, name: str, amount: int = 27) -> List[Media]: - """Recent medias + """ + Get recent medias for a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + amount: int, optional + Maximum number of media to return, default is 71 + + Returns + ------- + List[Media] + List of objects of Media """ try: try: diff --git a/instagrapi/mixins/igtv.py b/instagrapi/mixins/igtv.py index 9a137c65..67781afa 100644 --- a/instagrapi/mixins/igtv.py +++ b/instagrapi/mixins/igtv.py @@ -1,15 +1,15 @@ import json -import time import random +import time from pathlib import Path -from typing import List +from typing import Dict, List from uuid import uuid4 from instagrapi import config +from instagrapi.exceptions import (ClientError, IGTVConfigureError, + IGTVNotUpload) from instagrapi.extractors import extract_media_v1 -from instagrapi.exceptions import ClientError, IGTVNotUpload, IGTVConfigureError -from instagrapi.types import Usertag, Location, Media - +from instagrapi.types import Location, Media, Usertag try: from PIL import Image @@ -18,15 +18,53 @@ class DownloadIGTVMixin: + """ + Helpers to download IGTV videos + """ def igtv_download(self, media_pk: int, folder: Path = "") -> str: + """ + Download IGTV video + + Parameters + ---------- + media_pk: int + PK for the album you want to download + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working + directory. + + Returns + ------- + str + """ return self.video_download(media_pk, folder) - def igtv_download_by_url(self, url: str, filename: str = "", folder: Path = "") -> str: + def igtv_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> str: + """ + Download IGTV video using URL + + Parameters + ---------- + url: str + URL to download media from + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working + directory. + + Returns + ------- + str + """ return self.video_download_by_url(url, filename, folder) class UploadIGTVMixin: + """ + Helpers to upload IGTV videos + """ def igtv_upload( self, @@ -38,18 +76,30 @@ def igtv_upload( location: Location = None, configure_timeout: int = 10, ) -> Media: - """Upload IGTV to Instagram - - :param path: Path to IGTV file - :param title: Media title (String) - :param caption: Media description (String) - :param thumbnail: Path to thumbnail for IGTV. When None, then - thumbnail is generate automatically - :param usertags: Mentioned users (List) - :param location: Location - :param configure_timeout: Timeout between attempt to configure media (set caption and title) - - :return: Media + """ + Upload IGTV to Instagram + + Parameters + ---------- + path: Path + Path to IGTV file + title: str + Title of the video + caption: str + Media caption + thumbnail: Path, optional + Path to thumbnail for IGTV. Default value is None, and it generates a thumbnail + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is none + configure_timeout: int + Timeout between attempt to configure media (set caption, etc), default is 10 + + Returns + ------- + Media + An object of Media class """ path = Path(path) if thumbnail is not None: @@ -84,7 +134,8 @@ def igtv_upload( response = self.private.get( "https://{domain}/rupload_igvideo/{name}".format( domain=config.API_DOMAIN, name=upload_name - ), headers=headers + ), + headers=headers, ) self.request_log(response) if response.status_code != 200: @@ -97,13 +148,14 @@ def igtv_upload( "X-Entity-Length": igtv_len, "Content-Type": "application/octet-stream", "Content-Length": igtv_len, - **headers + **headers, } response = self.private.post( "https://{domain}/rupload_igvideo/{name}".format( domain=config.API_DOMAIN, name=upload_name ), - data=igtv_data, headers=headers + data=igtv_data, + headers=headers, ) self.request_log(response) if response.status_code != 200: @@ -115,7 +167,15 @@ def igtv_upload( time.sleep(configure_timeout) try: configured = self.igtv_configure( - upload_id, thumbnail, width, height, duration, title, caption, usertags, location + upload_id, + thumbnail, + width, + height, + duration, + title, + caption, + usertags, + location, ) except ClientError as e: if "Transcode not finished yet" in str(e): @@ -143,23 +203,40 @@ def igtv_configure( title: str, caption: str, usertags: List[Usertag] = [], - location: Location = None - ) -> dict: - """Post Configure IGTV (send caption, thumbnail and more to Instagram) - - :param upload_id: Unique upload_id (String) - :param thumbnail: Path to thumbnail for IGTV - :param width: Width in px (Integer) - :param height: Height in px (Integer) - :param duration: Duration in seconds (Integer) - :param caption: Media description (String) - :param usertags: Mentioned users (List) - :param location: Location + location: Location = None, + ) -> Dict: + """ + Post Configure IGTV (send caption, thumbnail and more to Instagram) + + Parameters + ---------- + upload_id: str + Unique identifier for a IGTV video + thumbnail: Path + Path to thumbnail for IGTV + width: int + Width of the video in pixels + height: int + Height of the video in pixels + duration: int + Duration of the video in seconds + title: str + Title of the video + caption: str + Media caption + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None + + Returns + ------- + Dict + A dictionary of response from the call """ self.photo_rupload(Path(thumbnail), upload_id) usertags = [ - {"user_id": tag.user.pk, "position": [tag.x, tag.y]} - for tag in usertags + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags ] data = { "igtv_ads_toggled_on": "0", @@ -190,13 +267,25 @@ def igtv_configure( def analyze_video(path: Path, thumbnail: Path = None) -> tuple: - """Analyze and crop thumbnail if need """ + Analyze and crop thumbnail if need + Parameters + ---------- + path: Path + Path to the video + thumbnail: Path + Path to thumbnail for IGTV + + Returns + ------- + Tuple + A tuple with (thumbail path, width, height, duration) + """ try: import moviepy.editor as mp except ImportError: - raise Exception('Please install moviepy>=1.0.3 and retry') + raise Exception("Please install moviepy>=1.0.3 and retry") print(f'Analizing IGTV file "{path}"') video = mp.VideoFileClip(str(path)) @@ -210,7 +299,18 @@ def analyze_video(path: Path, thumbnail: Path = None) -> tuple: def crop_thumbnail(path: Path) -> bool: - """Crop IGTV thumbnail with save height + """ + Analyze and crop thumbnail if need + + Parameters + ---------- + path: Path + Path to the video + + Returns + ------- + bool + A boolean value """ im = Image.open(str(path)) width, height = im.size diff --git a/instagrapi/mixins/insights.py b/instagrapi/mixins/insights.py index e2cfb234..057be058 100644 --- a/instagrapi/mixins/insights.py +++ b/instagrapi/mixins/insights.py @@ -1,10 +1,14 @@ import time +from typing import Dict, List +from instagrapi.exceptions import ClientError, MediaError, UserError from instagrapi.utils import json_value -from instagrapi.exceptions import UserError, ClientError, MediaError class InsightsMixin: + """ + Helper class to get insights + """ def insights_media_feed_all( self, @@ -13,19 +17,33 @@ def insights_media_feed_all( data_ordering: str = "REACH_COUNT", count: int = 0, sleep: int = 2, - ) -> list: - """Get insights for all medias from feed with page iteration with cursor and sleep timeout - :param post_type: Media type ("ALL", "CAROUSEL_V2", "IMAGE", "SHOPPING", "VIDEO") - :param time_frame: Time frame for media publishing date ("ONE_WEEK", "ONE_MONTH", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR", "TWO_YEARS") - :param data_ordering: Data ordering in instagram response ("REACH_COUNT", "LIKE_COUNT", "FOLLOW", "SHARE_COUNT", "BIO_LINK_CLICK", "COMMENT_COUNT", "IMPRESSION_COUNT", "PROFILE_VIEW", "VIDEO_VIEW_COUNT", "SAVE_COUNT"...) - :param count: Max media count for retrieving - :param sleep: Timeout between pages iterations - :return: List with media insights - :rtype: list + ) -> List[Dict]: + """ + Get insights for all medias from feed with page iteration with cursor and sleep timeout + + Parameters + ---------- + post_type: str, optional + Types of posts, default is "ALL" + Options: ("ALL", "CAROUSEL_V2", "IMAGE", "SHOPPING", "VIDEO") + time_frame: str, optional + Time frame to pull media insights, default is "TWO_YEARS" + Options: ("ONE_WEEK", "ONE_MONTH", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR", "TWO_YEARS") + data_ordering: str, optional + Ordering strategy for the data, default is "REACH_COUNT" + Options: ("REACH_COUNT", "LIKE_COUNT", "FOLLOW", "SHARE_COUNT", "BIO_LINK_CLICK", "COMMENT_COUNT", "IMPRESSION_COUNT", "PROFILE_VIEW", "VIDEO_VIEW_COUNT", "SAVE_COUNT"...) + count: int, optional + Max media count for retrieving, default is 0 + sleep: int, optional + Timeout between pages iterations, default is 2 + + Returns + ------- + List[Dict] + List of dictionaries of response from the call """ assert self.user_id, "Login required" - supported_post_types = ("ALL", "CAROUSEL_V2", - "IMAGE", "SHOPPING", "VIDEO") + supported_post_types = ("ALL", "CAROUSEL_V2", "IMAGE", "SHOPPING", "VIDEO") supported_time_frames = ( "ONE_WEEK", "ONE_MONTH", @@ -63,7 +81,8 @@ def insights_media_feed_all( query_params["cursor"] = cursor result = self.private_request( - "ads/graphql/", self.with_query_params(data, query_params), + "ads/graphql/", + self.with_query_params(data, query_params), ) if not json_value( result, @@ -72,8 +91,7 @@ def insights_media_feed_all( "business_manager", default=None, ): - raise UserError( - "Account is not business account", **self.last_json) + raise UserError("Account is not business account", **self.last_json) stats = result["data"]["shadow_instagram_user"]["business_manager"][ "top_posts_unit" @@ -89,10 +107,18 @@ def insights_media_feed_all( medias = medias[:count] return medias - def insights_account(self) -> dict: - """Get insights for account - :return: Dict with insights - :rtype: dict + """ + Helpers for getting insights for media + """ + + def insights_account(self) -> Dict: + """ + Get insights for account + + Returns + ------- + Dict + A dictionary of response from the call """ assert self.user_id, "Login required" data = { @@ -112,20 +138,27 @@ def insights_account(self) -> dict: } result = self.private_request( - "ads/graphql/", self.with_query_params(data, query_params), + "ads/graphql/", + self.with_query_params(data, query_params), ) - res = json_value( - result, "data", "shadow_instagram_user", "business_manager") + res = json_value(result, "data", "shadow_instagram_user", "business_manager") if not res: - raise UserError("Account is not business account", - **self.last_json) + raise UserError("Account is not business account", **self.last_json) return res - def insights_media(self, media_pk: int) -> dict: - """Get insights data for media - :param media_pk: Media id - :return: Dict with insights data - :rtype: dict + def insights_media(self, media_pk: int) -> Dict: + """ + Get insights data for media + + Parameters + ---------- + media_pk: int + PK for the album you want to download + + Returns + ------- + Dict + A dictionary with insights data """ assert self.user_id, "Login required" media_pk = self.media_pk(media_pk) @@ -142,8 +175,9 @@ def insights_media(self, media_pk: int) -> dict: } try: result = self.private_request( - "ads/graphql/", self.with_query_params(data, query_params), + "ads/graphql/", + self.with_query_params(data, query_params), ) - return result['data']['instagram_post_by_igid'] + return result["data"]["instagram_post_by_igid"] except ClientError as e: raise MediaError(e.message, media_pk=media_pk, **self.last_json) diff --git a/instagrapi/mixins/location.py b/instagrapi/mixins/location.py index cec5384d..ffbfc112 100644 --- a/instagrapi/mixins/location.py +++ b/instagrapi/mixins/location.py @@ -2,36 +2,96 @@ import time from typing import List -from instagrapi.extractors import extract_location -from instagrapi.exceptions import ClientLoginRequired +from instagrapi.exceptions import (ClientLoginRequired, ClientNotFoundError, + LocationNotFound) +from instagrapi.extractors import extract_location,extract_locationV2 from instagrapi.types import Location, Media class LocationMixin: + """ + Helper class to get location + """ def location_search(self, lat: float, lng: float) -> List[Location]: - """Search location + """ + Get locations using lat and long + + Parameters + ---------- + lat: float + Latitude you want to search for + lng: float + Longitude you want to search for + + Returns + ------- + List[Location] + List of objects of Location """ params = { - 'latitude': lat, - 'longitude': lng, + "latitude": lat, + "longitude": lng, # rankToken=c544eea5-726b-4091-a916-a71a35a76474 - self.uuid? # fb_access_token=EAABwzLixnjYBABK2YBFkT...pKrjju4cijEGYtcbIyCSJ0j4ZD } result = self.private_request("location_search/", params=params) locations = [] - for venue in result['venues']: - if 'lat' not in venue: - venue['lat'] = lat - venue['lng'] = lng + for venue in result["venues"]: + if "lat" not in venue: + venue["lat"] = lat + venue["lng"] = lng locations.append(extract_location(venue)) + + print("location list") + print(locations) + print("--end location list") + return locations + def location_search_pk(self, pk: int) -> List[Location]: + """ + Get locations using lat and long + + Parameters + ---------- + lat: float + Latitude you want to search for + lng: float + Longitude you want to search for + + Returns + ------- + List[Location] + List of objects of Location + """ + result = self.top_search(self.location_info(pk).name) + + location = "{}" + for places in result["places"]: + single_location=extract_locationV2(places) + if single_location.pk==pk: + location=single_location + + return location + def location_complete(self, location: Location) -> Location: - """Smart complete of location """ - assert location and isinstance(location, Location),\ - f'Location is wrong "{location}" ({type(location)})' + Smart complete of location + + Parameters + ---------- + location: Location + An object of location + + Returns + ------- + Location + An object of Location + """ + assert location and isinstance( + location, Location + ), f'Location is wrong "{location}" ({type(location)})' if location.pk and not location.lat: # search lat and lng info = self.location_info(location.pk) @@ -47,61 +107,125 @@ def location_complete(self, location: Location) -> Location: pass if not location.pk and location.external_id: info = self.location_info(location.external_id) - if info.name == location.name or (info.lat == location.lat and info.lng == location.lng): + if info.name == location.name or ( + info.lat == location.lat and info.lng == location.lng + ): location.pk = location.external_id return location def location_build(self, location: Location) -> str: - """Build correct location data """ - if not location: - return '{}' - if not location.external_id and location.lat: - try: - location = self.location_search(location.lat, location.lng)[0] - except IndexError: - pass + Build correct location data + + Parameters + ---------- + location: Location + An object of location + + Returns + ------- + str + if location.pk != None: + location = self.location_info(location.pk) + else: + """ + if location.pk != None: + location = self.location_search_pk(location.pk) + else: + if not location: + return "{}" + if not location.external_id and location.lat: + try: + location = self.location_search(location.lat, location.lng)[0] + except IndexError: + pass data = { + "pk": location.pk, "name": location.name, "address": location.address, "lat": location.lat, "lng": location.lng, "external_source": location.external_id_source, - "facebook_places_id": location.external_id + "facebook_places_id": location.external_id, } + return json.dumps(data, separators=(",", ":")) def location_info_a1(self, location_pk: int) -> Location: - """Return additonal info for location by ?__a=1 """ - data = self.public_a1_request(f"/explore/locations/{location_pk}/") - return extract_location(data['location']) + Get a location using location pk + + Parameters + ---------- + location_pk: int + Unique identifier for a location + + Returns + ------- + Location + An object of Location + """ + try: + data = self.public_a1_request(f"/explore/locations/{location_pk}/") + if not data.get("location"): + raise LocationNotFound(location_pk=location_pk, **data) + return extract_location(data["location"]) + except ClientNotFoundError: + raise LocationNotFound(location_pk=location_pk) def location_info(self, location_pk: int) -> Location: - """Return additonal info for location + """ + Get a location using location pk + + Parameters + ---------- + location_pk: int + Unique identifier for a location + + Returns + ------- + Location + An object of Location """ return self.location_info_a1(location_pk) - def location_medias_a1(self, location_pk: int, amount: int = 24, sleep: float = 0.5, tab_key: str = '') -> List[Media]: - """Receive medias by location_pk + def location_medias_a1( + self, location_pk: int, amount: int = 24, sleep: float = 0.5, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a location + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 24 + sleep: float, optional + Timeout between requests, default is 0.5 + tab_key: str, optional + Tab Key, default value is "" + + Returns + ------- + List[Media] + List of objects of Media """ medias = [] end_cursor = None while True: data = self.public_a1_request( - f'/explore/locations/{location_pk}/', - params={"max_id": end_cursor} if end_cursor else {} - )['location'] + f"/explore/locations/{location_pk}/", + params={"max_id": end_cursor} if end_cursor else {}, + )["location"] page_info = data["edge_location_to_media"]["page_info"] end_cursor = page_info["end_cursor"] edges = data[tab_key]["edges"] for edge in edges: if amount and len(medias) >= amount: break - node = edge['node'] - medias.append( - self.media_info_gql(node['id']) - ) + node = edge["node"] + medias.append(self.media_info_gql(node["id"])) # time.sleep(sleep) if not page_info["has_next_page"] or not end_cursor: break @@ -109,24 +233,54 @@ def location_medias_a1(self, location_pk: int, amount: int = 24, sleep: float = break time.sleep(sleep) uniq_pks = set() - medias = [ - m for m in medias - if not (m.pk in uniq_pks or uniq_pks.add(m.pk)) - ] + medias = [m for m in medias if not (m.pk in uniq_pks or uniq_pks.add(m.pk))] if amount: medias = medias[:amount] return medias - def location_medias_top_a1(self, location_pk: int, amount: int = 9, sleep: float = 0.5) -> List[Media]: - """Top medias by public API + def location_medias_top_a1( + self, location_pk: int, amount: int = 9, sleep: float = 0.5 + ) -> List[Media]: + """ + Get top medias for a location + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 9 + sleep: float, optional + Timeout between requests, default is 0.5 + + Returns + ------- + List[Media] + List of objects of Media """ return self.location_medias_a1( - location_pk, amount, sleep=sleep, - tab_key='edge_location_to_top_posts' + location_pk, amount, sleep=sleep, tab_key="edge_location_to_top_posts" ) - def location_medias_top(self, location_pk: int, amount: int = 9, sleep: float = 0.5) -> List[Media]: - """Top medias + def location_medias_top( + self, location_pk: int, amount: int = 9, sleep: float = 0.5 + ) -> List[Media]: + """ + Get top medias for a location + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 9 + sleep: float, optional + Timeout between requests, default is 0.5 + + Returns + ------- + List[Media] + List of objects of Media """ try: return self.location_medias_top_a1(location_pk, amount, sleep) @@ -135,16 +289,49 @@ def location_medias_top(self, location_pk: int, amount: int = 9, sleep: float = raise e return self.location_medias_top_a1(location_pk, amount, sleep) # retry - def location_medias_recent_a1(self, location_pk: int, amount: int = 24, sleep: float = 0.5) -> List[Media]: - """Recent medias by private API + def location_medias_recent_a1( + self, location_pk: int, amount: int = 24, sleep: float = 0.5 + ) -> List[Media]: + """ + Get recent medias for a location + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 24 + sleep: float, optional + Timeout between requests, default is 0.5 + + Returns + ------- + List[Media] + List of objects of Media """ return self.location_medias_a1( - location_pk, amount, sleep=sleep, - tab_key='edge_location_to_media' + location_pk, amount, sleep=sleep, tab_key="edge_location_to_media" ) - def location_medias_recent(self, location_pk: int, amount: int = 24, sleep: float = 0.5) -> List[Media]: - """Recent medias + def location_medias_recent( + self, location_pk: int, amount: int = 24, sleep: float = 0.5 + ) -> List[Media]: + """ + Get recent medias for a location + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 24 + sleep: float, optional + Timeout between requests, default is 0.5 + + Returns + ------- + List[Media] + List of objects of Media """ try: return self.location_medias_recent_a1(location_pk, amount, sleep) diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index d9d4dbbd..9cad3fbd 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -1,31 +1,44 @@ import json import random -from typing import List +import time from copy import deepcopy +from datetime import datetime +from typing import Dict, List from urllib.parse import urlparse -from instagrapi.utils import InstagramIdCodec -from instagrapi.exceptions import ( - ClientError, - ClientNotFoundError, - MediaNotFound, - ClientLoginRequired -) -from instagrapi.extractors import ( - extract_media_v1, extract_media_gql, - extract_media_oembed, extract_location -) -from instagrapi.types import ( - Usertag, Location, UserShort, Media -) +from instagrapi.exceptions import (ClientError, ClientLoginRequired, + ClientNotFoundError, MediaNotFound) +from instagrapi.extractors import (extract_location, extract_media_gql, + extract_media_oembed, extract_media_v1, + extract_user_short) +from instagrapi.types import Location, Media, UserShort, Usertag +from instagrapi.utils import InstagramIdCodec, json_value class MediaMixin: + """ + Helpers for media + """ + _medias_cache = {} # pk -> object def media_id(self, media_pk: int) -> str: - """Return full media id - Example: 2277033926878261772 -> 2277033926878261772_1903424587 + """ + Get full media id + + Parameters + ---------- + media_pk: int + Unique Media ID + + Returns + ------- + str + Full media id + + Example + ------- + 2277033926878261772 -> 2277033926878261772_1903424587 """ media_id = str(media_pk) if "_" not in media_id: @@ -38,8 +51,22 @@ def media_id(self, media_pk: int) -> str: @staticmethod def media_pk(media_id: str) -> int: - """Return short media id - Example: 2277033926878261772_1903424587 -> 2277033926878261772 + """ + Get short media id + + Parameters + ---------- + media_id: str + Unique Media ID + + Returns + ------- + str + media id + + Example + ------- + 2277033926878261772_1903424587 -> 2277033926878261772 """ media_pk = str(media_id) if "_" in media_pk: @@ -47,24 +74,66 @@ def media_pk(media_id: str) -> int: return int(media_pk) def media_pk_from_code(self, code: str) -> int: - """Return media_pk from code - Example: B1LbfVPlwIA -> 2110901750722920960 - Example: B-fKL9qpeab -> 2278584739065882267 - Example: CCQQsCXjOaBfS3I2PpqsNkxElV9DXj61vzo5xs0 -> 2346448800803776129 - (because: CCQQsCXjOaB -> 2346448800803776129) + """ + Get Media PK from Code + + Parameters + ---------- + code: str + Code + + Returns + ------- + int + Full media id + + Examples + -------- + B1LbfVPlwIA -> 2110901750722920960 + B-fKL9qpeab -> 2278584739065882267 + CCQQsCXjOaBfS3I2PpqsNkxElV9DXj61vzo5xs0 -> 2346448800803776129 """ return InstagramIdCodec.decode(code[:11]) def media_pk_from_url(self, url: str) -> int: - """Return media_pk from url - Example: https://instagram.com/p/B1LbfVPlwIA/ -> 2110901750722920960 - Example: https://www.instagram.com/p/B-fKL9qpeab/?igshid=1xm76zkq7o1im -> 2278584739065882267 + """ + Get Media PK from URL + + Parameters + ---------- + url: str + URL of the media + + Returns + ------- + int + Media PK + + Examples + -------- + https://instagram.com/p/B1LbfVPlwIA/ -> 2110901750722920960 + https://www.instagram.com/p/B-fKL9qpeab/?igshid=1xm76zkq7o1im -> 2278584739065882267 """ path = urlparse(url).path parts = [p for p in path.split("/") if p] return self.media_pk_from_code(parts.pop()) def media_info_a1(self, media_pk: int, max_id: str = None) -> Media: + """ + Get Media from PK + + Parameters + ---------- + media_pk: int + Unique identifier of the media + max_id: str, optional + Max ID, default value is None + + Returns + ------- + Media + An object of Media type + """ media_pk = self.media_pk(media_pk) shortcode = InstagramIdCodec.encode(media_pk) """Use Client.media_info @@ -78,6 +147,19 @@ def media_info_a1(self, media_pk: int, max_id: str = None) -> Media: return extract_media_gql(data["shortcode_media"]) def media_info_gql(self, media_pk: int) -> Media: + """ + Get Media from PK + + Parameters + ---------- + media_pk: int + Unique identifier of the media + + Returns + ------- + Media + An object of Media type + """ media_pk = self.media_pk(media_pk) shortcode = InstagramIdCodec.encode(media_pk) """Use Client.media_info @@ -94,13 +176,26 @@ def media_info_gql(self, media_pk: int) -> Media: ) if not data.get("shortcode_media"): raise MediaNotFound(media_pk=media_pk, **data) - if data['shortcode_media']['location']: - data['shortcode_media']['location'] = self.location_complete( - extract_location(data['shortcode_media']['location']) + if data["shortcode_media"]["location"]: + data["shortcode_media"]["location"] = self.location_complete( + extract_location(data["shortcode_media"]["location"]) ).dict() return extract_media_gql(data["shortcode_media"]) def media_info_v1(self, media_pk: int) -> Media: + """ + Get Media from PK + + Parameters + ---------- + media_pk: int + Unique identifier of the media + + Returns + ------- + Media + An object of Media type + """ try: result = self.private_request(f"media/{media_pk}/info/") except ClientNotFoundError as e: @@ -112,7 +207,20 @@ def media_info_v1(self, media_pk: int) -> Media: return extract_media_v1(result["items"].pop()) def media_info(self, media_pk: int, use_cache: bool = True) -> Media: - """Return dict with media information + """ + Get Media Information from PK + + Parameters + ---------- + media_pk: int + Unique identifier of the media + use_cache: bool, optional + Whether or not to use information from cache, default value is True + + Returns + ------- + Media + An object of Media type """ media_pk = self.media_pk(media_pk) if not use_cache or media_pk not in self._medias_cache: @@ -130,17 +238,28 @@ def media_info(self, media_pk: int, use_cache: bool = True) -> Media: # Or private account media = self.media_info_v1(media_pk) self._medias_cache[media_pk] = media - return deepcopy(self._medias_cache[media_pk]) # return copy of cache (dict changes protection) + return deepcopy( + self._medias_cache[media_pk] + ) # return copy of cache (dict changes protection) def media_delete(self, media_id: str) -> bool: - """Delete media by media_id + """ + Delete media by Media ID + + Parameters + ---------- + media_id: str + Unique identifier of the media + + Returns + ------- + bool + A boolean value """ assert self.user_id, "Login required" media_id = self.media_id(media_id) result = self.private_request( - f"media/{media_id}/delete/", self.with_default_data( - {"media_id": media_id} - ) + f"media/{media_id}/delete/", self.with_default_data({"media_id": media_id}) ) self._medias_cache.pop(self.media_pk(media_id), None) return result.get("did_delete") @@ -151,16 +270,34 @@ def media_edit( caption: str, title: str = "", usertags: List[Usertag] = [], - location: Location = None - ) -> dict: - """Edit caption for media + location: Location = None, + ) -> Dict: + """ + Edit caption for media + + Parameters + ---------- + media_id: str + Unique identifier of the media + caption: str + Media caption + title: str + Title of the media + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None + + Returns + ------- + Dict + A dictionary of response from the call """ assert self.user_id, "Login required" media_id = self.media_id(media_id) media = self.media_info(media_id) # from cache usertags = [ - {"user_id": tag.user.pk, "position": [tag.x, tag.y]} - for tag in usertags + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags ] data = { "caption_text": caption, @@ -183,24 +320,58 @@ def media_edit( } self._medias_cache.pop(self.media_pk(media_id), None) # clean cache result = self.private_request( - f"media/{media_id}/edit_media/", self.with_default_data(data), + f"media/{media_id}/edit_media/", + self.with_default_data(data), ) return result def media_user(self, media_pk: int) -> UserShort: - """Get user object + """ + Get author of the media + + Parameters + ---------- + media_pk: int + Unique identifier of the media + + Returns + ------- + UserShort + An object of UserShort """ return self.media_info(media_pk).user - def media_oembed(self, url: str) -> dict: - """Return info about media and user by post URL + def media_oembed(self, url: str) -> Dict: """ - return extract_media_oembed( - self.private_request(f"oembed?url={url}") - ) + Return info about media and user from post URL + + Parameters + ---------- + url: str + URL for a media + + Returns + ------- + Dict + A dictionary of response from the call + """ + return extract_media_oembed(self.private_request(f"oembed?url={url}")) def media_like(self, media_id: str, revert: bool = False) -> bool: - """Like media + """ + Like a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + revert: bool, optional + If liked, whether or not to unlike. Default is False + + Returns + ------- + bool + A boolean value """ assert self.user_id, "Login required" media_id = self.media_id(media_id) @@ -210,16 +381,193 @@ def media_like(self, media_id: str, revert: bool = False) -> bool: "radio_type": "wifi-none", "is_carousel_bumped_post": "false", "container_module": "feed_timeline", - "feed_position": str(random.randint(0, 6)) + "feed_position": str(random.randint(0, 6)), } - name = 'unlike' if revert else 'like' + name = "unlike" if revert else "like" result = self.private_request( - f"media/{media_id}/{name}/", - self.with_action_data(data) + f"media/{media_id}/{name}/", self.with_action_data(data) ) - return result['status'] == 'ok' + return result["status"] == "ok" def media_unlike(self, media_id: str) -> bool: - """Unlike media + """ + Unlike a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + + Returns + ------- + bool + A boolean value """ return self.media_like(media_id, revert=True) + + def user_medias_gql( + self, user_id: int, amount: int = 50, sleep: int = 2 + ) -> List[Media]: + """ + Get a user's media + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 50 + sleep: int, optional + Timeout between pages iterations, default is 2 + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + end_cursor = None + variables = { + "id": user_id, + "first": 50, # default amount + } + while True: + if end_cursor: + variables["after"] = end_cursor + data = self.public_graphql_request( + variables, query_hash="e7e2f4da4b02303f74f0841279e52d76" + ) + page_info = json_value( + data, "user", "edge_owner_to_timeline_media", "page_info", default={} + ) + edges = json_value( + data, "user", "edge_owner_to_timeline_media", "edges", default=[] + ) + for edge in edges: + medias.append(edge["node"]) + end_cursor = page_info.get("end_cursor") + if not page_info.get("has_next_page") or not end_cursor: + break + if len(medias) >= amount: + break + time.sleep(sleep) + return [extract_media_gql(media) for media in medias[:amount]] + + def user_medias_v1(self, user_id: int, amount: int = 18) -> List[Media]: + """ + Get a user's media + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 18 + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + next_max_id = "" + min_timestamp = None + while True: + try: + items = self.private_request( + f"feed/user/{user_id}/", + params={ + "max_id": next_max_id, + "min_timestamp": min_timestamp, + "rank_token": self.rank_token, + "ranked_content": "true", + }, + )["items"] + except Exception as e: + self.logger.exception(e) + break + medias.extend(items) + if not self.last_json.get("more_available"): + break + if len(medias) >= amount: + break + next_max_id = self.last_json.get("next_max_id", "") + return [extract_media_v1(media) for media in medias[:amount]] + + def user_medias(self, user_id: int, amount: int = 50) -> List[Media]: + """ + Get a user's media + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 50 + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + try: + try: + medias = self.user_medias_gql(user_id, amount) + except ClientLoginRequired as e: + if not self.inject_sessionid_to_public(): + raise e + medias = self.user_medias_gql(user_id, amount) # retry + except Exception as e: + if not isinstance(e, ClientError): + self.logger.exception(e) + # User may been private, attempt via Private API + # (You can check is_private, but there may be other reasons, + # it is better to try through a Private API) + medias = self.user_medias_v1(user_id, amount) + return medias + + def media_seen(self, media_ids: List[str], skipped_media_ids: List[str] = []): + """ + Mark a media as seen + + Parameters + ---------- + media_id: str + + Returns + ------- + bool + A boolean value + """ + + def gen(media_ids): + result = {} + for media_id in media_ids: + media_pk, user_id = self.media_id(media_id).split('_') + end = int(datetime.now().timestamp()) + begin = end - random.randint(100, 3000) + result[f"{media_pk}_{user_id}_{user_id}"] = [f"{begin}_{end}"] + return result + + data = { + "container_module": "feed_timeline", + "live_vods_skipped": {}, + "nuxes_skipped": {}, + "nuxes": {}, + "reels": gen(media_ids), + "live_vods": {}, + "reel_media_skipped": gen(skipped_media_ids) + } + result = self.private_request( + "/v2/media/seen/?reel=1&live_vod=0", + self.with_default_data(data) + ) + return result["status"] == "ok" + + def media_likers(self, media_id: str) -> List[UserShort]: + media_id = self.media_id(media_id) + result = self.private_request(f"media/{media_id}/likers/") + return [extract_user_short(u) for u in result['users']] diff --git a/instagrapi/mixins/photo.py b/instagrapi/mixins/photo.py index 29980a6e..268af63c 100644 --- a/instagrapi/mixins/photo.py +++ b/instagrapi/mixins/photo.py @@ -1,30 +1,51 @@ -import shutil import json -import time import random -import requests +import shutil +import time from pathlib import Path -from typing import List -from uuid import uuid4 +from typing import Dict, List from urllib.parse import urlparse +from uuid import uuid4 + +import requests from instagrapi import config +from instagrapi.exceptions import (PhotoConfigureError, + PhotoConfigureStoryError, PhotoNotUpload) from instagrapi.extractors import extract_media_v1 -from instagrapi.exceptions import ( - PhotoNotUpload, PhotoConfigureError, PhotoConfigureStoryError -) -from instagrapi.types import Usertag, Location, StoryMention, StoryLink, Media +from instagrapi.types import (Location, Media, Story, StoryHashtag, StoryLink, + StoryLocation, StoryMention, StorySticker, + Usertag) from instagrapi.utils import dumps try: - from PIL import Image,ImageFilter + from PIL import Image except ImportError: raise Exception("You don't have PIL installed. Please install PIL or Pillow>=7.2.0") class DownloadPhotoMixin: + """ + Helpers for downloading photo + """ def photo_download(self, media_pk: int, folder: Path = "") -> Path: + """ + Download photo using media pk + + Parameters + ---------- + media_pk: int + Unique Media ID + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working + directory + + Returns + ------- + Path + Path for the file downloaded + """ media = self.media_info(media_pk) assert media.media_type == 1, "Must been photo" filename = "{username}_{media_pk}".format( @@ -32,10 +53,29 @@ def photo_download(self, media_pk: int, folder: Path = "") -> Path: ) return self.photo_download_by_url(media.thumbnail_url, filename, folder) - def photo_download_by_url(self, url: str, filename: str = "", folder: Path = "") -> Path: - fname = urlparse(url).path.rsplit('/', 1)[1] - filename = "%s.%s" % (filename, fname.rsplit('.', 1)[ - 1]) if filename else fname + def photo_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> Path: + """ + Download photo using media pk + + Parameters + ---------- + url: str + URL for a media + filename: str, optional + Filename for the media + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working + directory + + Returns + ------- + Path + Path for the file downloaded + """ + fname = urlparse(url).path.rsplit("/", 1)[1] + filename = "%s.%s" % (filename, fname.rsplit(".", 1)[1]) if filename else fname path = Path(folder) / filename response = requests.get(url, stream=True) response.raise_for_status() @@ -46,20 +86,28 @@ def photo_download_by_url(self, url: str, filename: str = "", folder: Path = "") class UploadPhotoMixin: + """ + Helpers for downloading photo + """ def photo_rupload( - self, - path: Path, - upload_id: str = "", - to_album: bool = False + self, path: Path, upload_id: str = "", to_album: bool = False ) -> tuple: - """Upload photo to Instagram + """ + Upload photo to Instagram - :param path: Path to photo file - :param upload_id: Unique upload_id (String). When None, then generate - automatically. Example from video.video_configure + Parameters + ---------- + path: Path + Path to the media + upload_id: str, optional + Unique upload_id (String). When None, then generate automatically. Example from video.video_configure + to_album: bool, optional - :return: Tuple (upload_id, width, height) + Returns + ------- + tuple + (Upload ID for the media, width, height) """ assert isinstance(path, Path), f"Path must been Path, now {path} ({type(path)})" upload_id = upload_id or str(int(time.time() * 1000)) @@ -69,9 +117,10 @@ def photo_rupload( upload_name = "{upload_id}_0_{rand}".format( upload_id=upload_id, rand=random.randint(1000000000, 9999999999) ) + # media_type: "2" when from video/igtv/album thumbnail, "1" - upload photo only rupload_params = { "retry_context": '{"num_step_auto_retry":0,"num_reupload":0,"num_step_manual_retry":0}', - "media_type": "1", # "2" if upload_id else "1", # "2" when from video/igtv/album thumbnail, "1" - upload photo only + "media_type": "1", # "2" if upload_id else "1", "xsharing_user_ids": "[]", "upload_id": upload_id, "image_compression": json.dumps( @@ -97,7 +146,8 @@ def photo_rupload( "https://{domain}/rupload_igphoto/{name}".format( domain=config.API_DOMAIN, name=upload_name ), - data=photo_data, headers=headers + data=photo_data, + headers=headers, ) self.request_log(response) if response.status_code != 200: @@ -117,40 +167,44 @@ def photo_upload( upload_id: str = "", usertags: List[Usertag] = [], location: Location = None, - links: List[StoryLink] = [], - configure_timeout: int = 3, - configure_handler=None, - configure_exception=None ) -> Media: - """Upload photo and configure to feed + """ + Upload photo and configure to feed - :param path: Path to photo file - :param caption: Media description (String) - :param storyUpload: if True will create a story from path - :param upload_id: Unique upload_id (String). When None, then generate - automatically. Example from video.video_configure - :param usertags: Mentioned users (List) - :param location: Location - :param links: URLs for Swipe Up (List of dicts) - :param configure_timeout: Timeout between attempt to configure media (set caption, etc) - :param configure_handler: Configure handler method - :param configure_exception: Configure exception class + Parameters + ---------- + path: Path + Path to the media + caption: str + Media caption + upload_id: str, optional + Unique upload_id (String). When None, then generate automatically. Example from video.video_configure + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None - :return: Media + Returns + ------- + Media + An object of Media class """ path = Path(path) upload_id, width, height = self.photo_rupload(path, upload_id) for attempt in range(10): self.logger.debug(f"Attempt #{attempt} to configure Photo: {path}") - time.sleep(configure_timeout) - if (configure_handler or self.photo_configure)(upload_id, width, height, caption, usertags, location, links): + time.sleep(3) + if self.photo_configure( + upload_id, width, height, caption, usertags, location, + ): media = self.last_json.get("media") self.expose() if storyUpload: self.photo_upload_to_story(path,caption) return extract_media_v1(media) - raise (configure_exception or PhotoConfigureError)( - response=self.last_response, **self.last_json) + raise PhotoConfigureError( + response=self.last_response, **self.last_json + ) def photo_configure( self, @@ -160,23 +214,32 @@ def photo_configure( caption: str, usertags: List[Usertag] = [], location: Location = None, - links: List[StoryLink] = [] - ) -> dict: - """Post Configure Photo (send caption to Instagram) + ) -> Dict: + """ + Post Configure Photo (send caption to Instagram) - :param upload_id: Unique upload_id (String) - :param width: Width in px (Integer) - :param height: Height in px (Integer) - :param caption: Media description (String) - :param usertags: Mentioned users (List) - :param location: Location - :param links: URLs for Swipe Up (List of dicts) + Parameters + ---------- + upload_id: str + Unique upload_id + width: int + Width of the video in pixels + height: int + Height of the video in pixels + caption: str + Media caption + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None - :return: Media (Dict) + Returns + ------- + Dict + A dictionary of response from the call """ usertags = [ - {"user_id": tag.user.pk, "position": [tag.x, tag.y]} - for tag in usertags + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags ] data = { "timezone_offset": "10800", @@ -198,96 +261,74 @@ def photo_configure( } return self.private_request("media/configure/", self.with_default_data(data)) - def stories_shaper(self,path): - img = Image.open(path) - if (img.size[0], img.size[1]) == (1080, 1920): - print("Image is already 1080x1920. Just converting image.") - new_path = "{path}_STORIES.jpg".format(path=path) - new = Image.new("RGB", (img.size[0], img.size[1]), (255, 255, 255)) - new.paste(img, (0, 0, img.size[0], img.size[1])) - new.save(new_path) - return new_path - else: - min_width = 1080 - min_height = 1920 - if img.size[1] != 1920: - height_percent = min_height / float(img.size[1]) - width_size = int(float(img.size[0]) * float(height_percent)) - img = img.resize((width_size, min_height), Image.ANTIALIAS) - else: - pass - if img.size[0] < 1080: - width_percent = min_width / float(img.size[0]) - height_size = int(float(img.size[1]) * float(width_percent)) - img_bg = img.resize((min_width, height_size), Image.ANTIALIAS) - else: - pass - img_bg = img.crop( - ( - int((img.size[0] - 1080) / 2), - int((img.size[1] - 1920) / 2), - int(1080 + ((img.size[0] - 1080) / 2)), - int(1920 + ((img.size[1] - 1920) / 2)), - ) - ).filter(ImageFilter.GaussianBlur(100)) - if img.size[1] > img.size[0]: - height_percent = min_height / float(img.size[1]) - width_size = int(float(img.size[0]) * float(height_percent)) - img = img.resize((width_size, min_height), Image.ANTIALIAS) - if img.size[0] > 1080: - width_percent = min_width / float(img.size[0]) - height_size = int(float(img.size[1]) * float(width_percent)) - img = img.resize((min_width, height_size), Image.ANTIALIAS) - img_bg.paste( - img, (int(540 - img.size[0] / 2), int(960 - img.size[1] / 2)) - ) - else: - img_bg.paste(img, (int(540 - img.size[0] / 2), 0)) - else: - width_percent = min_width / float(img.size[0]) - height_size = int(float(img.size[1]) * float(width_percent)) - img = img.resize((min_width, height_size), Image.ANTIALIAS) - img_bg.paste(img, (int(540 - img.size[0] / 2), int(960 - img.size[1] / 2))) - new_path = "{path}_STORIES.jpg".format(path=path) - new = Image.new("RGB", (img_bg.size[0], img_bg.size[1]), (255, 255, 255)) - new.paste(img_bg, (0, 0, img_bg.size[0], img_bg.size[1])) - new.save(new_path) - return new_path - def photo_upload_to_story( self, path: Path, caption: str, - blur:bool=True, upload_id: str = "", mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], links: List[StoryLink] = [], - configure_timeout: int = 3 - ) -> Media: - """Upload photo and configure to story + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + ) -> Story: + """ + Upload photo as a story and configure it - :param path: Path to photo file - :param caption: Media description (String) - :param upload_id: Unique upload_id (String). When None, then generate - automatically. Example from video.video_configure - :param mentions: Mentioned users (List) - :param links: URLs for Swipe Up (List of dicts) - :param configure_timeout: Timeout between at dict: - """Story Configure for Photo + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Post configure photo - :param upload_id: Unique upload_id (String) - :param width: Width in px (Integer) - :param height: Height in px (Integer) - :param caption: Media description (String) - :param usertags: Mentioned users (List) - :param location: Temporary unused - :param links: URLs for Swipe Up (List of dicts) + Parameters + ---------- + upload_id: str + Unique upload_id + width: int + Width of the video in pixels + height: int + Height of the video in pixels + caption: str + Media caption + mentions: List[StoryMention], optional + List of mentions to be tagged on this upload, default is empty list. + locations: List[StoryLocation], optional + List of locations to be tagged on this upload, default is empty list. + links: List[StoryLink] + URLs for Swipe Up + hashtags: List[StoryHashtag], optional + List of hashtags to be tagged on this upload, default is empty list. + stickers: List[StorySticker], optional + List of stickers to be tagged on this upload, default is empty list. + extra_data: List[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. - :return: Media (Dict) + Returns + ------- + Dict + A dictionary of response from the call """ timestamp = int(time.time()) + story_sticker_ids = [] data = { "text_metadata": '[{"font_size":40.0,"scale":1.0,"width":611.0,"height":169.0,"x":0.51414347,"y":0.8487708,"rotation":0.0}]', "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), @@ -319,7 +383,7 @@ def photo_configure_to_story( "scene_capture_type": "", "timezone_offset": "10800", "client_shared_at": str(timestamp - 5), # 5 seconds ago - "story_sticker_ids": "time_sticker_digital", + "story_sticker_ids": "", "media_folder": "Camera", "configure_mode": "1", "source_type": "4", @@ -331,12 +395,6 @@ def photo_configure_to_story( "upload_id": upload_id, "client_timestamp": str(timestamp), "device": self.device, - "implicit_location": { - "media_location": { - "lat": 44.64972222222222, - "lng": 33.541666666666664 - } - }, "edits": { "crop_original_size": [width * 1.0, height * 1.0], "crop_center": [0.0, 0.0], @@ -344,17 +402,84 @@ def photo_configure_to_story( }, "extra": {"source_width": width, "source_height": height}, } + data.update(extra_data) if links: links = [link.dict() for link in links] data["story_cta"] = dumps([{"links": links}]) + tap_models = [] + static_models = [] if mentions: - mentions = [ + reel_mentions = [ { - "x": 0.5002546, "y": 0.8583542, "z": 0, - "width": 0.4712963, "height": 0.0703125, "rotation": 0.0, - "type": "mention", "user_id": str(mention.user.pk), - "is_sticker": False, "display_type": "mention_username" - } for mention in mentions + "x": 0.5002546, + "y": 0.8583542, + "z": 0, + "width": 0.4712963, + "height": 0.0703125, + "rotation": 0.0, + "type": "mention", + "user_id": str(mention.user.pk), + "is_sticker": False, + "display_type": "mention_username", + } + for mention in mentions ] - data["tap_models"] = data["reel_mentions"] = json.dumps(mentions) - return self.private_request("media/configure_to_story/", self.with_default_data(data)) + data["reel_mentions"] = json.dumps(reel_mentions) + tap_models.extend(reel_mentions) + if hashtags: + story_sticker_ids.append("hashtag_sticker") + for mention in hashtags: + item = { + "x": mention.x, + "y": mention.y, + "z": 0, + "width": mention.width, + "height": mention.height, + "rotation": 0.0, + "type": "hashtag", + "tag_name": mention.hashtag.name, + "is_sticker": True, + "tap_state": 0, + "tap_state_str_id": "hashtag_sticker_gradient" + } + tap_models.append(item) + if locations: + story_sticker_ids.append("location_sticker") + for mention in locations: + mention.location = self.location_complete(mention.location) + item = { + "x": mention.x, + "y": mention.y, + "z": 0, + "width": mention.width, + "height": mention.height, + "rotation": 0.0, + "type": "location", + "location_id": str(mention.location.pk), + "is_sticker": True, + "tap_state": 0, + "tap_state_str_id": "location_sticker_vibrant" + } + tap_models.append(item) + if stickers: + for sticker in stickers: + str_id = sticker.id # "gif_Igjf05J559JWuef4N5" + static_models.append({ + "x": sticker.x, + "y": sticker.y, + "z": sticker.z, + "width": sticker.width, + "height": sticker.height, + "rotation": sticker.rotation, + "str_id": str_id, + "sticker_type": sticker.type, + }) + story_sticker_ids.append(str_id) + if sticker.type == "gif": + data["has_animated_sticker"] = "1" + data["tap_models"] = dumps(tap_models) + data["static_models"] = dumps(static_models) + data["story_sticker_ids"] = dumps(story_sticker_ids) + return self.private_request( + "media/configure_to_story/", self.with_default_data(data) + ) diff --git a/instagrapi/mixins/private.py b/instagrapi/mixins/private.py index 60f52f3e..8eedeb3f 100644 --- a/instagrapi/mixins/private.py +++ b/instagrapi/mixins/private.py @@ -1,35 +1,41 @@ -import time +import hashlib import json -import random import logging -import hashlib -import requests +import random +import time from json.decoder import JSONDecodeError +import requests + from instagrapi import config +from instagrapi.exceptions import (BadPassword, ChallengeRequired, + ClientBadRequestError, + ClientConnectionError, ClientError, + ClientForbiddenError, ClientJSONDecodeError, + ClientNotFoundError, ClientRequestTimeout, + ClientThrottledError, FeedbackRequired, + LoginRequired, PleaseWaitFewMinutes, + RateLimitError, SentryBlock, UnknownError, + VideoTooLongException) from instagrapi.utils import generate_signature -from instagrapi.exceptions import ( - ClientError, - ClientConnectionError, - ClientNotFoundError, - ClientJSONDecodeError, - ClientForbiddenError, - ClientBadRequestError, - ClientThrottledError, - ClientRequestTimeout, - FeedbackRequired, - ChallengeRequired, - LoginRequired, - SentryBlock, - RateLimitError, - BadPassword, - PleaseWaitFewMinutes, - VideoTooLongException, - UnknownError, -) -def manual_input_code(self, username, choice=None): +def manual_input_code(self, username: str, choice=None): + """ + Manual security code helper + + Parameters + ---------- + username: str + User name of a Instagram account + choice: optional + Whether sms or email + + Returns + ------- + str + Code + """ code = None choice_name = {0: 'sms', 1: 'email'}.get(choice) while True: @@ -40,6 +46,9 @@ def manual_input_code(self, username, choice=None): class PrivateRequestMixin: + """ + Helpers for private request + """ private_requests_count = 0 handle_exception = None challenge_code_handler = manual_input_code @@ -57,9 +66,23 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def small_delay(self): + """ + Small Delay + + Returns + ------- + Void + """ time.sleep(random.uniform(0.75, 3.75)) def very_small_delay(self): + """ + Very small delay + + Returns + ------- + Void + """ time.sleep(random.uniform(0.175, 0.875)) @property @@ -148,11 +171,10 @@ def _send_private_request( headers=None, extra_sig=None, ): + endpoint1=endpoint self.last_response = None self.last_json = last_json = {} # for Sentry context in traceback self.private.headers.update(self.base_headers) - if endpoint.startswith("/"): - endpoint = endpoint[1:] if headers: self.private.headers.update(headers) if not login: @@ -160,6 +182,12 @@ def _send_private_request( if self.user_id and login: raise Exception(f"User already login ({self.user_id})") try: + if not endpoint.startswith('/'): + endpoint = f"/v1/{endpoint}" + api_url = f"https://{config.API_DOMAIN}/api{endpoint}" + url=endpoint + if "topsearch" in endpoint: + api_url= endpoint1 if data: # POST # Client.direct_answer raw dict # data = json.dumps(data) @@ -169,11 +197,10 @@ def _send_private_request( if extra_sig: data += "&".join(extra_sig) response = self.private.post( - config.API_URL + endpoint, data=data, params=params + api_url, data=data, params=params ) else: # GET - response = self.private.get( - config.API_URL + endpoint, params=params) + response = self.private.get(api_url, params=params) self.logger.debug( "private_request %s: %s (%s)", response.status_code, response.url, response.text ) diff --git a/instagrapi/mixins/public.py b/instagrapi/mixins/public.py index b872a9d3..80765cf7 100644 --- a/instagrapi/mixins/public.py +++ b/instagrapi/mixins/public.py @@ -1,23 +1,18 @@ import json -import time -import requests import logging +import time from json.decoder import JSONDecodeError +import requests + +from instagrapi.exceptions import (ClientBadRequestError, + ClientConnectionError, ClientError, + ClientForbiddenError, ClientGraphqlError, + ClientIncompleteReadError, + ClientJSONDecodeError, ClientLoginRequired, + ClientNotFoundError, ClientThrottledError, + GenericRequestError) from instagrapi.utils import json_value -from instagrapi.exceptions import ( - ClientError, - ClientConnectionError, - ClientNotFoundError, - ClientJSONDecodeError, - ClientForbiddenError, - ClientBadRequestError, - ClientGraphqlError, - ClientThrottledError, - ClientIncompleteReadError, - ClientLoginRequired, - GenericRequestError, -) class PublicRequestMixin: @@ -38,8 +33,7 @@ def __init__(self, *args, **kwargs): "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15", } ) - self.request_timeout = kwargs.pop( - "request_timeout", self.request_timeout) + self.request_timeout = kwargs.pop("request_timeout", self.request_timeout) super().__init__(*args, **kwargs) def public_request( @@ -53,7 +47,10 @@ def public_request( retries_timeout=10, ): kwargs = dict( - data=data, params=params, headers=headers, return_json=return_json, + data=data, + params=params, + headers=headers, + return_json=return_json, ) assert retries_count <= 10, "Retries count is too high" assert retries_timeout <= 600, "Retries timeout is too high" @@ -151,8 +148,7 @@ def _send_public_request( raise ClientError(e, response=e.response) except requests.ConnectionError as e: - raise ClientConnectionError( - "{} {}".format(e.__class__.__name__, str(e))) + raise ClientConnectionError("{} {}".format(e.__class__.__name__, str(e))) def public_a1_request(self, endpoint, data=None, params=None, headers=None): url = self.PUBLIC_API_URL + endpoint.lstrip("/") @@ -170,8 +166,7 @@ def public_a1_request(self, endpoint, data=None, params=None, headers=None): error_type = response.get("error_type") if error_type == "generic_request_error": raise GenericRequestError( - json_value(response, "errors", "error", - 0, default=error_type), + json_value(response, "errors", "error", 0, default=error_type), **response ) raise e @@ -186,8 +181,7 @@ def public_graphql_request( headers=None, ): assert query_id or query_hash, "Must provide valid one of: query_id, query_hash" - default_params = {"variables": json.dumps( - variables, separators=(",", ":"))} + default_params = {"variables": json.dumps(variables, separators=(",", ":"))} if query_id: default_params["query_id"] = query_id @@ -211,8 +205,7 @@ def public_graphql_request( if body_json.get("status", None) != "ok": raise ClientGraphqlError( "Unexpected status '{}' in response. Message: '{}'".format( - body_json.get("status", None), body_json.get( - "message", None) + body_json.get("status", None), body_json.get("message", None) ), response=body_json, ) @@ -232,10 +225,8 @@ def public_graphql_request( class TopSearchesPublicMixin: - def top_search(self, query): - """Anonymous IG search request - """ + """Anonymous IG search request""" url = "https://www.instagram.com/web/search/topsearch/" params = { "context": "blended", @@ -243,12 +234,11 @@ def top_search(self, query): "rank_token": 0.7763938004511706, "include_reel": "true", } - response = self.public_request(url, params=params, return_json=True) + response = self.private_request(url, params=params) return response class ProfilePublicMixin: - def location_feed(self, location_id, count=16, end_cursor=None): if count > 50: raise ValueError("Count cannot be greater than 50") diff --git a/instagrapi/mixins/story.py b/instagrapi/mixins/story.py new file mode 100644 index 00000000..0402505c --- /dev/null +++ b/instagrapi/mixins/story.py @@ -0,0 +1,213 @@ +import json +from copy import deepcopy +from typing import List + +from instagrapi import config +from instagrapi.exceptions import (ClientNotFoundError, StoryNotFound, + UserNotFound) +from instagrapi.extractors import (extract_story_gql, extract_story_v1, + extract_user_short) +from instagrapi.types import Story, UserShort + + +class StoryMixin: + _stories_cache = {} # pk -> object + + # def story_info_gql(self, story_pk: int): + # # GQL havent video_url :-( + # return self.media_info_gql(self, int(story_pk)) + + def story_info_v1(self, story_pk: int) -> Story: + """ + Get Story by pk or id + + Parameters + ---------- + story_pk: int + Unique identifier of the story + + Returns + ------- + Story + An object of Story type + """ + story_id = self.media_id(story_pk) + story_pk, user_id = story_id.split("_") + stories = self.user_stories_v1(user_id) + story_pk = int(story_pk) + for story in stories: + self._stories_cache[story.pk] = story + if story_pk in self._stories_cache: + return deepcopy(self._stories_cache[story_pk]) + raise StoryNotFound(story_pk=story_pk, user_id=user_id) + + def story_info(self, story_pk: int, use_cache: bool = True) -> Story: + """ + Get Story by pk or id + + Parameters + ---------- + story_pk: int + Unique identifier of the story + use_cache: bool, optional + Whether or not to use information from cache, default value is True + + Returns + ------- + Story + An object of Story type + """ + if not use_cache or story_pk not in self._stories_cache: + story = self.story_info_v1(story_pk) + self._stories_cache[story_pk] = story + return deepcopy(self._stories_cache[story_pk]) + + def story_delete(self, story_pk: int) -> bool: + """ + Delete story + + Parameters + ---------- + story_pk: int + Unique identifier of the story + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + media_id = self.media_id(story_pk) + self._stories_cache.pop(self.media_pk(media_id), None) + return self.media_delete(media_id) + + def users_stories_gql(self, user_ids: List[int]) -> List[UserShort]: + """ + Get a user's stories (Public API) + + Parameters + ---------- + user_ids: List[int] + + Returns + ------- + List[UserShort] + A list of objects of UserShort for each user_id + """ + self.inject_sessionid_to_public() + + def _userid_chunks(): + assert user_ids is not None + user_ids_per_query = 50 + for i in range(0, len(user_ids), user_ids_per_query): + yield user_ids[i:i + user_ids_per_query] + + stories_un = {} + for userid_chunk in _userid_chunks(): + res = self.public_graphql_request( + query_hash="303a4ae99711322310f25250d988f3b7", + variables={"reel_ids": userid_chunk, "precomposed_overlay": False} + ) + stories_un.update(res) + users = [] + for media in stories_un['reels_media']: + user = extract_user_short(media['owner']) + user.stories = [ + extract_story_gql(m) + for m in media['items'] + ] + users.append(user) + return users + + def user_stories_gql(self, user_id: int, amount: int = None) -> List[UserShort]: + """ + Get a user's stories (Public API) + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of story to return, default is all + + Returns + ------- + List[UserShort] + A list of objects of UserShort for each user_id + """ + user = self.users_stories_gql([user_id])[0] + stories = deepcopy(user.stories) + if amount: + stories = stories[:amount] + return stories + + def user_stories_v1(self, user_id: int, amount: int = None) -> List[Story]: + """ + Get a user's stories (Private API) + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of story to return, default is all + + Returns + ------- + List[Story] + A list of objects of Story + """ + params = { + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES) + } + user_id = int(user_id) + reel = self.private_request(f"feed/user/{user_id}/story/", params=params)[ + "reel" + ] + stories = [] + for item in reel["items"]: + stories.append(extract_story_v1(item)) + if amount: + amount = int(amount) + stories = stories[:amount] + return stories + + def user_stories(self, user_id: int, amount: int = None) -> List[Story]: + """ + Get a user's stories + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of story to return, default is all + + Returns + ------- + List[Story] + A list of objects of STory + """ + try: + return self.user_stories_gql(user_id, amount) + except ClientNotFoundError as e: + raise UserNotFound(e, user_id=user_id, **self.last_json) + except IndexError: + return [] + except Exception: + return self.user_stories_v1(user_id, amount) + + def story_seen(self, story_pks: List[int], skipped_story_pks: List[int] = []): + """ + Mark a story as seen + + Parameters + ---------- + story_pk: int + + Returns + ------- + bool + A boolean value + """ + return self.media_seen( + [self.media_id(mid) for mid in story_pks], + [self.media_id(mid) for mid in skipped_story_pks] + ) diff --git a/instagrapi/mixins/user.py b/instagrapi/mixins/user.py index 38f31721..0ceb68bc 100644 --- a/instagrapi/mixins/user.py +++ b/instagrapi/mixins/user.py @@ -1,26 +1,20 @@ -import time -from typing import List, Dict from copy import deepcopy +from typing import Dict, List -from instagrapi.exceptions import ( - ClientError, - ClientNotFoundError, - UserNotFound, - ClientLoginRequired -) -from instagrapi.extractors import ( - extract_user_gql, - extract_user_v1, - extract_user_short, - extract_media_gql, - extract_media_v1, -) -from instagrapi.utils import json_value -from instagrapi.types import User, Media, UserShort from instagrapi import config +from instagrapi.exceptions import (ClientError, ClientLoginRequired, + ClientNotFoundError, UserNotFound) +from instagrapi.extractors import (extract_user_gql, extract_user_short, + extract_user_v1) +from instagrapi.types import User, UserShort +from instagrapi.utils import json_value class UserMixin: + """ + Helpers to manage user + """ + _users_cache = {} # user_pk -> User _userhorts_cache = {} # user_pk -> UserShort _usernames_cache = {} # username -> user_pk @@ -28,13 +22,40 @@ class UserMixin: _users_followers = {} # user_pk -> dict(user_pk -> "short user object") def user_id_from_username(self, username: str) -> int: - """Get user_id by username - Result: 'adw0rd' -> 1903424587 + """ + Get full media id + + Parameters + ---------- + username: str + Username for an Instagram account + + Returns + ------- + int + User PK + + Example + ------- + 'adw0rd' -> 1903424587 """ return int(self.user_info_by_username(username).pk) def user_short_gql(self, user_id: int, use_cache: bool = True) -> UserShort: - """Return UserShort by user_id + """ + Get full media id + + Parameters + ---------- + user_id: int + User ID + use_cache: bool, optional + Whether or not to use information from cache, default value is True + + Returns + ------- + UserShort + An object of UserShort type """ if use_cache: cache = self._userhorts_cache.get(user_id) @@ -54,14 +75,42 @@ def user_short_gql(self, user_id: int, use_cache: bool = True) -> UserShort: return user def username_from_user_id_gql(self, user_id: int) -> str: - """Get username by user_id - Result: 1903424587 -> 'adw0rd' + """ + Get username from user id + + Parameters + ---------- + user_id: int + User ID + + Returns + ------- + str + User name + + Example + ------- + 1903424587 -> 'adw0rd' """ return self.user_short_gql(user_id).username def username_from_user_id(self, user_id: int) -> str: - """Get username by user_id - Result: 1903424587 -> 'adw0rd' + """ + Get username from user id + + Parameters + ---------- + user_id: int + User ID + + Returns + ------- + str + User name + + Example + ------- + 1903424587 -> 'adw0rd' """ user_id = int(user_id) try: @@ -71,12 +120,34 @@ def username_from_user_id(self, user_id: int) -> str: return username def user_info_by_username_gql(self, username: str) -> User: - """Return user object via GraphQL API + """ + Get user object from user name + + Parameters + ---------- + username: str + User name of an instagram account + + Returns + ------- + User + An object of User type """ return extract_user_gql(self.public_a1_request(f"/{username!s}/")["user"]) def user_info_by_username_v1(self, username: str) -> User: - """Return user object via Private API + """ + Get user object from user name + + Parameters + ---------- + username: str + User name of an instagram account + + Returns + ------- + User + An object of User type """ try: result = self.private_request(f"users/{username}/usernameinfo/") @@ -89,8 +160,20 @@ def user_info_by_username_v1(self, username: str) -> User: return extract_user_v1(result["user"]) def user_info_by_username(self, username: str, use_cache: bool = True) -> User: - """Get user info by username - Result as in self.user_info() + """ + Get user object from username + + Parameters + ---------- + username: str + User name of an instagram account + use_cache: bool, optional + Whether or not to use information from cache, default value is True + + Returns + ------- + User + An object of User type """ if not use_cache or username not in self._usernames_cache: try: @@ -109,16 +192,36 @@ def user_info_by_username(self, username: str, use_cache: bool = True) -> User: return self.user_info(self._usernames_cache[username]) def user_info_gql(self, user_id: int) -> User: - """Return user object via GraphQL API + """ + Get user object from user id + + Parameters + ---------- + user_id: int + User id of an instagram account + + Returns + ------- + User + An object of User type """ user_id = int(user_id) # GraphQL haven't method to receive user by id - return self.user_info_by_username_gql( - self.username_from_user_id_gql(user_id) - ) + return self.user_info_by_username_gql(self.username_from_user_id_gql(user_id)) def user_info_v1(self, user_id: int) -> User: - """Return user object via Private API + """ + Get user object from user id + + Parameters + ---------- + user_id: int + User id of an instagram account + + Returns + ------- + User + An object of User type """ user_id = int(user_id) try: @@ -132,7 +235,20 @@ def user_info_v1(self, user_id: int) -> User: return extract_user_v1(result["user"]) def user_info(self, user_id: int, use_cache: bool = True) -> User: - """Get user info by user_id + """ + Get user object from user id + + Parameters + ---------- + user_id: int + User id of an instagram account + use_cache: bool, optional + Whether or not to use information from cache, default value is True + + Returns + ------- + User + An object of User type """ user_id = int(user_id) if not use_cache or user_id not in self._users_cache: @@ -149,10 +265,25 @@ def user_info(self, user_id: int, use_cache: bool = True) -> User: user = self.user_info_v1(user_id) self._users_cache[user_id] = user self._usernames_cache[user.username] = user.pk - return deepcopy(self._users_cache[user_id]) # return copy of cache (dict changes protection) + return deepcopy( + self._users_cache[user_id] + ) # return copy of cache (dict changes protection) - def user_following_gql(self, user_id: int, amount: int = 0) -> list: - """Return list of following users (without authorization) + def user_following_gql(self, user_id: int, amount: int = 0) -> List[UserShort]: + """ + Get user's followers information + + Parameters + ---------- + user_id: int + User id of an instagram account + amount: int, optional + Maximum number of media to return, default is 0 + + Returns + ------- + List[UserShort] + List of objects of User type """ user_id = int(user_id) end_cursor = None @@ -161,7 +292,7 @@ def user_following_gql(self, user_id: int, amount: int = 0) -> list: "id": user_id, "include_reel": True, "fetch_mutual": False, - "first": 24 + "first": 24, } while True: if end_cursor: @@ -171,12 +302,8 @@ def user_following_gql(self, user_id: int, amount: int = 0) -> list: ) if not data["user"] and not users: raise UserNotFound(user_id=user_id, **data) - page_info = json_value( - data, "user", "edge_follow", "page_info", default={} - ) - edges = json_value( - data, "user", "edge_follow", "edges", default=[] - ) + page_info = json_value(data, "user", "edge_follow", "page_info", default={}) + edges = json_value(data, "user", "edge_follow", "edges", default=[]) for edge in edges: users.append(extract_user_short(edge["node"])) end_cursor = page_info.get("end_cursor") @@ -189,13 +316,28 @@ def user_following_gql(self, user_id: int, amount: int = 0) -> list: users = users[:amount] return users - def user_following_v1(self, user_id: int, amount: int = 0) -> list: - """Return list of following users (with authorization) + def user_following_v1(self, user_id: int, amount: int = 0) -> List[UserShort]: + """ + Get user's followers information + + Parameters + ---------- + user_id: int + User id of an instagram account + amount: int, optional + Maximum number of media to return, default is 0 + + Returns + ------- + List[UserShort] + List of objects of User type """ user_id = int(user_id) max_id = "" users = [] while True: + if amount and len(users) >= amount: + break result = self.private_request( f"friendships/{user_id}/following/", params={ @@ -207,18 +349,36 @@ def user_following_v1(self, user_id: int, amount: int = 0) -> list: for user in result["users"]: users.append(extract_user_short(user)) max_id = result.get("next_max_id") - if not max_id or (amount and len(users) >= amount): + if not max_id: break if amount: users = users[:amount] return users - def user_following(self, user_id: int, use_cache: bool = True, amount: int = 0) -> Dict[int, User]: - """Return dict {user_id: user} of following users + def user_following( + self, user_id: int, use_cache: bool = True, amount: int = 0 + ) -> Dict[int, UserShort]: + """ + Get user's followers information + + Parameters + ---------- + user_id: int + User id of an instagram account + use_cache: bool, optional + Whether or not to use information from cache, default value is True + amount: int, optional + Maximum number of media to return, default is 0 + + Returns + ------- + Dict[int, UserShort] + Dict of user_id and User object """ user_id = int(user_id) if not use_cache or user_id not in self._users_following: # Temporary: Instagram Required Login for GQL request + # You can inject sessionid from private to public session # try: # users = self.user_following_gql(user_id, amount) # except Exception as e: @@ -226,18 +386,34 @@ def user_following(self, user_id: int, use_cache: bool = True, amount: int = 0) # self.logger.exception(e) # users = self.user_following_v1(user_id, amount) users = self.user_following_v1(user_id, amount) - self._users_following[user_id] = { - user.pk: user for user in users - } - return self._users_following[user_id] + self._users_following[user_id] = {user.pk: user for user in users} + following = self._users_following[user_id] + if amount and len(following) > amount: + following = dict(list(following.items())[:amount]) + return following - def user_followers_v1(self, user_id: int, amount: int = 0) -> list: - """Return list of followers users (with auth) + def user_followers_v1(self, user_id: int, amount: int = 0) -> List[UserShort]: + """ + Get user's followers information + + Parameters + ---------- + user_id: int + User id of an instagram account + amount: int, optional + Maximum number of media to return, default is 0 + + Returns + ------- + List[UserShort] + List of objects of User type """ user_id = int(user_id) max_id = "" users = [] while True: + if amount and len(users) >= amount: + break result = self.private_request( f"friendships/{user_id}/followers/", params={"max_id": max_id, "rank_token": self.rank_token}, @@ -245,23 +421,53 @@ def user_followers_v1(self, user_id: int, amount: int = 0) -> list: for user in result["users"]: users.append(extract_user_short(user)) max_id = result.get("next_max_id") - if not max_id or (amount and len(users) >= amount): + if not max_id: break + if amount: + users = users[:amount] return users - def user_followers(self, user_id: int, use_cache: bool = True, amount: int = 0) -> Dict[int, User]: - """Return dict {user_id: user} of followers users + def user_followers( + self, user_id: int, use_cache: bool = True, amount: int = 0 + ) -> Dict[int, UserShort]: + """ + Get user's followers + + Parameters + ---------- + user_id: int + User id of an instagram account + use_cache: bool, optional + Whether or not to use information from cache, default value is True + amount: int, optional + Maximum number of media to return, default is 0 + + Returns + ------- + Dict[int, UserShort] + Dict of user_id and User object """ user_id = int(user_id) if not use_cache or user_id not in self._users_followers: users = self.user_followers_v1(user_id, amount) - self._users_followers[user_id] = { - user.pk: user for user in users - } - return self._users_followers[user_id] + self._users_followers[user_id] = {user.pk: user for user in users} + followers = self._users_followers[user_id] + if amount and len(followers) > amount: + followers = dict(list(followers.items())[:amount]) + return followers def user_follow(self, user_id: int) -> bool: - """Follow user by user_id + """ + Follow a user + + Parameters + ---------- + user_id: int + + Returns + ------- + bool + A boolean value """ assert self.user_id, "Login required" user_id = int(user_id) @@ -275,7 +481,17 @@ def user_follow(self, user_id: int) -> bool: return result["friendship_status"]["following"] is True def user_unfollow(self, user_id: int) -> bool: - """Unfollow user by user_id + """ + Unfollow a user + + Parameters + ---------- + user_id: int + + Returns + ------- + bool + A boolean value """ assert self.user_id, "Login required" user_id = int(user_id) @@ -284,97 +500,3 @@ def user_unfollow(self, user_id: int) -> bool: if self.user_id in self._users_following: self._users_following[self.user_id].pop(user_id, None) return result["friendship_status"]["following"] is False - - def user_medias_gql(self, user_id: int, amount: int = 50, sleep: int = 2) -> List[Media]: - """ - !Use Client.user_medias instead! - Return list with media of instagram profile by user id using graphql - :rtype: list - :param user_id: Profile user id in instagram - :param amount: Count of medias for fetching (by default instagram return 50) - :param sleep: Timeout between requests - :return: List of medias for profile - """ - amount = int(amount) - user_id = int(user_id) - medias = [] - end_cursor = None - variables = { - "id": user_id, - "first": 50, # default amount - } - while True: - if end_cursor: - variables["after"] = end_cursor - data = self.public_graphql_request( - variables, query_hash="e7e2f4da4b02303f74f0841279e52d76" - ) - page_info = json_value( - data, "user", "edge_owner_to_timeline_media", "page_info", default={} - ) - edges = json_value( - data, "user", "edge_owner_to_timeline_media", "edges", default=[] - ) - for edge in edges: - medias.append(edge["node"]) - end_cursor = page_info.get("end_cursor") - if not page_info.get("has_next_page") or not end_cursor: - break - if len(medias) >= amount: - break - time.sleep(sleep) - return [extract_media_gql(media) for media in medias[:amount]] - - def user_medias_v1(self, user_id: int, amount: int = 18) -> List[Media]: - """Get all medias by user_id via Private API - :user_id: User ID - :amount: By default instagram return 18 items by each request - """ - amount = int(amount) - user_id = int(user_id) - medias = [] - next_max_id = "" - min_timestamp = None - while True: - try: - items = self.private_request( - f"feed/user/{user_id}/", - params={ - "max_id": next_max_id, - "min_timestamp": min_timestamp, - "rank_token": self.rank_token, - "ranked_content": "true", - }, - )["items"] - except Exception as e: - self.logger.exception(e) - break - medias.extend(items) - if not self.last_json.get("more_available"): - break - if len(medias) >= amount: - break - next_max_id = self.last_json.get("next_max_id", "") - return [extract_media_v1(media) for media in medias[:amount]] - - def user_medias(self, user_id: int, amount: int = 50) -> List[Media]: - """Get all medias by user_id - First, through the Public API, then through the Private API - """ - amount = int(amount) - user_id = int(user_id) - try: - try: - medias = self.user_medias_gql(user_id, amount) - except ClientLoginRequired as e: - if not self.inject_sessionid_to_public(): - raise e - medias = self.user_medias_gql(user_id, amount) # retry - except Exception as e: - if not isinstance(e, ClientError): - self.logger.exception(e) - # User may been private, attempt via Private API - # (You can check is_private, but there may be other reasons, - # it is better to try through a Private API) - medias = self.user_medias_v1(user_id, amount) - return medias diff --git a/instagrapi/mixins/video.py b/instagrapi/mixins/video.py index dad5c8c1..67539364 100644 --- a/instagrapi/mixins/video.py +++ b/instagrapi/mixins/video.py @@ -1,24 +1,44 @@ -import time import random -import requests +import time from pathlib import Path -from typing import List -from uuid import uuid4 +from typing import Dict, List from urllib.parse import urlparse +from uuid import uuid4 + +import requests from instagrapi import config +from instagrapi.exceptions import (VideoConfigureError, + VideoConfigureStoryError, VideoNotDownload, + VideoNotUpload) from instagrapi.extractors import extract_media_v1 -from instagrapi.exceptions import ( - VideoNotDownload, VideoNotUpload, VideoConfigureError, - VideoConfigureStoryError -) -from instagrapi.types import Usertag, Location, StoryMention, StoryLink, Media +from instagrapi.types import (Location, Media, Story, StoryHashtag, StoryLink, + StoryLocation, StoryMention, StorySticker, + Usertag) from instagrapi.utils import dumps class DownloadVideoMixin: + """ + Helpers for downloading video + """ def video_download(self, media_pk: int, folder: Path = "") -> Path: + """ + Download video using media pk + + Parameters + ---------- + media_pk: int + Unique Media ID + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working dir. + + Returns + ------- + Path + Path for the file downloaded + """ media = self.media_info(media_pk) assert media.media_type == 2, "Must been video" filename = "{username}_{media_pk}".format( @@ -26,10 +46,29 @@ def video_download(self, media_pk: int, folder: Path = "") -> Path: ) return self.video_download_by_url(media.video_url, filename, folder) - def video_download_by_url(self, url: str, filename: str = "", folder: Path = "") -> Path: - fname = urlparse(url).path.rsplit('/', 1)[1] - filename = "%s.%s" % (filename, fname.rsplit('.', 1)[ - 1]) if filename else fname + def video_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> Path: + """ + Download video using media pk + + Parameters + ---------- + url: str + URL for a media + filename: str, optional + Filename for the media + folder: Path, optional + Directory in which you want to download the album, default is "" and will download the files to working + directory + + Returns + ------- + Path + Path for the file downloaded + """ + fname = urlparse(url).path.rsplit("/", 1)[1] + filename = "%s.%s" % (filename, fname.rsplit(".", 1)[1]) if filename else fname path = Path(folder) / filename response = requests.get(url, stream=True) response.raise_for_status() @@ -37,8 +76,7 @@ def video_download_by_url(self, url: str, filename: str = "", folder: Path = "") file_length = len(response.content) if content_length != file_length: raise VideoNotDownload( - 'Broken file "%s" (Content-length=%s, but file length=%s)' - % (path, content_length, file_length) + f'Broken file "{path}" (Content-length={content_length}, but file length={file_length})' ) with open(path, "wb") as f: f.write(response.content) @@ -47,21 +85,33 @@ def video_download_by_url(self, url: str, filename: str = "", folder: Path = "") class UploadVideoMixin: + """ + Helpers for downloading video + """ def video_rupload( self, path: Path, thumbnail: Path = None, to_album: bool = False, - to_story: bool = False + to_story: bool = False, ) -> tuple: - """Upload video to Instagram + """ + Upload video to Instagram - :param path: Path to video file - :param thumbnail: Path to thumbnail for video. When None, then - thumbnail is generate automatically + Parameters + ---------- + path: Path + Path to the media + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + to_album: bool, optional + to_story: bool, optional - :return: Tuple (upload_id, width, height, duration) + Returns + ------- + tuple + (Upload ID for the media, width, height) """ assert isinstance(path, Path), f"Path must been Path, now {path} ({type(path)})" upload_id = str(int(time.time() * 1000)) @@ -87,7 +137,7 @@ def video_rupload( "extract_cover_frame": "1", "content_tags": "has-overlay", "for_album": "1", - **rupload_params + **rupload_params, } headers = { "Accept-Encoding": "gzip, deflate", @@ -97,21 +147,16 @@ def video_rupload( # "X_FB_VIDEO_WATERFALL_ID": "1594919079102", # VIDEO } if to_album: - headers = { - "Segment-Start-Offset": "0", - "Segment-Type": "3", - **headers - } + headers = {"Segment-Start-Offset": "0", "Segment-Type": "3", **headers} response = self.private.get( "https://{domain}/rupload_igvideo/{name}".format( domain=config.API_DOMAIN, name=upload_name - ), headers=headers + ), + headers=headers, ) self.request_log(response) if response.status_code != 200: - raise VideoNotUpload( - response.text, response=response, **self.last_json - ) + raise VideoNotUpload(response.text, response=response, **self.last_json) video_data = open(path, "rb").read() video_len = str(len(video_data)) headers = { @@ -121,19 +166,18 @@ def video_rupload( "Content-Type": "application/octet-stream", "Content-Length": video_len, "X-Entity-Type": "video/mp4", - **headers + **headers, } response = self.private.post( "https://{domain}/rupload_igvideo/{name}".format( domain=config.API_DOMAIN, name=upload_name ), - data=video_data, headers=headers + data=video_data, + headers=headers, ) self.request_log(response) if response.status_code != 200: - raise VideoNotUpload( - response.text, response=response, **self.last_json - ) + raise VideoNotUpload(response.text, response=response, **self.last_json) return upload_id, width, height, duration, Path(thumbnail) def video_upload( @@ -143,39 +187,47 @@ def video_upload( thumbnail: Path = None, usertags: List[Usertag] = [], location: Location = None, - links: List[StoryLink] = [], - configure_timeout: int = 3, - configure_handler=None, - configure_exception=None, - to_story: bool = False ) -> Media: - """Upload video to feed - - :param path: Path to video file - :param caption: Media description (String) - :param thumbnail: Path to thumbnail for video. When None, then - thumbnail is generate automatically - :param usertags: Mentioned users (List) - :param location: Location - :param links: URLs for Swipe Up (List of dicts) - :param configure_timeout: Timeout between attempt to configure media (set caption, etc) - :param configure_handler: Configure handler method - :param configure_exception: Configure exception class - - :return: Media + """ + Upload video and configure to feed + + Parameters + ---------- + path: Path + Path to the media + caption: str + Media caption + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None + + Returns + ------- + Media + An object of Media class """ path = Path(path) if thumbnail is not None: thumbnail = Path(thumbnail) upload_id, width, height, duration, thumbnail = self.video_rupload( - path, thumbnail, to_story=to_story + path, thumbnail, to_story=False ) for attempt in range(20): self.logger.debug(f"Attempt #{attempt} to configure Video: {path}") - time.sleep(configure_timeout) + time.sleep(3) try: - configured = (configure_handler or self.video_configure)( - upload_id, width, height, duration, thumbnail, caption, usertags, location, links + configured = self.video_configure( + upload_id, + width, + height, + duration, + thumbnail, + caption, + usertags, + location, ) except Exception as e: if "Transcode not finished yet" in str(e): @@ -191,8 +243,10 @@ def video_upload( media = configured.get("media") self.expose() return extract_media_v1(media) - raise (configure_exception or VideoConfigureError)( - response=self.last_response, **self.last_json) + raise VideoConfigureError( + response=self.last_response, + **self.last_json + ) def video_configure( self, @@ -204,26 +258,37 @@ def video_configure( caption: str, usertags: List[Usertag] = [], location: Location = None, - links: List[StoryLink] = [] - ) -> dict: - """Post Configure Video (send caption, thumbnail and more to Instagram) - - :param upload_id: Unique upload_id (String) - :param width: Width in px (Integer) - :param height: Height in px (Integer) - :param duration: Duration in seconds (Integer) - :param thumbnail: Path to thumbnail for video - :param caption: Media description (String) - :param usertags: Mentioned users (List) - :param location: Location - :param links: URLs for Swipe Up (List of dicts) - - :return: Media (Dict) + ) -> Dict: + """ + Post Configure Video (send caption, thumbnail and more to Instagram) + + Parameters + ---------- + upload_id: str + Unique upload_id + width: int + Width of the video in pixels + height: int + Height of the video in pixels + duration: int + Duration of the video in seconds + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + caption: str + Media caption + usertags: List[Usertag], optional + List of users to be tagged on this upload, default is empty list. + location: Location, optional + Location tag for this upload, default is None + + Returns + ------- + Dict + A dictionary of response from the call """ self.photo_rupload(Path(thumbnail), upload_id) usertags = [ - {"user_id": tag.user.pk, "position": [tag.x, tag.y]} - for tag in usertags + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags ] data = { "multi_sharing": "1", @@ -243,7 +308,9 @@ def video_configure( "device": self.device, "caption": caption, } - return self.private_request("media/configure/?video=1", self.with_default_data(data)) + return self.private_request( + "media/configure/?video=1", self.with_default_data(data) + ) def video_upload_to_story( self, @@ -251,28 +318,83 @@ def video_upload_to_story( caption: str, thumbnail: Path = None, mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], links: List[StoryLink] = [], - configure_timeout: int = 3 - ) -> Media: - """Upload video to feed + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + ) -> Story: + """ + Upload video as a story and configure it - :param path: Path to video file - :param caption: Media description (String) - :param thumbnail: Path to thumbnail for video. When None, then - thumbnail is generate automatically - :param mentions: Mentioned users (List) - :param links: URLs for Swipe Up (List of dicts) - :param configure_timeout: Timeout between attempt to configure media (set caption, etc) + Parameters + ---------- + path: Path + Path to the media + caption: str + Media caption + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + mentions: List[StoryMention], optional + List of mentions to be tagged on this upload, default is empty list. + locations: List[StoryLocation], optional + List of locations to be tagged on this upload, default is empty list. + links: List[StoryLink] + URLs for Swipe Up + hashtags: List[StoryHashtag], optional + List of hashtags to be tagged on this upload, default is empty list. + stickers: List[StorySticker], optional + List of stickers to be tagged on this upload, default is empty list. - :return: Media + Returns + ------- + Story + An object of Media class """ - return self.video_upload( - path, caption, thumbnail, mentions, - links=links, - configure_timeout=configure_timeout, - configure_handler=self.video_configure_to_story, - configure_exception=VideoConfigureStoryError, - to_story=True + path = Path(path) + if thumbnail is not None: + thumbnail = Path(thumbnail) + upload_id, width, height, duration, thumbnail = self.video_rupload( + path, thumbnail, to_story=True + ) + for attempt in range(20): + self.logger.debug(f"Attempt #{attempt} to configure Video: {path}") + time.sleep(3) + try: + configured = self.video_configure_to_story( + upload_id, + width, + height, + duration, + thumbnail, + caption, + mentions, + locations, + links, + hashtags, + stickers, + ) + except Exception as e: + if "Transcode not finished yet" in str(e): + """ + Response 202 status: + {"message": "Transcode not finished yet.", "status": "fail"} + """ + time.sleep(10) + continue + raise e + if configured: + media = configured.get("media") + self.expose() + return Story( + links=links, + mentions=mentions, + hashtags=hashtags, + locations=locations, + stickers=stickers, + **extract_media_v1(media).dict() + ) + raise VideoConfigureStoryError( + response=self.last_response, **self.last_json ) def video_configure_to_story( @@ -284,24 +406,49 @@ def video_configure_to_story( thumbnail: Path, caption: str, mentions: List[StoryMention] = [], - location: Location = None, - links: List[StoryLink] = [] - ) -> dict: - """Post Configure Video (send caption, thumbnail and more to Instagram) - - :param upload_id: Unique upload_id (String) - :param thumbnail: Path to thumbnail for video - :param width: Width in px (Integer) - :param height: Height in px (Integer) - :param duration: Duration in seconds (Integer) - :param caption: Media description (String) - :param mentions: Mentioned users (List) - :param location: Temporary unused - :param links: URLs for Swipe Up (List of dicts) - - :return: Media (Dict) + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Story Configure for Photo + + Parameters + ---------- + upload_id: str + Unique upload_id + width: int + Width of the video in pixels + height: int + Height of the video in pixels + duration: int + Duration of the video in seconds + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + caption: str + Media caption + mentions: List[StoryMention], optional + List of mentions to be tagged on this upload, default is empty list. + locations: List[StoryLocation], optional + List of locations to be tagged on this upload, default is empty list. + links: List[StoryLink] + URLs for Swipe Up + hashtags: List[StoryHashtag], optional + List of hashtags to be tagged on this upload, default is empty list. + stickers: List[StorySticker], optional + List of stickers to be tagged on this upload, default is empty list. + extra_data: List[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Dict + A dictionary of response from the call """ timestamp = int(time.time()) + story_sticker_ids = [] data = { "supported_capabilities_new": dumps(config.SUPPORTED_CAPABILITIES), "has_original_sound": "1", @@ -320,6 +467,7 @@ def video_configure_to_story( "client_shared_at": str(timestamp - 7), # 7 seconds ago "imported_taken_at": str(timestamp - 5 * 24 * 3600), # 5 days ago "date_time_original": time.strftime("%Y%m%dT%H%M%S.000Z", time.localtime()), + "story_sticker_ids": "", "media_folder": "Camera", "configure_mode": "1", "source_type": "4", @@ -327,7 +475,7 @@ def video_configure_to_story( "creation_surface": "camera", "caption": caption, "capture_type": "normal", - "rich_text_format_types": "[\"strong\"]", # default, typewriter + "rich_text_format_types": '["strong"]', # default, typewriter "upload_id": upload_id, # Facebook Sharing Part: # "xpost_surface": "auto_xpost", @@ -338,47 +486,129 @@ def video_configure_to_story( # "attempt_id": str(uuid4()), "device": self.device, "length": duration, - "implicit_location": { - "media_location": { - "lat": 0.0, - "lng": 0.0 - } - }, "clips": [{"length": duration, "source_type": "4"}], "extra": {"source_width": width, "source_height": height}, "audio_muted": False, - "poster_frame_index": 0 + "poster_frame_index": 0, } + data.update(extra_data) if links: links = [link.dict() for link in links] data["story_cta"] = dumps([{"links": links}]) + tap_models = [] + static_models = [] if mentions: reel_mentions = [] text_metadata = [] for mention in mentions: - reel_mentions.append({ - "x": mention.x, "y": mention.y, "z": 0, - "width": mention.width, "height": mention.height, "rotation": 0.0, - "type": "mention", "user_id": str(mention.user.pk), "is_sticker": False, "display_type": "mention_username" - }) - text_metadata.append({ - "font_size": 40.0, "scale": 1.2798771, - "width": 1017.50226, "height": 216.29922, - "x": mention.x, "y": mention.y, "rotation": 0.0 - }) + reel_mentions.append( + { + "x": mention.x, + "y": mention.y, + "z": 0, + "width": mention.width, + "height": mention.height, + "rotation": 0.0, + "type": "mention", + "user_id": str(mention.user.pk), + "is_sticker": False, + "display_type": "mention_username", + } + ) + text_metadata.append( + { + "font_size": 40.0, + "scale": 1.2798771, + "width": 1017.50226, + "height": 216.29922, + "x": mention.x, + "y": mention.y, + "rotation": 0.0, + } + ) data["text_metadata"] = dumps(text_metadata) - data["tap_models"] = data["reel_mentions"] = dumps(reel_mentions) - return self.private_request("media/configure_to_story/?video=1", self.with_default_data(data)) + data["reel_mentions"] = dumps(reel_mentions) + tap_models.extend(reel_mentions) + if hashtags: + story_sticker_ids.append("hashtag_sticker") + for mention in hashtags: + item = { + "x": mention.x, + "y": mention.y, + "z": 0, + "width": mention.width, + "height": mention.height, + "rotation": 0.0, + "type": "hashtag", + "tag_name": mention.hashtag.name, + "is_sticker": True, + "tap_state": 0, + "tap_state_str_id": "hashtag_sticker_gradient" + } + tap_models.append(item) + if locations: + story_sticker_ids.append("location_sticker") + for mention in locations: + mention.location = self.location_complete(mention.location) + item = { + "x": mention.x, + "y": mention.y, + "z": 0, + "width": mention.width, + "height": mention.height, + "rotation": 0.0, + "type": "location", + "location_id": str(mention.location.pk), + "is_sticker": True, + "tap_state": 0, + "tap_state_str_id": "location_sticker_vibrant" + } + tap_models.append(item) + if stickers: + for sticker in stickers: + str_id = sticker.id # "gif_Igjf05J559JWuef4N5" + static_models.append({ + "x": sticker.x, + "y": sticker.y, + "z": sticker.z, + "width": sticker.width, + "height": sticker.height, + "rotation": sticker.rotation, + "str_id": str_id, + "sticker_type": sticker.type, + }) + story_sticker_ids.append(str_id) + if sticker.type == "gif": + data["has_animated_sticker"] = "1" + data["tap_models"] = dumps(tap_models) + data["static_models"] = dumps(static_models) + data["story_sticker_ids"] = dumps(story_sticker_ids) + return self.private_request( + "media/configure_to_story/?video=1", self.with_default_data(data) + ) def analyze_video(path: Path, thumbnail: Path = None) -> tuple: - """Analyze video file + """ + Story Configure for Photo + + Parameters + ---------- + path: Path + Path to the media + thumbnail: str + Path to thumbnail for video. When None, then thumbnail is generate automatically + + Returns + ------- + Tuple + (width, height, duration, thumbnail) """ try: import moviepy.editor as mp except ImportError: - raise Exception('Please install moviepy>=1.0.3 and retry') + raise Exception("Please install moviepy>=1.0.3 and retry") print(f'Analizing video file "{path}"') video = mp.VideoFileClip(str(path)) diff --git a/instagrapi/story.py b/instagrapi/story.py index 95e40a77..f10292f1 100644 --- a/instagrapi/story.py +++ b/instagrapi/story.py @@ -2,26 +2,51 @@ from pathlib import Path from typing import List +from .types import StoryBuild, StoryMention + try: - from moviepy.editor import ( - TextClip, CompositeVideoClip, VideoFileClip, ImageClip - ) + from moviepy.editor import CompositeVideoClip, ImageClip, TextClip, VideoFileClip except ImportError: - raise Exception('Please install moviepy==1.0.3 and retry') + raise Exception("Please install moviepy==1.0.3 and retry") -from .types import StoryBuild, StoryMention +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=7.2.0") class StoryBuilder: + """ + Helpers for Story building + """ + width = 720 height = 1280 - def __init__(self, path: Path, caption: str = "", mentions: List[StoryMention] = [], bgpath: Path = None): - """Init params - :path: path to cource video or photo file - :caption: text caption for story - :mentions: list of StoryMention (see types.py) - :bgpath: path to background image (recommend jpg and 720x1280) + def __init__( + self, + path: Path, + caption: str = "", + mentions: List[StoryMention] = [], + bgpath: Path = None, + ): + """ + Initialization function + + Parameters + ---------- + path: Path + Path for a file + caption: str, optional + Media caption, default value is "" + mentions: List[StoryMention], optional + List of mentions to be tagged on this upload, default is empty list + bgpath: Path + Path for a background image, default value is "" + + Returns + ------- + Void """ self.path = Path(path) self.caption = caption @@ -29,16 +54,25 @@ def __init__(self, path: Path, caption: str = "", mentions: List[StoryMention] = self.bgpath = Path(bgpath) if bgpath else None def build_main(self, clip, max_duration: int = 0) -> StoryBuild: - """Build clip - :clip: Clip object (VideoFileClip, ImageClip) - :max_duration: Result duration in seconds - :return: StoryBuild (with new path and mentions) + """ + Build clip + + Parameters + ---------- + clip: (VideoFileClip, ImageClip) + An object of either VideoFileClip or ImageClip + max_duration: int, optional + Duration of the clip if a video clip, default value is 0 + + Returns + ------- + StoryBuild + An object of StoryBuild """ clips = [] # Background if self.bgpath: - assert self.bgpath.exists(),\ - f'Wrong path to background {self.bgpath}' + assert self.bgpath.exists(), f"Wrong path to background {self.bgpath}" background = ImageClip(str(self.bgpath)) clips.append(background) # Media clip @@ -50,18 +84,27 @@ def build_main(self, clip, max_duration: int = 0) -> StoryBuild: clips.append(media_clip) mention = self.mentions[0] if self.mentions else None # Text clip - caption = "@%s" % mention.user.username if mention.user.username else self.caption + caption = ( + "@%s" % mention.user.username if mention.user.username else self.caption + ) text_clip = TextClip( - caption, color="white", font="Arial", - kerning=-1, fontsize=100, method="label" + caption, + color="white", + font="Arial", + kerning=-1, + fontsize=100, + method="label", ) text_clip_left = (self.width - 600) / 2 text_clip_top = clip_top + clip.size[1] + 50 offset = (text_clip_top + text_clip.size[1]) - self.height if offset > 0: text_clip_top -= offset + 90 - text_clip = text_clip.resize(width=600).set_position( - (text_clip_left, text_clip_top)).fadein(3) + text_clip = ( + text_clip.resize(width=600) + .set_position((text_clip_left, text_clip_top)) + .fadein(3) + ) clips.append(text_clip) # Mentions mentions = [] @@ -74,23 +117,50 @@ def build_main(self, clip, max_duration: int = 0) -> StoryBuild: duration = max_duration if max_duration and clip.duration and max_duration > clip.duration: duration = clip.duration - destination = tempfile.mktemp('.mp4') - CompositeVideoClip(clips, size=(self.width, self.height))\ - .set_fps(24)\ - .set_duration(duration)\ - .write_videofile(destination, codec='libx264', audio=True, audio_codec='aac') + destination = tempfile.mktemp(".mp4") + CompositeVideoClip(clips, size=(self.width, self.height)).set_fps( + 24 + ).set_duration(duration).write_videofile( + destination, codec="libx264", audio=True, audio_codec="aac" + ) return StoryBuild(mentions=mentions, path=destination) def video(self, max_duration: int = 0): - """Build CompositeVideoClip from source video - :max_duration: Result duration in seconds + """ + Build CompositeVideoClip from source video + + Parameters + ---------- + max_duration: int, optional + Duration of the clip if a video clip, default value is 0 + + Returns + ------- + StoryBuild + An object of StoryBuild """ clip = VideoFileClip(str(self.path), has_mask=True) return self.build_main(clip, max_duration) def photo(self, max_duration: int = 0): - """Build CompositeVideoClip from source photo - :max_duration: Result duration in seconds """ - clip = ImageClip(str(self.path)).resize(width=self.width) + Build CompositeVideoClip from source video + + Parameters + ---------- + max_duration: int, optional + Duration of the clip if a video clip, default value is 0 + + Returns + ------- + StoryBuild + An object of StoryBuild + """ + + image_width, image_height = Image.open(self.path).size + + width_reduction_percent = (self.width / float(image_width)) + height_in_ratio = int((float(image_height) * float(width_reduction_percent))) + + clip = ImageClip(str(self.path)).resize(width=self.width, height=height_in_ratio) return self.build_main(clip, max_duration or 15) diff --git a/instagrapi/types.py b/instagrapi/types.py index 69907eb5..e2135475 100644 --- a/instagrapi/types.py +++ b/instagrapi/types.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import List, Optional -from pydantic import BaseModel, HttpUrl, FilePath + +from pydantic import BaseModel, FilePath, HttpUrl class Resource(BaseModel): @@ -20,7 +21,7 @@ class User(BaseModel): media_count: int follower_count: int following_count: int - biography: Optional[str] = '' + biography: Optional[str] = "" external_url: Optional[HttpUrl] is_business: bool @@ -32,7 +33,7 @@ class Account(BaseModel): is_private: bool profile_pic_url: HttpUrl is_verified: bool - biography: Optional[str] = '' + biography: Optional[str] = "" external_url: Optional[HttpUrl] is_business: bool birthday: Optional[str] @@ -44,10 +45,11 @@ class Account(BaseModel): class UserShort(BaseModel): pk: int username: Optional[str] - full_name: Optional[str] = '' + full_name: Optional[str] = "" profile_pic_url: Optional[HttpUrl] # is_private: bool # is_verified: bool + stories: List = [] class Usertag(BaseModel): @@ -59,7 +61,7 @@ class Usertag(BaseModel): class Location(BaseModel): pk: Optional[int] name: str - address: Optional[str] = '' + address: Optional[str] = "" lng: Optional[float] lat: Optional[float] external_id: Optional[int] @@ -75,7 +77,7 @@ class Media(BaseModel): code: str taken_at: datetime media_type: int - product_type: Optional[str] = '' # only for IGTV + product_type: Optional[str] = "" # igtv or feed thumbnail_url: Optional[HttpUrl] location: Optional[Location] = None user: UserShort @@ -87,7 +89,7 @@ class Media(BaseModel): video_url: Optional[HttpUrl] # for Video and IGTV view_count: Optional[int] = 0 # for Video and IGTV video_duration: Optional[float] = 0.0 # for Video and IGTV - title: Optional[str] = '' + title: Optional[str] = "" resources: List[Resource] = [] @@ -127,6 +129,13 @@ class Comment(BaseModel): like_count: Optional[int] +class Hashtag(BaseModel): + id: int + name: str + media_count: Optional[int] + profile_pic_url: Optional[HttpUrl] + + class StoryMention(BaseModel): user: UserShort x: Optional[float] @@ -135,6 +144,33 @@ class StoryMention(BaseModel): height: Optional[float] +class StoryHashtag(BaseModel): + hashtag: Hashtag + x: Optional[float] + y: Optional[float] + width: Optional[float] + height: Optional[float] + + +class StoryLocation(BaseModel): + location: Location + x: Optional[float] + y: Optional[float] + width: Optional[float] + height: Optional[float] + + +class StorySticker(BaseModel): + id: str + type: Optional[str] = 'gif' + x: float + y: float + z: Optional[int] = 1000005 + width: float + height: float + rotation: Optional[float] = 0.0 + + class StoryBuild(BaseModel): mentions: List[StoryMention] path: FilePath @@ -144,6 +180,24 @@ class StoryLink(BaseModel): webUri: HttpUrl +class Story(BaseModel): + pk: int + id: str + code: str + taken_at: datetime + media_type: int + product_type: Optional[str] = "" + thumbnail_url: Optional[HttpUrl] + user: UserShort + video_url: Optional[HttpUrl] # for Video and IGTV + video_duration: Optional[float] = 0.0 # for Video and IGTV + mentions: List[StoryMention] + links: List[StoryLink] + hashtags: List[StoryHashtag] + locations: List[StoryLocation] + stickers: List[StorySticker] + + class DirectMessage(BaseModel): id: int # e.g. 28597946203914980615241927545176064 user_id: Optional[int] @@ -195,17 +249,10 @@ def is_seen(self, user_id: int): :param user_id: You account user_id """ user_id = str(user_id) - own_timestamp = int(self.last_seen_at[user_id]['timestamp']) + own_timestamp = int(self.last_seen_at[user_id]["timestamp"]) timestamps = [ - (int(v['timestamp']) - own_timestamp) > 0 + (int(v["timestamp"]) - own_timestamp) > 0 for k, v in self.last_seen_at.items() if k != user_id ] return not any(timestamps) - - -class Hashtag(BaseModel): - id: int - name: str - media_count: Optional[int] - profile_pic_url: Optional[HttpUrl] diff --git a/instagrapi/utils.py b/instagrapi/utils.py index 0279f11b..f7890f5d 100644 --- a/instagrapi/utils.py +++ b/instagrapi/utils.py @@ -1,8 +1,8 @@ +import hashlib import hmac import json import random import string -import hashlib import urllib from . import config @@ -41,13 +41,14 @@ def decode(shortcode, alphabet=ENCODING_CHARS): def generate_signature(data): - """Generate signature of POST data for Private API - """ + """Generate signature of POST data for Private API""" body = hmac.new( config.IG_SIG_KEY.encode("utf-8"), data.encode("utf-8"), hashlib.sha256 ).hexdigest() return "signed_body={body}.{data}&ig_sig_key_version={sig_key}".format( - body=body, data=urllib.parse.quote(data), sig_key=config.SIG_KEY_VERSION, + body=body, + data=urllib.parse.quote(data), + sig_key=config.SIG_KEY_VERSION, ) @@ -68,7 +69,7 @@ def gen_password(size=10, symbols=False): chars = string.ascii_letters + string.digits if symbols: chars += string.punctuation - return ''.join(random.choice(chars) for _ in range(size)) + return "".join(random.choice(chars) for _ in range(size)) def gen_csrftoken(size=32): diff --git a/instagrapi/zones.py b/instagrapi/zones.py index f92ad60e..2e9c7b3f 100644 --- a/instagrapi/zones.py +++ b/instagrapi/zones.py @@ -1,4 +1,4 @@ -from datetime import tzinfo, timedelta +from datetime import timedelta, tzinfo class CET(tzinfo): From 5ab2508963c8cc488942f54322592e7c50c459bd Mon Sep 17 00:00:00 2001 From: jhd3197 Date: Sun, 31 Jan 2021 12:20:18 -0500 Subject: [PATCH 5/6] Update location.py adding location_search_pk and location_search_name --- instagrapi/mixins/location.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/instagrapi/mixins/location.py b/instagrapi/mixins/location.py index ffbfc112..f37f21f1 100644 --- a/instagrapi/mixins/location.py +++ b/instagrapi/mixins/location.py @@ -49,21 +49,18 @@ def location_search(self, lat: float, lng: float) -> List[Location]: return locations - def location_search_pk(self, pk: int) -> List[Location]: + def location_search_pk(self, pk: int) -> Location: """ - Get locations using lat and long + Get locations using pk Parameters ---------- - lat: float - Latitude you want to search for - lng: float - Longitude you want to search for - + pk: int + id Returns ------- - List[Location] - List of objects of Location + Location + An object of Location """ result = self.top_search(self.location_info(pk).name) @@ -75,6 +72,26 @@ def location_search_pk(self, pk: int) -> List[Location]: return location + def location_search_name(self, LocationName: str) -> List[Location]: + """ + Get locations using name + + Parameters + ---------- + LocationName: string + LocationName + Returns + ------- + List[Location] + List of objects of Location + """ + result = self.top_search(LocationName) + locations = [] + for places in result["places"]: + locations.append(extract_locationV2(places)) + + return locations + def location_complete(self, location: Location) -> Location: """ Smart complete of location From df025a1f54f9659908629e3f197030e882aecfeb Mon Sep 17 00:00:00 2001 From: jhd3197 Date: Mon, 1 Feb 2021 02:17:53 -0500 Subject: [PATCH 6/6] Adding Location Search --- instagrapi/extractors.py | 4 ++++ instagrapi/mixins/location.py | 44 +++++++++++++++++++++++++++++++++++ instagrapi/mixins/photo.py | 3 +++ instagrapi/mixins/private.py | 9 ++++--- instagrapi/mixins/public.py | 2 +- 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/instagrapi/extractors.py b/instagrapi/extractors.py index 8e595cac..6c5695d9 100644 --- a/instagrapi/extractors.py +++ b/instagrapi/extractors.py @@ -156,6 +156,10 @@ def extract_location(data): """Extract location info""" if not data: return None + try: + data=data["place"]["location"] + except: + pass data["pk"] = data.get("id", data.get("pk", None)) data["external_id"] = data.get("external_id", data.get("facebook_places_id")) data["external_id_source"] = data.get( diff --git a/instagrapi/mixins/location.py b/instagrapi/mixins/location.py index 4de8f692..84a2da3d 100644 --- a/instagrapi/mixins/location.py +++ b/instagrapi/mixins/location.py @@ -44,6 +44,50 @@ def location_search(self, lat: float, lng: float) -> List[Location]: locations.append(extract_location(venue)) return locations + def location_search_pk(self, location_pk: int) -> Location: + """ + Get locations using pk + + Parameters + ---------- + pk: int + id + Returns + ------- + Location + An object of Location + """ + result = self.top_search(self.location_info(location_pk).name) + + location = "{}" + for places in result["places"]: + single_location=extract_location(places) + if int(single_location.pk)==location_pk: + location=single_location + break + + return location + + def location_search_name(self, locationName) -> List[Location]: + """ + Get locations using locationName + + Parameters + ---------- + LocationName: string + LocationName + Returns + ------- + List[Location] + List of objects of Location + """ + result = self.top_search(locationName) + locations = [] + for places in result["places"]: + locations.append(extract_location(places)) + + return locations + def location_complete(self, location: Location) -> Location: """ Smart complete of location diff --git a/instagrapi/mixins/photo.py b/instagrapi/mixins/photo.py index 36173260..268af63c 100644 --- a/instagrapi/mixins/photo.py +++ b/instagrapi/mixins/photo.py @@ -163,6 +163,7 @@ def photo_upload( self, path: Path, caption: str, + storyUpload:bool=False, upload_id: str = "", usertags: List[Usertag] = [], location: Location = None, @@ -198,6 +199,8 @@ def photo_upload( ): media = self.last_json.get("media") self.expose() + if storyUpload: + self.photo_upload_to_story(path,caption) return extract_media_v1(media) raise PhotoConfigureError( response=self.last_response, **self.last_json diff --git a/instagrapi/mixins/private.py b/instagrapi/mixins/private.py index bbb7a5e3..8858d6d2 100644 --- a/instagrapi/mixins/private.py +++ b/instagrapi/mixins/private.py @@ -181,9 +181,12 @@ def _send_private_request( if self.user_id and login: raise Exception(f"User already login ({self.user_id})") try: - if not endpoint.startswith('/'): - endpoint = f"/v1/{endpoint}" - api_url = f"https://{config.API_DOMAIN}/api{endpoint}" + if "topsearch" in endpoint: + api_url= endpoint + else: + if not endpoint.startswith('/'): + endpoint = f"/v1/{endpoint}" + api_url = f"https://{config.API_DOMAIN}/api{endpoint}" if data: # POST # Client.direct_answer raw dict # data = json.dumps(data) diff --git a/instagrapi/mixins/public.py b/instagrapi/mixins/public.py index e88092eb..80765cf7 100644 --- a/instagrapi/mixins/public.py +++ b/instagrapi/mixins/public.py @@ -234,7 +234,7 @@ def top_search(self, query): "rank_token": 0.7763938004511706, "include_reel": "true", } - response = self.public_request(url, params=params, return_json=True) + response = self.private_request(url, params=params) return response