diff --git a/InstagramAPI.py b/InstagramAPI.py deleted file mode 100644 index 31af205..0000000 --- a/InstagramAPI.py +++ /dev/null @@ -1,923 +0,0 @@ -ο»Ώ#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import requests -import random -import json -import hashlib -import hmac -import urllib -import uuid -import time -import copy -import math -import sys -from datetime import datetime -import calendar -import os -from requests_toolbelt import MultipartEncoder - -# Turn off InsecureRequestWarning -from requests.packages.urllib3.exceptions import InsecureRequestWarning -requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - - -# The urllib library was split into other modules from Python 2 to Python 3 -if sys.version_info.major == 3: - import urllib.parse - - - -class InstagramAPI: - API_URL = 'https://i.instagram.com/api/v1/' - DEVICE_SETTINTS = {'manufacturer': 'Xiaomi', - 'model': 'HM 1SW', - 'android_version': 18, - 'android_release': '4.3'} - USER_AGENT = 'Instagram 10.26.0 Android ({android_version}/{android_release}; 320dpi; 720x1280; {manufacturer}; {model}; armani; qcom; en_US)'.format(**DEVICE_SETTINTS) - IG_SIG_KEY = '4f8732eb9ba7d1c8e8897a75d6474d4eb3f5279137431b2aafb71fafe2abe178' - EXPERIMENTS = 'ig_promote_reach_objective_fix_universe,ig_android_universe_video_production,ig_search_client_h1_2017_holdout,ig_android_live_follow_from_comments_universe,ig_android_carousel_non_square_creation,ig_android_live_analytics,ig_android_follow_all_dialog_confirmation_copy,ig_android_stories_server_coverframe,ig_android_video_captions_universe,ig_android_offline_location_feed,ig_android_direct_inbox_retry_seen_state,ig_android_ontact_invite_universe,ig_android_live_broadcast_blacklist,ig_android_insta_video_reconnect_viewers,ig_android_ad_async_ads_universe,ig_android_search_clear_layout_universe,ig_android_shopping_reporting,ig_android_stories_surface_universe,ig_android_verified_comments_universe,ig_android_preload_media_ahead_in_current_reel,android_instagram_prefetch_suggestions_universe,ig_android_reel_viewer_fetch_missing_reels_universe,ig_android_direct_search_share_sheet_universe,ig_android_business_promote_tooltip,ig_android_direct_blue_tab,ig_android_async_network_tweak_universe,ig_android_elevate_main_thread_priority_universe,ig_android_stories_gallery_nux,ig_android_instavideo_remove_nux_comments,ig_video_copyright_whitelist,ig_react_native_inline_insights_with_relay,ig_android_direct_thread_message_animation,ig_android_draw_rainbow_client_universe,ig_android_direct_link_style,ig_android_live_heart_enhancements_universe,ig_android_rtc_reshare,ig_android_preload_item_count_in_reel_viewer_buffer,ig_android_users_bootstrap_service,ig_android_auto_retry_post_mode,ig_android_shopping,ig_android_main_feed_seen_state_dont_send_info_on_tail_load,ig_fbns_preload_default,ig_android_gesture_dismiss_reel_viewer,ig_android_tool_tip,ig_android_ad_logger_funnel_logging_universe,ig_android_gallery_grid_column_count_universe,ig_android_business_new_ads_payment_universe,ig_android_direct_links,ig_android_audience_control,ig_android_live_encore_consumption_settings_universe,ig_perf_android_holdout,ig_android_cache_contact_import_list,ig_android_links_receivers,ig_android_ad_impression_backtest,ig_android_list_redesign,ig_android_stories_separate_overlay_creation,ig_android_stop_video_recording_fix_universe,ig_android_render_video_segmentation,ig_android_live_encore_reel_chaining_universe,ig_android_sync_on_background_enhanced_10_25,ig_android_immersive_viewer,ig_android_mqtt_skywalker,ig_fbns_push,ig_android_ad_watchmore_overlay_universe,ig_android_react_native_universe,ig_android_profile_tabs_redesign_universe,ig_android_live_consumption_abr,ig_android_story_viewer_social_context,ig_android_hide_post_in_feed,ig_android_video_loopcount_int,ig_android_enable_main_feed_reel_tray_preloading,ig_android_camera_upsell_dialog,ig_android_ad_watchbrowse_universe,ig_android_internal_research_settings,ig_android_search_people_tag_universe,ig_android_react_native_ota,ig_android_enable_concurrent_request,ig_android_react_native_stories_grid_view,ig_android_business_stories_inline_insights,ig_android_log_mediacodec_info,ig_android_direct_expiring_media_loading_errors,ig_video_use_sve_universe,ig_android_cold_start_feed_request,ig_android_enable_zero_rating,ig_android_reverse_audio,ig_android_branded_content_three_line_ui_universe,ig_android_live_encore_production_universe,ig_stories_music_sticker,ig_android_stories_teach_gallery_location,ig_android_http_stack_experiment_2017,ig_android_stories_device_tilt,ig_android_pending_request_search_bar,ig_android_fb_topsearch_sgp_fork_request,ig_android_seen_state_with_view_info,ig_android_animation_perf_reporter_timeout,ig_android_new_block_flow,ig_android_story_tray_title_play_all_v2,ig_android_direct_address_links,ig_android_stories_archive_universe,ig_android_save_collections_cover_photo,ig_android_live_webrtc_livewith_production,ig_android_sign_video_url,ig_android_stories_video_prefetch_kb,ig_android_stories_create_flow_favorites_tooltip,ig_android_live_stop_broadcast_on_404,ig_android_live_viewer_invite_universe,ig_android_promotion_feedback_channel,ig_android_render_iframe_interval,ig_android_accessibility_logging_universe,ig_android_camera_shortcut_universe,ig_android_use_one_cookie_store_per_user_override,ig_profile_holdout_2017_universe,ig_android_stories_server_brushes,ig_android_ad_media_url_logging_universe,ig_android_shopping_tag_nux_text_universe,ig_android_comments_single_reply_universe,ig_android_stories_video_loading_spinner_improvements,ig_android_collections_cache,ig_android_comment_api_spam_universe,ig_android_facebook_twitter_profile_photos,ig_android_shopping_tag_creation_universe,ig_story_camera_reverse_video_experiment,ig_android_direct_bump_selected_recipients,ig_android_ad_cta_haptic_feedback_universe,ig_android_vertical_share_sheet_experiment,ig_android_family_bridge_share,ig_android_search,ig_android_insta_video_consumption_titles,ig_android_stories_gallery_preview_button,ig_android_fb_auth_education,ig_android_camera_universe,ig_android_me_only_universe,ig_android_instavideo_audio_only_mode,ig_android_user_profile_chaining_icon,ig_android_live_video_reactions_consumption_universe,ig_android_stories_hashtag_text,ig_android_post_live_badge_universe,ig_android_swipe_fragment_container,ig_android_search_users_universe,ig_android_live_save_to_camera_roll_universe,ig_creation_growth_holdout,ig_android_sticker_region_tracking,ig_android_unified_inbox,ig_android_live_new_watch_time,ig_android_offline_main_feed_10_11,ig_import_biz_contact_to_page,ig_android_live_encore_consumption_universe,ig_android_experimental_filters,ig_android_search_client_matching_2,ig_android_react_native_inline_insights_v2,ig_android_business_conversion_value_prop_v2,ig_android_redirect_to_low_latency_universe,ig_android_ad_show_new_awr_universe,ig_family_bridges_holdout_universe,ig_android_background_explore_fetch,ig_android_following_follower_social_context,ig_android_video_keep_screen_on,ig_android_ad_leadgen_relay_modern,ig_android_profile_photo_as_media,ig_android_insta_video_consumption_infra,ig_android_ad_watchlead_universe,ig_android_direct_prefetch_direct_story_json,ig_android_shopping_react_native,ig_android_top_live_profile_pics_universe,ig_android_direct_phone_number_links,ig_android_stories_weblink_creation,ig_android_direct_search_new_thread_universe,ig_android_histogram_reporter,ig_android_direct_on_profile_universe,ig_android_network_cancellation,ig_android_background_reel_fetch,ig_android_react_native_insights,ig_android_insta_video_audio_encoder,ig_android_family_bridge_bookmarks,ig_android_data_usage_network_layer,ig_android_universal_instagram_deep_links,ig_android_dash_for_vod_universe,ig_android_modular_tab_discover_people_redesign,ig_android_mas_sticker_upsell_dialog_universe,ig_android_ad_add_per_event_counter_to_logging_event,ig_android_sticky_header_top_chrome_optimization,ig_android_rtl,ig_android_biz_conversion_page_pre_select,ig_android_promote_from_profile_button,ig_android_live_broadcaster_invite_universe,ig_android_share_spinner,ig_android_text_action,ig_android_own_reel_title_universe,ig_promotions_unit_in_insights_landing_page,ig_android_business_settings_header_univ,ig_android_save_longpress_tooltip,ig_android_constrain_image_size_universe,ig_android_business_new_graphql_endpoint_universe,ig_ranking_following,ig_android_stories_profile_camera_entry_point,ig_android_universe_reel_video_production,ig_android_power_metrics,ig_android_sfplt,ig_android_offline_hashtag_feed,ig_android_live_skin_smooth,ig_android_direct_inbox_search,ig_android_stories_posting_offline_ui,ig_android_sidecar_video_upload_universe,ig_android_promotion_manager_entry_point_universe,ig_android_direct_reply_audience_upgrade,ig_android_swipe_navigation_x_angle_universe,ig_android_offline_mode_holdout,ig_android_live_send_user_location,ig_android_direct_fetch_before_push_notif,ig_android_non_square_first,ig_android_insta_video_drawing,ig_android_swipeablefilters_universe,ig_android_live_notification_control_universe,ig_android_analytics_logger_running_background_universe,ig_android_save_all,ig_android_reel_viewer_data_buffer_size,ig_direct_quality_holdout_universe,ig_android_family_bridge_discover,ig_android_react_native_restart_after_error_universe,ig_android_startup_manager,ig_story_tray_peek_content_universe,ig_android_profile,ig_android_high_res_upload_2,ig_android_http_service_same_thread,ig_android_scroll_to_dismiss_keyboard,ig_android_remove_followers_universe,ig_android_skip_video_render,ig_android_story_timestamps,ig_android_live_viewer_comment_prompt_universe,ig_profile_holdout_universe,ig_android_react_native_insights_grid_view,ig_stories_selfie_sticker,ig_android_stories_reply_composer_redesign,ig_android_streamline_page_creation,ig_explore_netego,ig_android_ig4b_connect_fb_button_universe,ig_android_feed_util_rect_optimization,ig_android_rendering_controls,ig_android_os_version_blocking,ig_android_encoder_width_safe_multiple_16,ig_search_new_bootstrap_holdout_universe,ig_android_snippets_profile_nux,ig_android_e2e_optimization_universe,ig_android_comments_logging_universe,ig_shopping_insights,ig_android_save_collections,ig_android_live_see_fewer_videos_like_this_universe,ig_android_show_new_contact_import_dialog,ig_android_live_view_profile_from_comments_universe,ig_fbns_blocked,ig_formats_and_feedbacks_holdout_universe,ig_android_reduce_view_pager_buffer,ig_android_instavideo_periodic_notif,ig_search_user_auto_complete_cache_sync_ttl,ig_android_marauder_update_frequency,ig_android_suggest_password_reset_on_oneclick_login,ig_android_promotion_entry_from_ads_manager_universe,ig_android_live_special_codec_size_list,ig_android_enable_share_to_messenger,ig_android_background_main_feed_fetch,ig_android_live_video_reactions_creation_universe,ig_android_channels_home,ig_android_sidecar_gallery_universe,ig_android_upload_reliability_universe,ig_migrate_mediav2_universe,ig_android_insta_video_broadcaster_infra_perf,ig_android_business_conversion_social_context,android_ig_fbns_kill_switch,ig_android_live_webrtc_livewith_consumption,ig_android_destroy_swipe_fragment,ig_android_react_native_universe_kill_switch,ig_android_stories_book_universe,ig_android_all_videoplayback_persisting_sound,ig_android_draw_eraser_universe,ig_direct_search_new_bootstrap_holdout_universe,ig_android_cache_layer_bytes_threshold,ig_android_search_hash_tag_and_username_universe,ig_android_business_promotion,ig_android_direct_search_recipients_controller_universe,ig_android_ad_show_full_name_universe,ig_android_anrwatchdog,ig_android_qp_kill_switch,ig_android_2fac,ig_direct_bypass_group_size_limit_universe,ig_android_promote_simplified_flow,ig_android_share_to_whatsapp,ig_android_hide_bottom_nav_bar_on_discover_people,ig_fbns_dump_ids,ig_android_hands_free_before_reverse,ig_android_skywalker_live_event_start_end,ig_android_live_join_comment_ui_change,ig_android_direct_search_story_recipients_universe,ig_android_direct_full_size_gallery_upload,ig_android_ad_browser_gesture_control,ig_channel_server_experiments,ig_android_video_cover_frame_from_original_as_fallback,ig_android_ad_watchinstall_universe,ig_android_ad_viewability_logging_universe,ig_android_new_optic,ig_android_direct_visual_replies,ig_android_stories_search_reel_mentions_universe,ig_android_threaded_comments_universe,ig_android_mark_reel_seen_on_Swipe_forward,ig_internal_ui_for_lazy_loaded_modules_experiment,ig_fbns_shared,ig_android_capture_slowmo_mode,ig_android_live_viewers_list_search_bar,ig_android_video_single_surface,ig_android_offline_reel_feed,ig_android_video_download_logging,ig_android_last_edits,ig_android_exoplayer_4142,ig_android_post_live_viewer_count_privacy_universe,ig_android_activity_feed_click_state,ig_android_snippets_haptic_feedback,ig_android_gl_drawing_marks_after_undo_backing,ig_android_mark_seen_state_on_viewed_impression,ig_android_live_backgrounded_reminder_universe,ig_android_live_hide_viewer_nux_universe,ig_android_live_monotonic_pts,ig_android_search_top_search_surface_universe,ig_android_user_detail_endpoint,ig_android_location_media_count_exp_ig,ig_android_comment_tweaks_universe,ig_android_ad_watchmore_entry_point_universe,ig_android_top_live_notification_universe,ig_android_add_to_last_post,ig_save_insights,ig_android_live_enhanced_end_screen_universe,ig_android_ad_add_counter_to_logging_event,ig_android_blue_token_conversion_universe,ig_android_exoplayer_settings,ig_android_progressive_jpeg,ig_android_offline_story_stickers,ig_android_gqls_typing_indicator,ig_android_chaining_button_tooltip,ig_android_video_prefetch_for_connectivity_type,ig_android_use_exo_cache_for_progressive,ig_android_samsung_app_badging,ig_android_ad_holdout_watchandmore_universe,ig_android_offline_commenting,ig_direct_stories_recipient_picker_button,ig_insights_feedback_channel_universe,ig_android_insta_video_abr_resize,ig_android_insta_video_sound_always_on''' - SIG_KEY_VERSION = '4' - - # username # Instagram username - # password # Instagram password - # debug # Debug - # uuid # UUID - # device_id # Device ID - # username_id # Username ID - # token # _csrftoken - # isLoggedIn # Session status - # rank_token # Rank token - # IGDataPath # Data storage path - - def __init__(self, username, password, debug=False, IGDataPath=None): - m = hashlib.md5() - m.update(username.encode('utf-8') + password.encode('utf-8')) - self.device_id = self.generateDeviceId(m.hexdigest()) - self.setUser(username, password) - self.isLoggedIn = False - self.LastResponse = None - self.s = requests.Session() - - def setUser(self, username, password): - self.username = username - self.password = password - self.uuid = self.generateUUID(True) - - def setProxy(self, proxy=None): - """Set proxy for all requests:: - - Proxy format - user:password@ip:port""" - - if proxy is not None: - print('Set proxy!') - proxies = {'http': 'http://' + proxy, 'https': 'http://' + proxy} - self.s.proxies.update(proxies) - - def login(self, force=False): - if (not self.isLoggedIn or force): - if (self.SendRequest('si/fetch_headers/?challenge_type=signup&guid=' + self.generateUUID(False), None, True)): - - data = {'phone_id': self.generateUUID(True), - '_csrftoken': self.LastResponse.cookies['csrftoken'], - 'username': self.username, - 'guid': self.uuid, - 'device_id': self.device_id, - 'password': self.password, - 'login_attempt_count': '0'} - - if (self.SendRequest('accounts/login/', self.generateSignature(json.dumps(data)), True)): - self.isLoggedIn = True - self.username_id = self.LastJson["logged_in_user"]["pk"] - self.rank_token = "%s_%s" % (self.username_id, self.uuid) - self.token = self.LastResponse.cookies["csrftoken"] - self.syncFeatures() - self.autoCompleteUserList() - self.timelineFeed() - self.getv2Inbox() - self.getRecentActivity() - print("Login success!\n") - return True - else: - print("\nWhoops. Couldn't login. Check the above flags to know more.") - exit(1) - - def syncFeatures(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'id': self.username_id, - '_csrftoken': self.token, - 'experiments': self.EXPERIMENTS}) - return self.SendRequest('qe/sync/', self.generateSignature(data)) - - def autoCompleteUserList(self): - return self.SendRequest('friendships/autocomplete_user_list/') - - def timelineFeed(self): - return self.SendRequest('feed/timeline/') - - def megaphoneLog(self): - return self.SendRequest('megaphone/log/') - - def expose(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'id': self.username_id, - '_csrftoken': self.token, - 'experiment': 'ig_android_profile_contextual_feed'}) - return self.SendRequest('qe/expose/', self.generateSignature(data)) - - def uploadPhoto(self, photo, caption=None, upload_id=None, is_sidecar=None): - if upload_id is None: - upload_id = str(int(time.time() * 1000)) - data = {'upload_id': upload_id, - '_uuid': self.uuid, - '_csrftoken': self.token, - 'image_compression': '{"lib_name":"jt","lib_version":"1.3.0","quality":"87"}', - 'photo': ('pending_media_%s.jpg' % upload_id, open(photo, 'rb'), 'application/octet-stream', {'Content-Transfer-Encoding': 'binary'})} - if is_sidecar: - data['is_sidecar'] = '1' - m = MultipartEncoder(data, boundary=self.uuid) - self.s.headers.update({'X-IG-Capabilities': '3Q4=', - 'X-IG-Connection-Type': 'WIFI', - 'Cookie2': '$Version=1', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Content-type': m.content_type, - 'Connection': 'close', - 'User-Agent': self.USER_AGENT}) - response = self.s.post(self.API_URL + "upload/photo/", data=m.to_string()) - if response.status_code == 200: - if self.configure(upload_id, photo, caption): - self.expose() - return False - - def uploadVideo(self, video, thumbnail, caption=None, upload_id=None, is_sidecar=None): - if upload_id is None: - upload_id = str(int(time.time() * 1000)) - data = {'upload_id': upload_id, - '_csrftoken': self.token, - 'media_type': '2', - '_uuid': self.uuid} - if is_sidecar: - data['is_sidecar'] = '1' - m = MultipartEncoder(data, boundary=self.uuid) - self.s.headers.update({'X-IG-Capabilities': '3Q4=', - 'X-IG-Connection-Type': 'WIFI', - 'Host': 'i.instagram.com', - 'Cookie2': '$Version=1', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Content-type': m.content_type, - 'Connection': 'keep-alive', - 'User-Agent': self.USER_AGENT}) - response = self.s.post(self.API_URL + "upload/video/", data=m.to_string()) - if response.status_code == 200: - body = json.loads(response.text) - upload_url = body['video_upload_urls'][3]['url'] - upload_job = body['video_upload_urls'][3]['job'] - - videoData = open(video, 'rb').read() - # solve issue #85 TypeError: slice indices must be integers or None or have an __index__ method - request_size = int(math.floor(len(videoData) / 4)) - lastRequestExtra = (len(videoData) - (request_size * 3)) - - headers = copy.deepcopy(self.s.headers) - self.s.headers.update({'X-IG-Capabilities': '3Q4=', - 'X-IG-Connection-Type': 'WIFI', - 'Cookie2': '$Version=1', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Content-type': 'application/octet-stream', - 'Session-ID': upload_id, - 'Connection': 'keep-alive', - 'Content-Disposition': 'attachment; filename="video.mov"', - 'job': upload_job, - 'Host': 'upload.instagram.com', - 'User-Agent': self.USER_AGENT}) - for i in range(0, 4): - start = i * request_size - if i == 3: - end = i * request_size + lastRequestExtra - else: - end = (i + 1) * request_size - length = lastRequestExtra if i == 3 else request_size - content_range = "bytes {start}-{end}/{lenVideo}".format(start=start, end=(end - 1), - lenVideo=len(videoData)).encode('utf-8') - - self.s.headers.update({'Content-Length': str(end - start), 'Content-Range': content_range, }) - response = self.s.post(upload_url, data=videoData[start:start + length]) - self.s.headers = headers - - if response.status_code == 200: - if self.configureVideo(upload_id, video, thumbnail, caption): - self.expose() - return False - - def uploadAlbum(self, media, caption=None, upload_id=None): - if not media: - raise Exception("List of media to upload can't be empty.") - - if len(media) < 2 or len(media) > 10: - raise Exception('Instagram requires that albums contain 2-10 items. You tried to submit {}.'.format(len(media))) - - # Figure out the media file details for ALL media in the album. - # NOTE: We do this first, since it validates whether the media files are - # valid and lets us avoid wasting time uploading totally invalid albums! - for idx, item in enumerate(media): - if not item.get('file', '') or item.get('tipe', ''): - raise Exception('Media at index "{}" does not have the required "file" and "type" keys.'.format(idx)) - - # $itemInternalMetadata = new InternalMetadata(); - # If usertags are provided, verify that the entries are valid. - if item.get('usertags', []): - self.throwIfInvalidUsertags(item['usertags']) - - # Pre-process media details and throw if not allowed on Instagram. - if item.get('type', '') == 'photo': - # Determine the photo details. - # $itemInternalMetadata->setPhotoDetails(Constants::FEED_TIMELINE_ALBUM, $item['file']); - pass - - elif item.get('type', '') == 'video': - # Determine the video details. - # $itemInternalMetadata->setVideoDetails(Constants::FEED_TIMELINE_ALBUM, $item['file']); - pass - - else: - raise Exception('Unsupported album media type "{}".'.format(item['type'])) - - itemInternalMetadata = {} - item['internalMetadata'] = itemInternalMetadata - - # Perform all media file uploads. - for idx, item in enumerate(media): - itemInternalMetadata = item['internalMetadata'] - item_upload_id = self.generateUploadId() - if item.get('type', '') == 'photo': - self.uploadPhoto(item['file'], caption=caption, is_sidecar=True, upload_id=item_upload_id) - # $itemInternalMetadata->setPhotoUploadResponse($this->ig->internal->uploadPhotoData(Constants::FEED_TIMELINE_ALBUM, $itemInternalMetadata)); - - elif item.get('type', '') == 'video': - # Attempt to upload the video data. - self.uploadVideo(item['file'], item['thumbnail'], caption=caption, is_sidecar=True, upload_id=item_upload_id) - # $itemInternalMetadata = $this->ig->internal->uploadVideo(Constants::FEED_TIMELINE_ALBUM, $item['file'], $itemInternalMetadata); - # Attempt to upload the thumbnail, associated with our video's ID. - # $itemInternalMetadata->setPhotoUploadResponse($this->ig->internal->uploadPhotoData(Constants::FEED_TIMELINE_ALBUM, $itemInternalMetadata)); - pass - item['internalMetadata']['upload_id'] = item_upload_id - - albumInternalMetadata = {} - return self.configureTimelineAlbum(media, albumInternalMetadata, captionText=caption) - - def throwIfInvalidUsertags(self, usertags): - for user_position in usertags: - # Verify this usertag entry, ensuring that the entry is format - # ['position'=>[0.0,1.0],'user_id'=>'123'] and nothing else. - correct = True - if isinstance(user_position, dict): - position = user_position.get('position', None) - user_id = user_position.get('user_id', None) - - if isinstance(position, list) and len(position) == 2: - try: - x = float(position[0]) - y = float(position[1]) - if x < 0.0 or x > 1.0: - correct = False - if y < 0.0 or y > 1.0: - correct = False - except: - correct = False - try: - user_id = long(user_id) - if user_id < 0: - correct = False - except: - correct = False - if not correct: - raise Exception('Invalid user entry in usertags array.') - - def configureTimelineAlbum(self, media, albumInternalMetadata, captionText='', location=None): - endpoint = 'media/configure_sidecar/' - albumUploadId = self.generateUploadId() - - date = datetime.utcnow().isoformat() - childrenMetadata = [] - for item in media: - itemInternalMetadata = item['internalMetadata'] - uploadId = itemInternalMetadata.get('upload_id', self.generateUploadId()) - if item.get('type', '') == 'photo': - # Build this item's configuration. - photoConfig = {'date_time_original': date, - 'scene_type': 1, - 'disable_comments': False, - 'upload_id': uploadId, - 'source_type': 0, - 'scene_capture_type': 'standard', - 'date_time_digitized': date, - 'geotag_enabled': False, - 'camera_position': 'back', - 'edits': {'filter_strength': 1, - 'filter_name': 'IGNormalFilter'} - } - # This usertag per-file EXTERNAL metadata is only supported for PHOTOS! - if item.get('usertags', []): - # NOTE: These usertags were validated in Timeline::uploadAlbum. - photoConfig['usertags'] = json.dumps({'in': item['usertags']}) - - childrenMetadata.append(photoConfig) - if item.get('type', '') == 'video': - # Get all of the INTERNAL per-VIDEO metadata. - videoDetails = itemInternalMetadata.get('video_details', {}) - # Build this item's configuration. - videoConfig = {'length': videoDetails.get('duration', 1.0), - 'date_time_original': date, - 'scene_type': 1, - 'poster_frame_index': 0, - 'trim_type': 0, - 'disable_comments': False, - 'upload_id': uploadId, - 'source_type': 'library', - 'geotag_enabled': False, - 'edits': { - 'length': videoDetails.get('duration', 1.0), - 'cinema': 'unsupported', - 'original_length': videoDetails.get('duration', 1.0), - 'source_type': 'library', - 'start_time': 0, - 'camera_position': 'unknown', - 'trim_type': 0} - } - - childrenMetadata.append(videoConfig) - # Build the request... - data = {'_csrftoken': self.token, - '_uid': self.username_id, - '_uuid': self.uuid, - 'client_sidecar_id': albumUploadId, - 'caption': captionText, - 'children_metadata': childrenMetadata} - self.SendRequest(endpoint, self.generateSignature(json.dumps(data))) - response = self.LastResponse - if response.status_code == 200: - self.LastResponse = response - self.LastJson = json.loads(response.text) - return True - else: - print("Request return " + str(response.status_code) + " error!") - # for debugging - try: - self.LastResponse = response - self.LastJson = json.loads(response.text) - except ValueError: - return False - - def sendMessage(self, target_user, msgText): - target_user = '[[{}]]'.format(','.join([target_user])) - url = 'direct_v2/threads/broadcast/text/' - data = { - 'text': msgText, - '_uuid': self.uuid, - '_csrftoken': self.token, - 'recipient_users': target_user, - '_uid': self.username_id, - 'action': 'send_item', - 'client_context': self.generateUUID(True)} - return self.SendRequest(url, data) - - def direct_share(self, media_id, recipients, text=None): - if not isinstance(position, list): - recipients = [str(recipients)] - recipient_users = '"",""'.join(str(r) for r in recipients) - endpoint = 'direct_v2/threads/broadcast/media_share/?media_type=photo' - boundary = self.uuid - bodies = [ - { - 'type': 'form-data', - 'name': 'media_id', - 'data': media_id, - }, - { - 'type': 'form-data', - 'name': 'recipient_users', - 'data': '[["{}"]]'.format(recipient_users), - }, - { - 'type': 'form-data', - 'name': 'client_context', - 'data': self.uuid, - }, - { - 'type': 'form-data', - 'name': 'thread', - 'data': '["0"]', - }, - { - 'type': 'form-data', - 'name': 'text', - 'data': text or '', - }, - ] - data = self.buildBody(bodies, boundary) - self.s.headers.update({'User-Agent': self.USER_AGENT, - 'Proxy-Connection': 'keep-alive', - 'Connection': 'keep-alive', - 'Accept': '*/*', - 'Content-Type': 'multipart/form-data; boundary={}'.format(boundary), - 'Accept-Language': 'en-en'}) - # self.SendRequest(endpoint,post=data) #overwrites 'Content-type' header and boundary is missed - response = self.s.post(self.API_URL + endpoint, data=data) - - if response.status_code == 200: - self.LastResponse = response - self.LastJson = json.loads(response.text) - return True - else: - print("Request return " + str(response.status_code) + " error!") - # for debugging - try: - self.LastResponse = response - self.LastJson = json.loads(response.text) - except: - pass - return False - - def configureVideo(self, upload_id, video, thumbnail, caption=''): - clip = VideoFileClip(video) - self.uploadPhoto(photo=thumbnail, caption=caption, upload_id=upload_id) - data = json.dumps({ - 'upload_id': upload_id, - 'source_type': 3, - 'poster_frame_index': 0, - 'length': 0.00, - 'audio_muted': False, - 'filter_type': 0, - 'video_result': 'deprecated', - 'clips': { - 'length': clip.duration, - 'source_type': '3', - 'camera_position': 'back', - }, - 'extra': { - 'source_width': clip.size[0], - 'source_height': clip.size[1], - }, - 'device': self.DEVICE_SETTINTS, - '_csrftoken': self.token, - '_uuid': self.uuid, - '_uid': self.username_id, - 'caption': caption, - }) - return self.SendRequest('media/configure/?video=1', self.generateSignature(data)) - - def configure(self, upload_id, photo, caption=''): - (w, h) = getImageSize(photo) - data = json.dumps({'_csrftoken': self.token, - 'media_folder': 'Instagram', - 'source_type': 4, - '_uid': self.username_id, - '_uuid': self.uuid, - 'caption': caption, - 'upload_id': upload_id, - 'device': self.DEVICE_SETTINTS, - 'edits': { - 'crop_original_size': [w * 1.0, h * 1.0], - 'crop_center': [0.0, 0.0], - 'crop_zoom': 1.0 - }, - 'extra': { - 'source_width': w, - 'source_height': h - }}) - return self.SendRequest('media/configure/?', self.generateSignature(data)) - - def editMedia(self, mediaId, captionText=''): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'caption_text': captionText}) - return self.SendRequest('media/' + str(mediaId) + '/edit_media/', self.generateSignature(data)) - - def removeSelftag(self, mediaId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('media/' + str(mediaId) + '/remove/', self.generateSignature(data)) - - def mediaInfo(self, mediaId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'media_id': mediaId}) - return self.SendRequest('media/' + str(mediaId) + '/info/', self.generateSignature(data)) - - def deleteMedia(self, mediaId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'media_id': mediaId}) - return self.SendRequest('media/' + str(mediaId) + '/delete/', self.generateSignature(data)) - - def changePassword(self, newPassword): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'old_password': self.password, - 'new_password1': newPassword, - 'new_password2': newPassword}) - return self.SendRequest('accounts/change_password/', self.generateSignature(data)) - - def explore(self): - return self.SendRequest('discover/explore/') - - def comment(self, mediaId, commentText): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'comment_text': commentText}) - return self.SendRequest('media/' + str(mediaId) + '/comment/', self.generateSignature(data)) - - def deleteComment(self, mediaId, commentId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('media/' + str(mediaId) + '/comment/' + str(commentId) + '/delete/', self.generateSignature(data)) - - def changeProfilePicture(self, photo): - # TODO Instagram.php 705-775 - return False - - def removeProfilePicture(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('accounts/remove_profile_picture/', self.generateSignature(data)) - - def setPrivateAccount(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('accounts/set_private/', self.generateSignature(data)) - - def setPublicAccount(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('accounts/set_public/', self.generateSignature(data)) - - def getProfileData(self): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token}) - return self.SendRequest('accounts/current_user/?edit=true', self.generateSignature(data)) - - def editProfile(self, url, phone, first_name, biography, email, gender): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'external_url': url, - 'phone_number': phone, - 'username': self.username, - 'full_name': first_name, - 'biography': biography, - 'email': email, - 'gender': gender}) - return self.SendRequest('accounts/edit_profile/', self.generateSignature(data)) - - def getUsernameInfo(self, usernameId): - return self.SendRequest('users/' + str(usernameId) + '/info/') - - def getSelfUsernameInfo(self): - return self.getUsernameInfo(self.username_id) - - def getSelfSavedMedia(self): - return self.SendRequest('feed/saved') - - def getRecentActivity(self): - activity = self.SendRequest('news/inbox/?') - return activity - - def getFollowingRecentActivity(self): - activity = self.SendRequest('news/?') - return activity - - def getv2Inbox(self): - inbox = self.SendRequest('direct_v2/inbox/?') - return inbox - - def getv2Threads(self, thread, cursor=None): - endpoint = 'direct_v2/threads/{0}'.format(thread) - if cursor is not None: - endpoint += '?cursor={0}'.format(cursor) - inbox = self.SendRequest(endpoint) - return inbox - - def getUserTags(self, usernameId): - tags = self.SendRequest('usertags/' + str(usernameId) + '/feed/?rank_token=' + str(self.rank_token) + '&ranked_content=true&') - return tags - - def getSelfUserTags(self): - return self.getUserTags(self.username_id) - - def tagFeed(self, tag): - userFeed = self.SendRequest('feed/tag/' + str(tag) + '/?rank_token=' + str(self.rank_token) + '&ranked_content=true&') - return userFeed - - def getMediaLikers(self, mediaId): - likers = self.SendRequest('media/' + str(mediaId) + '/likers/?') - return likers - - def getGeoMedia(self, usernameId): - locations = self.SendRequest('maps/user/' + str(usernameId) + '/') - return locations - - def getSelfGeoMedia(self): - return self.getGeoMedia(self.username_id) - - def fbUserSearch(self, query): - query = self.SendRequest('fbsearch/topsearch/?context=blended&query=' + str(query) + '&rank_token=' + str(self.rank_token)) - return query - - def searchUsers(self, query): - query = self.SendRequest('users/search/?ig_sig_key_version=' + str(self.SIG_KEY_VERSION) + '&is_typeahead=true&query=' + str(query) + '&rank_token=' + str(self.rank_token)) - return query - - def searchUsername(self, usernameName): - query = self.SendRequest('users/' + str(usernameName) + '/usernameinfo/') - return query - - def syncFromAdressBook(self, contacts): - return self.SendRequest('address_book/link/?include=extra_display_name,thumbnails', "contacts=" + json.dumps(contacts)) - - def searchTags(self, query): - query = self.SendRequest('tags/search/?is_typeahead=true&q=' + str(query) + '&rank_token=' + str(self.rank_token)) - return query - - def getTimeline(self): - query = self.SendRequest('feed/timeline/?rank_token=' + str(self.rank_token) + '&ranked_content=true&') - return query - - def getUserFeed(self, usernameId, maxid='', minTimestamp=None): - query = self.SendRequest('feed/user/%s/?max_id=%s&min_timestamp=%s&rank_token=%s&ranked_content=true' - % (usernameId, maxid, minTimestamp, self.rank_token)) - return query - - def getSelfUserFeed(self, maxid='', minTimestamp=None): - return self.getUserFeed(self.username_id, maxid, minTimestamp) - - def getHashtagFeed(self, hashtagString, maxid=''): - return self.SendRequest('feed/tag/' + hashtagString + '/?max_id=' + str(maxid) + '&rank_token=' + self.rank_token + '&ranked_content=true&') - - def searchLocation(self, query): - locationFeed = self.SendRequest('fbsearch/places/?rank_token=' + str(self.rank_token) + '&query=' + str(query)) - return locationFeed - - def getLocationFeed(self, locationId, maxid=''): - return self.SendRequest('feed/location/' + str(locationId) + '/?max_id=' + maxid + '&rank_token=' + self.rank_token + '&ranked_content=true&') - - def getPopularFeed(self): - popularFeed = self.SendRequest('feed/popular/?people_teaser_supported=1&rank_token=' + str(self.rank_token) + '&ranked_content=true&') - return popularFeed - - def getUserFollowings(self, usernameId, maxid=''): - url = 'friendships/' + str(usernameId) + '/following/?' - query_string = {'ig_sig_key_version': self.SIG_KEY_VERSION, - 'rank_token': self.rank_token} - if maxid: - query_string['max_id'] = maxid - if sys.version_info.major == 3: - url += urllib.parse.urlencode(query_string) - else: - url += urllib.urlencode(query_string) - return self.SendRequest(url) - - def getSelfUsersFollowing(self): - return self.getUserFollowings(self.username_id) - - def getUserFollowers(self, usernameId, maxid=''): - if maxid == '': - return self.SendRequest('friendships/' + str(usernameId) + '/followers/?rank_token=' + self.rank_token) - else: - return self.SendRequest('friendships/' + str(usernameId) + '/followers/?rank_token=' + self.rank_token + '&max_id=' + str(maxid)) - - def getSelfUserFollowers(self): - return self.getUserFollowers(self.username_id) - - def like(self, mediaId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'media_id': mediaId}) - return self.SendRequest('media/' + str(mediaId) + '/like/', self.generateSignature(data)) - - def unlike(self, mediaId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - '_csrftoken': self.token, - 'media_id': mediaId}) - return self.SendRequest('media/' + str(mediaId) + '/unlike/', self.generateSignature(data)) - - def getMediaComments(self, mediaId, max_id=''): - return self.SendRequest('media/' + mediaId + '/comments/?max_id=' + max_id) - - def setNameAndPhone(self, name='', phone=''): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'first_name': name, - 'phone_number': phone, - '_csrftoken': self.token}) - return self.SendRequest('accounts/set_phone_and_name/', self.generateSignature(data)) - - def getDirectShare(self): - return self.SendRequest('direct_share/inbox/?') - - def backup(self): - # TODO Instagram.php 1470-1485 - return False - - def follow(self, userId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'user_id': userId, - '_csrftoken': self.token}) - return self.SendRequest('friendships/create/' + str(userId) + '/', self.generateSignature(data)) - - def unfollow(self, userId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'user_id': userId, - '_csrftoken': self.token}) - return self.SendRequest('friendships/destroy/' + str(userId) + '/', self.generateSignature(data)) - - def block(self, userId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'user_id': userId, - '_csrftoken': self.token}) - return self.SendRequest('friendships/block/' + str(userId) + '/', self.generateSignature(data)) - - def unblock(self, userId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'user_id': userId, - '_csrftoken': self.token}) - return self.SendRequest('friendships/unblock/' + str(userId) + '/', self.generateSignature(data)) - - def userFriendship(self, userId): - data = json.dumps({'_uuid': self.uuid, - '_uid': self.username_id, - 'user_id': userId, - '_csrftoken': self.token}) - return self.SendRequest('friendships/show/' + str(userId) + '/', self.generateSignature(data)) - - def getLikedMedia(self, maxid=''): - return self.SendRequest('feed/liked/?max_id=' + str(maxid)) - - def generateSignature(self, data, skip_quote=False): - if not skip_quote: - try: - parsedData = urllib.parse.quote(data) - except AttributeError: - parsedData = urllib.quote(data) - else: - parsedData = data - return 'ig_sig_key_version=' + self.SIG_KEY_VERSION + '&signed_body=' + hmac.new(self.IG_SIG_KEY.encode('utf-8'), data.encode('utf-8'), hashlib.sha256).hexdigest() + '.' + parsedData - - def generateDeviceId(self, seed): - volatile_seed = "12345" - m = hashlib.md5() - m.update(seed.encode('utf-8') + volatile_seed.encode('utf-8')) - return 'android-' + m.hexdigest()[:16] - - def generateUUID(self, type): - generated_uuid = str(uuid.uuid4()) - if (type): - return generated_uuid - else: - return generated_uuid.replace('-', '') - - def generateUploadId(self): - return str(calendar.timegm(datetime.utcnow().utctimetuple())) - - def buildBody(self, bodies, boundary): - body = u'' - for b in bodies: - body += u'--{boundary}\r\n'.format(boundary=boundary) - body += u'Content-Disposition: {b_type}; name="{b_name}"'.format(b_type=b['type'], b_name=b['name']) - _filename = b.get('filename', None) - _headers = b.get('headers', None) - if _filename: - _filename, ext = os.path.splitext(_filename) - _body += u'; filename="pending_media_{uid}.{ext}"'.format(uid=self.generateUploadId(), ext=ext) - if _headers and isinstance(_headers, list): - for h in _headers: - _body += u'\r\n{header}'.format(header=h) - body += u'\r\n\r\n{data}\r\n'.format(data=b['data']) - body += u'--{boundary}--'.format(boundary=boundary) - return body - - def SendRequest(self, endpoint, post=None, login=False): - verify = False # don't show request warning - - if (not self.isLoggedIn and not login): - raise Exception("Not logged in!\n") - - self.s.headers.update({'Connection': 'close', - 'Accept': '*/*', - 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Cookie2': '$Version=1', - 'Accept-Language': 'en-US', - 'User-Agent': self.USER_AGENT}) - - while True: - try: - if (post is not None): - response = self.s.post(self.API_URL + endpoint, data=post, verify=verify) - else: - response = self.s.get(self.API_URL + endpoint, verify=verify) - break - except Exception as e: - print('Except on SendRequest (wait 60 sec and resend): ' + str(e)) - time.sleep(60) - - if response.status_code == 200: - self.LastResponse = response - self.LastJson = json.loads(response.text) - return True - else: - print("Request return " + str(response.status_code) + " error!") - # for debugging - try: - self.LastResponse = response - self.LastJson = json.loads(response.text) - print("\nπŸ›‘ Title: ", self.LastJson["error_title"]) - print("πŸ›‘ Error Message: ", self.LastJson["message"]) - print("πŸ›‘ Status: ", self.LastJson["status"]) - print("πŸ›‘ Error Type: ", self.LastJson["error_type"]) - except: - pass - return False - - def getTotalFollowers(self, usernameId): - followers = [] - next_max_id = '' - while 1: - self.getUserFollowers(usernameId, next_max_id) - temp = self.LastJson - - for item in temp["users"]: - followers.append(item) - - if temp["big_list"] is False: - return followers - next_max_id = temp["next_max_id"] - - def getTotalFollowings(self, usernameId): - followers = [] - next_max_id = '' - while True: - self.getUserFollowings(usernameId, next_max_id) - temp = self.LastJson - - for item in temp["users"]: - followers.append(item) - - if temp["big_list"] is False: - return followers - next_max_id = temp["next_max_id"] - - def getTotalUserFeed(self, usernameId, minTimestamp=None): - user_feed = [] - next_max_id = '' - while True: - self.getUserFeed(usernameId, next_max_id, minTimestamp) - temp = self.LastJson - for item in temp["items"]: - user_feed.append(item) - if temp["more_available"] is False: - return user_feed - next_max_id = temp["next_max_id"] - - def getTotalSelfUserFeed(self, minTimestamp=None): - return self.getTotalUserFeed(self.username_id, minTimestamp) - - def getTotalSelfFollowers(self): - return self.getTotalFollowers(self.username_id) - - def getTotalSelfFollowings(self): - return self.getTotalFollowings(self.username_id) - - def getTotalLikedMedia(self, scan_rate=1): - next_id = '' - liked_items = [] - for x in range(0, scan_rate): - temp = self.getLikedMedia(next_id) - temp = self.LastJson - try: - next_id = temp["next_max_id"] - for item in temp["items"]: - liked_items.append(item) - except KeyError: - break - return liked_items \ No newline at end of file diff --git a/bomber.py b/bomber.py index 8d21d62..978fa87 100644 --- a/bomber.py +++ b/bomber.py @@ -1,98 +1,246 @@ -""" - β–„β–ˆβ–ˆβ–ˆβ–ˆ β–ˆ β–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–„ β–„β–ˆβ–ˆβ–ˆβ–“ β–„β–„β–„β–„ β–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆ β–„β–„β–„ β–ˆβ–ˆβ–“ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ - β–ˆβ–ˆβ–’ β–€β–ˆβ–’ β–ˆβ–ˆ β–“β–ˆβ–ˆβ–’β–“β–ˆβ–ˆβ–’β–€β–ˆβ–€ β–ˆβ–ˆβ–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„ β–“β–ˆβ–ˆ β–’ β–ˆβ–ˆβ–’β–’β–ˆβ–ˆβ–ˆβ–ˆβ–„ β–“β–ˆβ–ˆβ–’β–’β–ˆβ–ˆ β–’ β–“β–ˆ β–€ -β–’β–ˆβ–ˆβ–‘β–„β–„β–„β–‘β–“β–ˆβ–ˆ β–’β–ˆβ–ˆβ–‘β–“β–ˆβ–ˆ β–“β–ˆβ–ˆβ–‘β–’β–ˆβ–ˆβ–’ β–„β–ˆβ–ˆβ–“β–ˆβ–ˆ β–‘β–„β–ˆ β–’β–’β–ˆβ–ˆ β–€β–ˆβ–„ β–’β–ˆβ–ˆβ–’β–‘ β–“β–ˆβ–ˆβ–„ β–’β–ˆβ–ˆβ–ˆ -β–‘β–“β–ˆ β–ˆβ–ˆβ–“β–“β–“β–ˆ β–‘β–ˆβ–ˆβ–‘β–’β–ˆβ–ˆ β–’β–ˆβ–ˆ β–’β–ˆβ–ˆβ–‘β–ˆβ–€ β–’β–ˆβ–ˆβ–€β–€β–ˆβ–„ β–‘β–ˆβ–ˆβ–„β–„β–„β–„β–ˆβ–ˆ β–‘β–ˆβ–ˆβ–‘ β–’ β–ˆβ–ˆβ–’β–’β–“β–ˆ β–„ -β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–€β–’β–’β–’β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“ β–’β–ˆβ–ˆβ–’ β–‘β–ˆβ–ˆβ–’β–‘β–“β–ˆ β–€β–ˆβ–“β–‘β–ˆβ–ˆβ–“ β–’β–ˆβ–ˆβ–’ β–“β–ˆ β–“β–ˆβ–ˆβ–’β–‘β–ˆβ–ˆβ–‘β–’β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–’β–’β–‘β–’β–ˆβ–ˆβ–ˆβ–ˆβ–’ - β–‘β–’ β–’ β–‘β–’β–“β–’ β–’ β–’ β–‘ β–’β–‘ β–‘ β–‘β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–€β–’β–‘ β–’β–“ β–‘β–’β–“β–‘ β–’β–’ β–“β–’β–ˆβ–‘β–‘β–“ β–’ β–’β–“β–’ β–’ β–‘β–‘β–‘ β–’β–‘ β–‘ - β–‘ β–‘ β–‘β–‘β–’β–‘ β–‘ β–‘ β–‘ β–‘ β–‘β–’β–‘β–’ β–‘ β–‘β–’ β–‘ β–’β–‘ β–’ β–’β–’ β–‘ β–’ β–‘β–‘ β–‘β–’ β–‘ β–‘ β–‘ β–‘ β–‘ -β–‘ β–‘ β–‘ β–‘β–‘β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘β–‘ β–‘ β–‘ β–’ β–’ β–‘β–‘ β–‘ β–‘ β–‘ - β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ - β–‘ -""" import sys +import instagrapi +import json +import random +import os +import signal +from getpass import getpass -try: - from InstagramAPI import InstagramAPI - import requests - import json - import random - import getpass +config = [] +cl = instagrapi.Client() - while True: - nostop = 0 - while True: - accounts = input("Put your Instagram accounts list here (if there is no file just press ENTER): ") - if not accounts: - username = input("Put your IG Username then press ENTER: ") - try: - password = getpass.getpass(prompt="Put your IG Password then press ENTER: ") - except Exception as error: - print("Error:", error) +def login(): + while True: + isAccount = input("Login | Do you have an account list ? (y/N): ") + if not isAccount or isAccount != "y": + while True: + if openJson()['sessionId'] == "": + while True: + username = input("Login | Username: ") + try: + password = getpass(prompt="Login | Password: ") + except Exception as error: + print("Login | Error:", error) + try: + cl.login(username, password) + print('Login | Logged in as {}'.format(cl.username)) + break + except: + print('Login | Bad password') + writeJson('sessionId', cl.sessionid) + print('Login | sessionId saved') + break else: - print("Got Password. Attempting to login.") - api = InstagramAPI(username, password) - api.login() - break - - else: + try: + cl.login_by_sessionid(openJson()['sessionId']) + print('Login | Logged in by sessionId') + break + except: + while True: + username = input("Login | Username: ") + try: + password = getpass(prompt="Login | Password: ") + except Exception as error: + print("Login | Error:", error) + try: + cl.login(username, password) + print('Login | Logged in as {}'.format(cl.username)) + break + except: + print('Login | Bad password') + writeJson('sessionId', cl.sessionid) + print('Login | sessionId saved') + break + break + else: + while True: + accounts = input("Login | Path: ") try: line = random.choice(open(accounts).readlines()) username, password = line.split(':') - print("Username found: ", username) - print("Password found: ", password) - api = InstagramAPI(username, password) - api.login() + print("Login | Username found: ", username) + cl.login(username, password) break - except: - print("Wrong file") + print("Login | Wrong file") + break + +def start(): + clear() + main() + + +def clear(): + os.system('cls' if os.name == 'nt' else 'clear') + print(header) + + +def bomber(): + clear() + login() + + while True: while True: - print("Would you prefer enter the user ID or the username (bΓͺta)") - if input("UserID = i, Username = o: ") == "o": - user = input("Enter the victim's IG Username: ") - try: - response = requests.get("https://www.instagram.com/" + user + "/?__a=1") - respJSON = response.json() - user_id = str(respJSON['graphql'].get("user").get("id")) - except: - print("Unknown victim's username") - print("Either Instagram API is not corrected or you entered a false username") - exit(0) + modeChoice = input('| Use grabbed users ? (y/N): ') + user = "" + if modeChoice == "y": + user_id = openJson()['userList'] + break else: while True: - user_id = input("Enter the victim's IG UserID (or press i to get more info about UserID): ") - - if isinstance(int(user_id), int) == True: + user = input("| Victim username: ") + try: + user_id = cl.user_info_by_username(user).pk break - elif user_id == 'i': - print("""To found a IG UserID you have to search https://www.instagram.com/{USERNAME}/?__a=1' and to go to graphql=>user=>id. -The correct id is named id, not fbid.""") - input("Press enter to continue") - else: - print("This IG UserID is unknown.") + except: + print('Bad username') + break - while True: - nostop = 0 - message = input("Put the message you want the software send and press ENTER: ") + while True: + message = input("| Message: ") + times = 1 + if modeChoice != "y": while True: try: - times = int(input("How many messages do you want to send? ")) + times = int(input("| How many ?: ")) break except: print("Wrong number") - proxylist = input("Proxy list (TXT): (If you don't have proxy list press ENTER): ") - if accounts: - proxy = random.choice(open(proxylist).readlines()) - api.setProxy(proxy) - while times > nostop: - nostop = nostop + 1 - api.sendMessage(user_id, message) - print(nostop, ">> Sent to", user_id, ": ", message) - -except: - sys.exit( - '\nA critical error happened. Please make sure that you executed all commands properly and relaunch the program.') + # proxylist = input("Proxy list (TXT): (If you don't have proxy list press ENTER): ") + # if accounts: + # proxy = random.choice(open(proxylist).readlines()) + # api.setProxy(proxy) + + try: + noStop = 0 + if modeChoice == "y": + for oneUser in user_id: + noStop += 1 + cl.direct_send(message, [oneUser]) + print("({}) {} > {}: {}".format(noStop, cl.username, oneUser, message)) + break + else: + while times > noStop: + noStop += 1 + cl.direct_send(message, [user_id]) + print("({}) {} > {}: {}".format(noStop, cl.username, user, message)) + break + + except: + start() + + +def grabUser(): + clear() + login() + print(grabbedMenu) + + grabChoice = int(input("| ")) + while True: + if grabChoice == 3: + start() + break + else: + while True: + user = input("| Grabbed username: ") + try: + user_id = cl.user_info_by_username(user).pk + + if grabChoice == 1: + grabFollowers = cl.user_followers(user_id) + listFollowers = list(grabFollowers) + intFollowers = list(map(int, listFollowers)) + writeJson('userList', intFollowers) + print('{} followers of {} grabbed'.format(str(len(list(grabFollowers))), user)) + if grabChoice == 2: + grabFollowing = cl.user_following(user_id) + listFollowing = list(grabFollowing) + intFollowing = list(map(int, listFollowing)) + writeJson('userList', intFollowing) + print('{} followers of {} grabbed'.format(str(len(list(grabFollowing))), user)) + + input('Continue...') + clear() + print(grabbedMenu) + grabChoice = int(input("| ")) + break + except instagrapi.exceptions.UserNotFound: + print('Bad username') + + break + + +def usToArray(us): + return str(len(us)) + + +def update(): + os.system("git pull") + + +def main(): + print(mainMenu) + while True: + menuCursor = input("| ") + try: + if int(menuCursor) == 1: + bomber() + elif int(menuCursor) == 2: + grabUser() + elif int(menuCursor) == 3: + update() + elif int(menuCursor) == 4: + sys.exit() + else: + print("Wrong input") + except ValueError: + print("Wrong input") + + +def writeJson(key, value): + with open('config.json', 'r+') as jsonFile: + data = json.load(jsonFile) + data[key] = value + jsonFile.seek(0) + json.dump(data, jsonFile, indent=4) + jsonFile.truncate() + jsonFile.close() + + +def openJson(): + with open('config.json', 'r') as jsonFile: + config = json.load(jsonFile) + jsonFile.close() + return config + + +header = """ +β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— +β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— +β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• +β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— +β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ•β• β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ +β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• +https://github.com/Gumbraise/instagram-bomber ╬ Ver. {} +""".format(openJson()['version']) + +mainMenu = """ + 1 | Instagram Bomber + 2 | Get User List + 3 | Update + 4 | Exit +""" + +grabbedMenu = """ + 1 | Grab Followers + 2 | Grab Following + 3 | Back +""" + +print("Launching Instagram-Bomber...") +update() +clear() +main() diff --git a/config.json b/config.json new file mode 100644 index 0000000..dfd1284 --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "version": "2.0", + "sessionId": "", + "userList": [] +} \ No newline at end of file diff --git a/instagrapi/__init__.py b/instagrapi/__init__.py new file mode 100644 index 0000000..3259d93 --- /dev/null +++ b/instagrapi/__init__.py @@ -0,0 +1,103 @@ +import logging +from urllib.parse import urlparse + +import requests +from requests.packages.urllib3.exceptions import InsecureRequestWarning + +from instagrapi.mixins.account import AccountMixin +from instagrapi.mixins.album import DownloadAlbumMixin, UploadAlbumMixin +from instagrapi.mixins.auth import LoginMixin +from instagrapi.mixins.bloks import BloksMixin +from instagrapi.mixins.challenge import ChallengeResolveMixin +from instagrapi.mixins.clip import DownloadClipMixin, UploadClipMixin +from instagrapi.mixins.collection import CollectionMixin +from instagrapi.mixins.comment import CommentMixin +from instagrapi.mixins.direct import DirectMixin +from instagrapi.mixins.fbsearch import FbSearchMixin +from instagrapi.mixins.hashtag import HashtagMixin +from instagrapi.mixins.highlight import HighlightMixin +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.notification import NotificationMixin +from instagrapi.mixins.password import PasswordMixin +from instagrapi.mixins.photo import DownloadPhotoMixin, UploadPhotoMixin +from instagrapi.mixins.private import PrivateRequestMixin +from instagrapi.mixins.public import ( + ProfilePublicMixin, + PublicRequestMixin, + TopSearchesPublicMixin, +) +from instagrapi.mixins.share import ShareMixin +from instagrapi.mixins.story import StoryMixin +from instagrapi.mixins.timeline import ReelsMixin +from instagrapi.mixins.totp import TOTPMixin +from instagrapi.mixins.user import UserMixin +from instagrapi.mixins.video import DownloadVideoMixin, UploadVideoMixin + +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + + +class Client( + PublicRequestMixin, + ChallengeResolveMixin, + PrivateRequestMixin, + TopSearchesPublicMixin, + ProfilePublicMixin, + LoginMixin, + ShareMixin, + FbSearchMixin, + HighlightMixin, + DownloadPhotoMixin, + UploadPhotoMixin, + DownloadVideoMixin, + UploadVideoMixin, + DownloadAlbumMixin, + NotificationMixin, + UploadAlbumMixin, + DownloadIGTVMixin, + UploadIGTVMixin, + MediaMixin, + UserMixin, + InsightsMixin, + CollectionMixin, + AccountMixin, + DirectMixin, + LocationMixin, + HashtagMixin, + CommentMixin, + StoryMixin, + PasswordMixin, + DownloadClipMixin, + UploadClipMixin, + ReelsMixin, + BloksMixin, + TOTPMixin, +): + proxy = None + logger = logging.getLogger("instagrapi") + + def __init__(self, settings: dict = {}, proxy: str = None, **kwargs): + super().__init__(**kwargs) + self.settings = settings + self.set_proxy(proxy) + self.init() + + def set_proxy(self, dsn: str): + if 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 "", + href=self.proxy, + ) + self.public.proxies = self.private.proxies = { + "http": proxy_href, + "https": proxy_href, + } + return True + self.public.proxies = self.private.proxies = {} + return False diff --git a/instagrapi/config.py b/instagrapi/config.py new file mode 100644 index 0000000..0603f57 --- /dev/null +++ b/instagrapi/config.py @@ -0,0 +1,42 @@ +API_DOMAIN = "i.instagram.com" + +# Instagram 134.0.0.26.121 +# Android (26/8.0.0; +# 480dpi; 1080x1920; Xiaomi; +# MI 5s; capricorn; qcom; en_US; 205280538) +USER_AGENT_BASE = ( + "Instagram {app_version} " + "Android ({android_version}/{android_release}; " + "{dpi}; {resolution}; {manufacturer}; " + "{model}; {device}; {cpu}; {locale}; {version_code})" +) +# Instagram 76.0.0.15.395 (iPhone9,2; iOS 10_0_2; en_US; en-US; scale=2.61; 1080x1920) AppleWebKit/420+ +# Instagram 208.0.0.32.135 (iPhone; iOS 14_7_1; en_US; en-US; scale=2.61; 1080x1920) AppleWebKit/605.1.15 + +SOFTWARE = "{model}-user+{android_release}+OPR1.170623.032+V10.2.3.0.OAGMIXM+release-keys" + +# QUERY_HASH_PROFILE = 'c9100bf9110dd6361671f113dd02e7d6' +# QUERY_HASH_MEDIAS = '42323d64886122307be10013ad2dcc44' +# QUERY_HASH_IGTVS = 'bc78b344a68ed16dd5d7f264681c4c76' +# QUERY_HASH_STORIES = '5ec1d322b38839230f8e256e1f638d5f' +# QUERY_HASH_HIGHLIGHTS_FOLDERS = 'ad99dd9d3646cc3c0dda65debcd266a7' +# QUERY_HASH_HIGHLIGHTS_STORIES = '5ec1d322b38839230f8e256e1f638d5f' +# QUERY_HASH_FOLLOWERS = 'c76146de99bb02f6415203be841dd25a' +# QUERY_HASH_FOLLOWINGS = 'd04b0a864b4b54837c0d870b0e77e076' +# QUERY_HASH_HASHTAG = '174a5243287c5f3a7de741089750ab3b' +# QUERY_HASH_COMMENTS = '33ba35852cb50da46f5b5e889df7d159' +# QUERY_HASH_TAGGED_MEDIAS = 'be13233562af2d229b008d2976b998b5' + +LOGIN_EXPERIMENTS = "ig_android_reg_nux_headers_cleanup_universe,ig_android_device_detection_info_upload,ig_android_nux_add_email_device,ig_android_gmail_oauth_in_reg,ig_android_device_info_foreground_reporting,ig_android_device_verification_fb_signup,ig_android_direct_main_tab_universe_v2,ig_android_passwordless_account_password_creation_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_quickcapture_keep_screen_on,ig_android_device_based_country_verification,ig_android_login_identifier_fuzzy_match,ig_android_reg_modularization_universe,ig_android_security_intent_switchoff,ig_android_device_verification_separate_endpoint,ig_android_suma_landing_page,ig_android_sim_info_upload,ig_android_smartlock_hints_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_retry_create_account_universe,ig_android_caption_typeahead_fix_on_o_universe" + +SUPPORTED_CAPABILITIES = [ + { + "name": "SUPPORTED_SDK_VERSIONS", + "value": "108.0,109.0,110.0,111.0,112.0,113.0,114.0,115.0,116.0,117.0,118.0,119.0,120.0,121.0,122.0,123.0,124.0,125.0,126.0,127.0" + }, + {"name": "FACE_TRACKER_VERSION","value": "14"}, + {"name": "segmentation","value": "segmentation_enabled"}, + {"name": "COMPRESSION","value": "ETC2_COMPRESSION"}, + {"name": "world_tracker","value": "world_tracker_enabled"}, + {"name": "gyroscope","value": "gyroscope_enabled"} +] diff --git a/instagrapi/exceptions.py b/instagrapi/exceptions.py new file mode 100644 index 0000000..abfecfd --- /dev/null +++ b/instagrapi/exceptions.py @@ -0,0 +1,267 @@ +class ClientError(Exception): + response = None + code = None + message = "" + + def __init__(self, *args, **kwargs): + args = list(args) + if len(args) > 0: + self.message = str(args.pop(0)) + for key in list(kwargs.keys()): + setattr(self, key, kwargs.pop(key)) + if not self.message: + self.message = "{title} ({body})".format( + title=getattr(self, 'reason', 'Unknown'), + body=getattr(self, 'error_type', vars(self)) + ) + super().__init__(self.message, *args, **kwargs) + if self.response: + self.code = self.response.status_code + + +class GenericRequestError(ClientError): + """Sorry, there was a problem with your request""" + + +class ClientGraphqlError(ClientError): + """Raised due to graphql issues""" + + +class ClientJSONDecodeError(ClientError): + """Raised due to json decoding issues""" + + +class ClientConnectionError(ClientError): + """Raised due to network connectivity-related issues""" + + +class ClientBadRequestError(ClientError): + """Raised due to a HTTP 400 response""" + + +class ClientForbiddenError(ClientError): + """Raised due to a HTTP 403 response""" + + +class ClientNotFoundError(ClientError): + """Raised due to a HTTP 404 response""" + + +class ClientThrottledError(ClientError): + """Raised due to a HTTP 429 response""" + + +class ClientRequestTimeout(ClientError): + """Raised due to a HTTP 408 response""" + + +class ClientIncompleteReadError(ClientError): + """Raised due to incomplete read HTTP response""" + + +class ClientLoginRequired(ClientError): + """Instagram redirect to https://www.instagram.com/accounts/login/""" + + +class ReloginAttemptExceeded(ClientError): + pass + + +class PrivateError(ClientError): + """For Private API and last_json logic""" + + +class NotFoundError(PrivateError): + reason = 'Not found' + + +class FeedbackRequired(PrivateError): + pass + + +class ChallengeError(PrivateError): + pass + + +class ChallengeRedirection(ChallengeError): + pass + + +class ChallengeRequired(ChallengeError): + pass + + +class ChallengeSelfieCaptcha(ChallengeError): + pass + + +class ChallengeUnknownStep(ChallengeError): + pass + + +class SelectContactPointRecoveryForm(ChallengeError): + pass + + +class RecaptchaChallengeForm(ChallengeError): + pass + + +class SubmitPhoneNumberForm(ChallengeError): + pass + + +class LegacyForceSetNewPasswordForm(ChallengeError): + pass + + +class LoginRequired(PrivateError): + """Instagram request relogin + Example: + {'message': 'login_required', + 'response': , + 'error_title': "You've Been Logged Out", + 'error_body': 'Please log back in.', + 'logout_reason': 8, + 'status': 'fail'} + """ + + +class SentryBlock(PrivateError): + pass + + +class RateLimitError(PrivateError): + pass + + +class BadPassword(PrivateError): + pass + + +class PleaseWaitFewMinutes(PrivateError): + pass + + +class UnknownError(PrivateError): + pass + + +class MediaError(PrivateError): + pass + + +class MediaNotFound(NotFoundError, MediaError): + pass + + +class UserError(PrivateError): + pass + + +class UserNotFound(NotFoundError, UserError): + pass + + +class CollectionError(PrivateError): + pass + + +class CollectionNotFound(NotFoundError, CollectionError): + pass + + +class DirectError(PrivateError): + pass + + +class DirectThreadNotFound(NotFoundError, DirectError): + pass + + +class DirectMessageNotFound(NotFoundError, DirectError): + pass + + +class VideoTooLongException(PrivateError): + pass + + +class VideoNotDownload(PrivateError): + pass + + +class VideoNotUpload(PrivateError): + pass + + +class VideoConfigureError(VideoNotUpload): + pass + + +class VideoConfigureStoryError(VideoConfigureError): + pass + + +class PhotoNotUpload(PrivateError): + pass + + +class PhotoConfigureError(PhotoNotUpload): + pass + + +class PhotoConfigureStoryError(PhotoConfigureError): + pass + + +class IGTVNotUpload(PrivateError): + pass + + +class IGTVConfigureError(IGTVNotUpload): + pass + + +class ClipNotUpload(PrivateError): + pass + + +class ClipConfigureError(ClipNotUpload): + pass + + +class AlbumNotDownload(PrivateError): + pass + + +class AlbumUnknownFormat(PrivateError): + pass + + +class AlbumConfigureError(PrivateError): + pass + + +class HashtagError(PrivateError): + pass + + +class HashtagNotFound(NotFoundError, HashtagError): + pass + + +class LocationError(PrivateError): + pass + + +class LocationNotFound(NotFoundError, LocationError): + pass + + +class TwoFactorRequired(PrivateError): + pass + + +class HighlightNotFound(NotFoundError, PrivateError): + pass diff --git a/instagrapi/extractors.py b/instagrapi/extractors.py new file mode 100644 index 0000000..7838e26 --- /dev/null +++ b/instagrapi/extractors.py @@ -0,0 +1,393 @@ +import json +from copy import deepcopy + +from .types import ( + Account, + Collection, + Comment, + DirectMedia, + DirectMessage, + DirectResponse, + DirectShortThread, + DirectThread, + Hashtag, + Highlight, + Location, + Media, + MediaOembed, + Resource, + Story, + StoryLink, + StoryMedia, + StoryMention, + Track, + User, + UserShort, + Usertag, +) +from .utils import InstagramIdCodec, json_value + +MEDIA_TYPES_GQL = {"GraphImage": 1, "GraphVideo": 2, "GraphSidecar": 8, "StoryVideo": 2} + + +def extract_media_v1(data): + """Extract media from Private API""" + media = deepcopy(data) + if "video_versions" in media: + # Select Best Quality by Resolutiuon + 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( + 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.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) + media["has_liked"] = media.get("has_liked", False) + return Media( + caption_text=(media.get("caption") or {}).get("text", ""), + resources=[ + extract_resource_v1(edge) for edge in media.get("carousel_media", []) + ], + **media, + ) + + +def extract_media_gql(data): + """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") + try: + media["media_type"] = MEDIA_TYPES_GQL[media["__typename"]] + except KeyError: + media["media_type"] = 0 + if media.get("media_type") == 2 and not media.get("product_type"): + media["product_type"] = "feed" + if "thumbnail_src" in media: + media["thumbnail_url"] = media["thumbnail_src"] + else: + media["thumbnail_url"] = sorted( + # display_resources - user feed, thumbnail_resources - hashtag feed + media.get("display_resources", media.get("thumbnail_resources")), + key=lambda o: o["config_width"] * o["config_height"], + )[-1]["src"] + if media.get("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_id = media.get("id") + media["pk"] = media_id + media["id"] = f"{media_id}_{user.pk}" + return Media( + code=media.get("shortcode"), + taken_at=media.get("taken_at_timestamp"), + location=extract_location(location) if location else None, + user=user, + 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, + ), + resources=[ + extract_resource_gql(edge["node"]) + for edge in media.get("edge_sidecar_to_children", {}).get("edges", []) + ], + **media, + ) + + +def extract_resource_v1(data): + 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["image_versions2"]["candidates"], + key=lambda o: o["height"] * o["width"], + )[-1]["url"] + return Resource(**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) + + +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) + + +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}"' + return UserShort(**data) + + +def extract_user_gql(data): + """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"], + public_email=data["business_email"], + contact_phone_number=data["business_phone_number"], + **data, + ) + + +def extract_user_v1(data): + """For Private API""" + data["external_url"] = data.get("external_url") or None + return User(**data) + + +def extract_location(data): + """Extract location info""" + if not data: + return None + data["pk"] = data.get("id", data.get("pk", data.get("location_id", 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["address"] = data.get("address", data.get("location_address")) + data["city"] = data.get("city", data.get("location_city")) + data["zip"] = data.get("zip", data.get("location_zip")) + address_json = data.get("address_json", "{}") + if isinstance(address_json, str): + address = json.loads(address_json) + data["address"] = address.get("street_address") + data["city"] = address.get("city_name") + data["zip"] = address.get("zip_code") + return Location(**data) + + +def extract_comment(data): + """Extract comment""" + data["has_liked"] = data.get("has_liked_comment") + data["like_count"] = data.get("comment_like_count") + return Comment(**data) + + +def extract_collection(data): + """Extract collection for authorized account + Example: + {'collection_id': '17851406186124602', + 'collection_name': 'Repost', + 'collection_type': 'MEDIA', + 'collection_media_count': 1, + 'cover_media': {...} + """ + 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 MediaOembed(**data) + + +def extract_direct_thread(data): + data["pk"] = data.get("thread_v2_id") + data["id"] = data.get("thread_id") + data["messages"] = [] + for item in data["items"]: + item["thread_id"] = data["id"] + data["messages"].append( + extract_direct_message(item) + ) + data["users"] = [extract_user_short(u) for u in data["users"]] + if "inviter" in data: + data["inviter"] = extract_user_short(data["inviter"]) + data["left_users"] = data.get("left_users", []) + return DirectThread(**data) + + +def extract_direct_short_thread(data): + data["users"] = [extract_user_short(u) for u in data["users"]] + data["id"] = data.get("thread_id") + return DirectShortThread(**data) + + +def extract_direct_response(data): + return DirectResponse(**data) + + +def extract_direct_message(data): + data["id"] = data.get("item_id") + if "media_share" in data: + ms = data["media_share"] + if not ms.get("code"): + ms["code"] = InstagramIdCodec.encode(ms["id"]) + data["media_share"] = extract_media_v1(ms) + if "media" in data: + data["media"] = extract_direct_media(data["media"]) + clip = data.get("clip", {}) + if clip: + if "clip" in clip: + # Instagram Β―\_(ツ)_/Β― + clip = clip.get("clip") + data["clip"] = extract_media_v1(clip) + return DirectMessage(**data) + + +def extract_direct_media(data): + media = deepcopy(data) + if "video_versions" in media: + # Select Best Quality by Resolutiuon + media["video_url"] = sorted( + media["video_versions"], key=lambda o: o["height"] * o["width"] + )[-1]["url"] + if "image_versions2" in media: + media["thumbnail_url"] = sorted( + media["image_versions2"]["candidates"], + key=lambda o: o["height"] * o["width"], + )[-1]["url"] + if "user" in media: + media["user"] = extract_user_short(media.get("user")) + return DirectMedia(**media) + + +def extract_account(data): + 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["profile_pic_url"] = data["profile_pic_url"] or None + return Hashtag(**data) + + +def extract_hashtag_v1(data): + data["allow_following"] = data.get("allow_following") == 1 + data["profile_pic_url"] = data["profile_pic_url"] or None + 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"] = "story" + 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"] = [] + feed_medias = [] + story_feed_medias = data.get('story_feed_media') or [] + for feed_media in story_feed_medias: + feed_media["media_pk"] = int(feed_media["media_id"]) + feed_medias.append(StoryMedia(**feed_media)) + story["medias"] = feed_medias + 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"] + story["product_type"] = "story" + story["thumbnail_url"] = story.get("display_url") + story["mentions"] = [] + story["medias"] = [] + for item in story.get("tappable_objects", []): + if item["__typename"] == "GraphTappableMention": + item["id"] = 1 + item["user"] = extract_user_short(item) + story["mentions"].append(StoryMention(**item)) + if item["__typename"] == "GraphTappableFeedMedia": + media = item.get("media") + if media: + item["media_pk"] = int(media["id"]) + item["media_code"] = media["shortcode"] + story["medias"].append(StoryMedia(**item)) + 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) + + +def extract_highlight_v1(data): + highlight = deepcopy(data) + highlight['pk'] = highlight['id'].split(':')[1] + highlight['items'] = [ + extract_story_v1(item) + for item in highlight.get('items', []) + ] + return Highlight(**highlight) + + +def extract_track(data): + return Track(**data) diff --git a/instagrapi/mixins/__init__.py b/instagrapi/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/instagrapi/mixins/account.py b/instagrapi/mixins/account.py new file mode 100644 index 0000000..a5f89f5 --- /dev/null +++ b/instagrapi/mixins/account.py @@ -0,0 +1,234 @@ +from json.decoder import JSONDecodeError +from pathlib import Path +from typing import Dict + +import requests + +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 dumps, gen_token + + +class AccountMixin: + """ + Helper class to manage your account + """ + + 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": ""}, + headers={ + "x-requested-with": "XMLHttpRequest", + "x-csrftoken": gen_token(), + "Connection": "Keep-Alive", + "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", + }, + proxies=self.public.proxies, + ) + try: + return response.json() + except JSONDecodeError as e: + if "/login/" in response.url: + raise ClientLoginRequired(e, response=response) + raise ClientError(e, response=response) + + def account_info(self) -> Account: + """ + Fetch your account info + + Returns + ------- + Account + An object of Account class + """ + result = self.private_request("accounts/current_user/?edit=true") + return extract_account(result["user"]) + + def account_security_info(self) -> dict: + """ + Fetch your account security info + + Returns + ------- + dict + Contains useful information on security settings: { + "is_phone_confirmed": true, + "is_two_factor_enabled": false, + "is_totp_two_factor_enabled": true, + "is_trusted_notifications_enabled": true, + "is_eligible_for_whatsapp_two_factor": true, + "is_whatsapp_two_factor_enabled": false, + "backup_codes": [...], + "trusted_devices": [], + "has_reachable_email": true, + "eligible_for_trusted_notifications": true, + "is_eligible_for_multiple_totp": false, + "totp_seeds": [], + "can_add_additional_totp_seed": false + } + """ + return self.private_request( + "accounts/account_security_info/", self.with_default_data({}) + ) + + 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", + "username", + "full_name", + "biography", + "phone_number", + "email", + ) + # if "email" in data: + # # email is handled separately + # self.send_confirm_email(data.pop("email")) + # if "phone_number" in data: + # # phone_number is handled separately + # self.send_confirm_phone_number(data.pop("phone_number")) + data = {key: val for key, val in data.items() if key in fields} + if "email" not in data or "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) + full_name = data.pop("full_name", None) + if full_name: + # Instagram original field-name for full user name is "first_name" + data["first_name"] = full_name + # Biography with entities (markup) + result = self.private_request( + "accounts/edit_profile/", self.with_default_data(data) + ) + biography = data.get("biography") + if biography: + self.account_set_biography(biography) + return extract_account(result["user"]) + + def account_set_biography(self, biography: str) -> bool: + """ + Set biography with entities (markup) + + Parameters + ---------- + biography: str + Biography raw text + + Returns + ------- + bool + A boolean value + """ + data = {"logged_in_uids": dumps([str(self.user_id)]), "raw_text": biography} + result = self.private_request( + "accounts/set_biography/", self.with_default_data(data) + ) + return result["status"] == "ok" + + def account_change_picture(self, path: Path) -> UserShort: + """ + 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}), + ) + return extract_user_short(result["user"]) + + def news_inbox_v1(self, mark_as_seen: bool = False) -> dict: + """ + Get old and new stories as is + + Parameters + ---------- + mark_as_seen: bool + Mark as seen or not + + Returns + ------- + dict + """ + return self.private_request( + "news/inbox/", params={"mark_as_seen": mark_as_seen} + ) + + def send_confirm_email(self, email: str) -> dict: + """ + Send confirmation code to new email address + + Parameters + ---------- + email: str + Email address + + Returns + ------- + dict + """ + return self.private_request( + "accounts/send_confirm_email/", + self.with_extra_data( + {"send_source": "personal_information", "email": email} + ), + ) + + def send_confirm_phone_number(self, phone_number: str) -> dict: + """ + Send confirmation code to new phone number + + Parameters + ---------- + phone_number: str + Phone number + + Returns + ------- + dict + """ + return self.private_request( + "accounts/initiate_phone_number_confirmation/", + self.with_extra_data( + { + "android_build_type": "release", + "send_source": "edit_profile", + "phone_number": phone_number, + } + ), + ) diff --git a/instagrapi/mixins/album.py b/instagrapi/mixins/album.py new file mode 100644 index 0000000..3e5bd2c --- /dev/null +++ b/instagrapi/mixins/album.py @@ -0,0 +1,287 @@ +import time +from pathlib import Path +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.types import Location, Media, Usertag +from instagrapi.utils import date_time_original, 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 = [] + for resource in media.resources: + filename = f"{media.user.username}_{resource.pk}" + if resource.media_type == 1: + paths.append( + 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) + ) + else: + raise AlbumNotDownload( + 'Media type "{resource.media_type}" unknown for album (resource={resource.pk})' + ) + 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: + file_name = urlparse(url).path.rsplit("/", 1)[1] + if file_name.lower().endswith((".jpg", ".jpeg")): + paths.append(self.photo_download_by_url(url, file_name, folder)) + elif file_name.lower().endswith(".mp4"): + paths.append(self.video_download_by_url(url, file_name, folder)) + else: + raise AlbumUnknownFormat() + return paths + + def album_download_origin(self, media_pk: int) -> List[bytes]: + """ + Download your album + + Parameters + ---------- + media_pk: int + PK for the album you want to download + 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" + files = [] + for resource in media.resources: + if resource.media_type == 1: + files.append( + self.photo_download_by_url_origin(resource.thumbnail_url) + ) + elif resource.media_type == 2: + files.append( + self.video_download_by_url_origin(resource.video_url) + ) + else: + raise AlbumNotDownload( + 'Media type "{resource.media_type}" unknown for album (resource={resource.pk})' + ) + return files + + +class UploadAlbumMixin: + def album_upload( + self, + paths: List[Path], + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + configure_timeout: int = 3, + configure_handler=None, + configure_exception=None, + to_story=False, + extra_data: Dict[str, str] = {}, + ) -> Media: + """ + Upload album to feed + + 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 + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Media + An object of Media class + """ + children = [] + for path in paths: + path = Path(path) + if path.suffix.lower() in (".jpg", ".jpeg"): + upload_id, width, height = self.photo_rupload(path, to_album=True) + 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.lower() == ".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": date_time_original(time.localtime()), + "audio_muted": "false", + } + ) + self.photo_rupload(thumbnail, upload_id) + else: + raise AlbumUnknownFormat() + + for attempt in range(50): + self.logger.debug(f"Attempt #{attempt} to configure Album: {paths}") + time.sleep(configure_timeout) + try: + configured = (configure_handler or self.album_configure)( + children, caption, usertags, location, extra_data=extra_data + ) + except Exception as e: + if "Transcode not finished yet" in str(e): + """ + Response 202 status: + {"message": "Transcode not finished yet.", "status": "fail"} + """ + time.sleep(configure_timeout) + continue + raise e + else: + if configured: + media = configured.get("media") + self.expose() + return extract_media_v1(media) + raise (configure_exception or AlbumConfigureError)( + response=self.last_response, **self.last_json + ) + + def album_configure( + self, + childs: List, + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Post Configure Album + + 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 + extra_data: Dict[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 + """ + upload_id = str(int(time.time() * 1000)) + if usertags: + usertags = [ + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags + ] + childs[0]["usertags"] = dumps({"in": usertags}) + data = { + "timezone_offset": str(self.timezone_offset), + "source_type": "4", + "creation_logger_session_id": self.client_session_id, + "location": self.location_build(location), + "caption": caption, + "client_sidecar_id": upload_id, + "upload_id": upload_id, + # "location": self.build_location(name, lat, lng, address), + "suggested_venue_position": -1, + "device": self.device, + "is_suggested_venue": False, + "children_metadata": [ + { + "source_type": "4", + "timezone_offset": str(self.timezone_offset), + "device": dumps(self.device), + **child, + } + for child in childs + ], + **extra_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 new file mode 100644 index 0000000..f85afd5 --- /dev/null +++ b/instagrapi/mixins/auth.py @@ -0,0 +1,829 @@ +import base64 + +# import datetime +import hashlib +import hmac +import json +import random +import re +import time +import uuid +from pathlib import Path +from typing import Dict, List +from uuid import uuid4 + +import requests +from pydantic import ValidationError + +from instagrapi import config +from instagrapi.exceptions import ( + ClientThrottledError, + PleaseWaitFewMinutes, + PrivateError, + ReloginAttemptExceeded, + TwoFactorRequired, +) +from instagrapi.utils import dumps, gen_token, generate_jazoest + +# from instagrapi.zones import CET + + +class PreLoginFlowMixin: + """ + Helpers for pre login flow + """ + + def pre_login_flow(self) -> bool: + """ + Emulation mobile app behavior before login + + Returns + ------- + bool + A boolean value + """ + # self.set_contact_point_prefill("prefill") + # self.get_prefill_candidates(True) + self.set_contact_point_prefill("prefill") + self.sync_launcher(True) + # self.sync_device_features(True) + return True + + 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 + """ + data = { + "android_device_id": self.android_device_id, + "client_contact_points": "[{\"type\":\"omnistring\",\"value\":\"%s\",\"source\":\"last_login_attempt\"}]" % self.username, + "phone_id": self.phone_id, + "usages": '["account_recovery_omnibox"]', + "logged_in_user_ids": "[]", # "[\"123456789\",\"987654321\"]", + "device_id": self.uuid, + } + # if login is False: + data["_csrftoken"] = self.token + return self.private_request("accounts/get_prefill_candidates/", data, login=login) + + 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", + # "experiments": config.LOGIN_EXPERIMENTS, + } + if login is False: + data["_uuid"] = self.uuid + data["_uid"] = self.user_id + data["_csrftoken"] = self.token + # headers={"X-DEVICE-ID": self.uuid} + return self.private_request("qe/sync/", data, login=login) + + 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", + } + if login is False: + data["_uid"] = self.user_id + data["_uuid"] = self.uuid + data["_csrftoken"] = self.token + return self.private_request("launcher/sync/", data, login=login) + + 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, + # "_csrftoken": self.token + } + 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 + + Returns + ------- + bool + A boolean value + """ + check_flow = [] + # chance = random.randint(1, 100) % 2 == 0 + # reason = "pull_to_refresh" if chance else "cold_start" + check_flow.append(self.get_reels_tray_feed("cold_start")) + check_flow.append(self.get_timeline_feed(["cold_start_fetch"])) + return all(check_flow) + + def get_timeline_feed(self, options: List[Dict] = ["pull_to_refresh"]) -> 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, + "X-CM-Bandwidth-KBPS": '-1.000', # str(random.randint(2000, 5000)), + "X-CM-Latency": str(random.randint(1, 5)), + } + data = { + "feed_view_info": "[]", # e.g. [{"media_id":"2634223601739446191_7450075998","version":24,"media_pct":1.0,"time_info":{"10":63124,"25":63124,"50":63124,"75":63124},"latest_timestamp":1628253523186}] + "phone_id": self.phone_id, + "battery_level": random.randint(25, 100), + "timezone_offset": str(self.timezone_offset), + "_csrftoken": self.token, + "device_id": self.uuid, + "request_id": self.request_id, + "_uuid": self.uuid, + "is_charging": random.randint(0, 1), + "will_sound_on": random.randint(0, 1), + "session_id": self.client_session_id, + "bloks_versioning_id": self.bloks_versioning_id, + } + if "pull_to_refresh" in options: + data["reason"] = "pull_to_refresh" + data["is_pull_to_refresh"] = "1" + elif "cold_start_fetch" in options: + data["reason"] = "cold_start_fetch" + data["is_pull_to_refresh"] = "0" + # if "push_disabled" in options: + # data["push_disabled"] = "true" + # if "recovered_from_crash" in options: + # data["recovered_from_crash"] = "1" + return self.private_request( + "feed/timeline/", json.dumps(data), with_signature=False, headers=headers + ) + + def get_reels_tray_feed(self, reason: str = "pull_to_refresh") -> Dict: + """ + 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, + "reason": reason, + "timezone_offset": str(self.timezone_offset), + "tray_session_id": self.tray_session_id, + "request_id": self.request_id, + "latest_preloaded_reel_ids": "[]", # [{"reel_id":"6009504750","media_count":"15","timestamp":1628253494,"media_ids":"[\"2634301737009283814\",\"2634301789371018685\",\"2634301853921370532\",\"2634301920174570551\",\"2634301973895112725\",\"2634302037581608844\",\"2634302088273817272\",\"2634302822117736694\",\"2634303181452199341\",\"2634303245482345741\",\"2634303317473473894\",\"2634303382971517344\",\"2634303441062726263\",\"2634303502039423893\",\"2634303754729475501\"]"},{"reel_id":"4357392188","media_count":"4","timestamp":1628250613,"media_ids":"[\"2634142331579781054\",\"2634142839803515356\",\"2634150786575125861\",\"2634279566740346641\"]"},{"reel_id":"5931631205","media_count":"7","timestamp":1628253023,"media_ids":"[\"2633699694927154768\",\"2634153361241413763\",\"2634196788830183839\",\"2634219197377323622\",\"2634294221109889541\",\"2634299705648894876\",\"2634299760434939842\"]"}], + "page_size": 50, + # "_csrftoken": self.token, + "_uuid": self.uuid, + } + return self.private_request("feed/reels_tray/", data) + + +class LoginMixin(PreLoginFlowMixin, PostLoginFlowMixin): + username = None + password = None + authorization = "" # Bearer IGT:2: + authorization_data = {} # decoded authorization header + last_login = None + relogin_attempt = 0 + device_settings = {} + client_session_id = "" + tray_session_id = "" + advertising_id = "" + android_device_id = "" + request_id = "" + phone_id = "" + app_id = "567067343352427" + uuid = "" + mid = "" + country = "US" + country_code = 1 # Phone code, default USA + locale = "en_US" + timezone_offset: int = -14400 # New York, GMT-4 in seconds + ig_u_rur = "" # e.g. CLN,49897488153,1666640702:01f7bdb93090f4f773516fc2cf1424178a58a2295b4c754090ba02cb0a834e2d1f731e20 + ig_www_claim = "" # e.g. hmac.AR2uidim8es5kYgDiNxY0UG_ZhffFFSt8TGCV5eA1VYYsMNx + + 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"] + ) + self.authorization_data = self.settings.get('authorization_data', {}) + self.last_login = self.settings.get("last_login") + self.set_timezone_offset(self.settings.get("timezone_offset", self.timezone_offset)) + self.set_device(self.settings.get("device_settings")) + self.bloks_versioning_id = hashlib.sha256(json.dumps(self.device_settings).encode()).hexdigest() + self.set_user_agent(self.settings.get("user_agent")) + self.set_uuids(self.settings.get("uuids", {})) + self.set_locale(self.settings.get("locale", self.locale)) + self.set_country(self.settings.get("country", self.country)) + self.set_country_code(self.settings.get("country_code", self.country_code)) + self.mid = self.settings.get("mid", self.cookie_dict.get("mid")) + self.set_ig_u_rur(self.settings.get("ig_u_rur")) + self.set_ig_www_claim(self.settings.get("ig_www_claim")) + # init headers + headers = self.base_headers + headers.update({'Authorization': self.authorization}) + self.private.headers.update(headers) + return True + + def login_by_sessionid(self, sessionid: str) -> bool: + """ + 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() + self.authorization_data = { + "ds_user_id": user_id, + "sessionid": sessionid, + "should_use_header_over_cookies": True + } + try: + user = self.user_info_v1(int(user_id)) + except (PrivateError, ValidationError): + user = self.user_short_gql(int(user_id)) + self.username = user.username + self.cookie_dict["ds_user_id"] = user.pk + return True + + def login(self, username: str, password: str, relogin: bool = False, verification_code: str = '') -> bool: + """ + Login + + Parameters + ---------- + username: str + Instagram Username + password: str + Instagram Password + relogin: bool + Whether or not to re login, default False + verification_code: str + 2FA verification code + + Returns + ------- + bool + A boolean value + """ + self.username = username + self.password = password + self.init() + if relogin: + self.private.cookies.clear() + if self.relogin_attempt > 1: + raise ReloginAttemptExceeded() + self.relogin_attempt += 1 + # if self.user_id and self.last_login: + # if time.time() - self.last_login < 60 * 60 * 24: + # return True # already login + if self.user_id and not relogin: + return True # already login + try: + self.pre_login_flow() + except (PleaseWaitFewMinutes, ClientThrottledError): + self.logger.warning('Ignore 429: Continue login') + # The instagram application ignores this error + # and continues to log in (repeat this behavior) + enc_password = self.password_encrypt(password) + data = { + "jazoest": generate_jazoest(self.phone_id), + "country_codes": "[{\"country_code\":\"%d\",\"source\":[\"default\"]}]" % int(self.country_code), + "phone_id": self.phone_id, + "enc_password": enc_password, + "username": username, + "adid": self.advertising_id, + "guid": self.uuid, + "device_id": self.android_device_id, + "google_tokens": "[]", + "login_attempt_count": "0" + } + try: + logged = self.private_request("accounts/login/", data, login=True) + self.authorization_data = self.parse_authorization( + self.last_response.headers.get('ig-set-authorization') + ) + except TwoFactorRequired as e: + if not verification_code.strip(): + raise TwoFactorRequired(f'{e} (you did not provide verification_code for login method)') + two_factor_identifier = self.last_json.get( + 'two_factor_info', {} + ).get('two_factor_identifier') + data = { + "verification_code": verification_code, + "phone_id": self.phone_id, + "_csrftoken": self.token, + "two_factor_identifier": two_factor_identifier, + "username": username, + "trust_this_device": "0", + "guid": self.uuid, + "device_id": self.android_device_id, + "waterfall_id": str(uuid4()), + "verification_method": "3" + } + logged = self.private_request("accounts/two_factor_login/", data, login=True) + self.authorization_data = self.parse_authorization( + self.last_response.headers.get('ig-set-authorization') + ) + if logged: + self.login_flow() + self.last_login = time.time() + return True + return False + + def one_tap_app_login(self, user_id: int, nonce: str) -> bool: + """One tap login emulation + + Parameters + ---------- + user_id: int + User ID + nonce: str + Login nonce (from Instagram, e.g. in /logout/) + + Returns + ------- + bool + A boolean value + """ + user_id = int(user_id) + data = { + "phone_id": self.phone_id, + "user_id": user_id, + "adid": self.advertising_id, + "guid": self.uuid, + "device_id": self.uuid, + "login_nonce": nonce, + "_csrftoken": self.token + } + return self.private_request("accounts/one_tap_app_login/", data) + + def relogin(self) -> bool: + """ + Relogin helper + + Returns + ------- + bool + A boolean value + """ + return self.login(self.username, self.password, relogin=True) + + @property + def cookie_dict(self) -> dict: + return self.private.cookies.get_dict() + + @property + def sessionid(self) -> str: + sessionid = self.cookie_dict.get("sessionid") + if not sessionid and self.authorization_data: + sessionid = self.authorization_data.get('sessionid') + return sessionid + + @property + def token(self) -> str: + """CSRF token + e.g. vUJGjpst6szjI38mZ6Pb1dROsWVerZelGSYGe0W1tuugpSUefVjRLj2Pom2SWNoA + """ + if not getattr(self, '_token', None): + self._token = self.cookie_dict.get("csrftoken", gen_token(64)) + return self._token + + @property + def rank_token(self) -> str: + return f"{self.user_id}_{self.uuid}" + + @property + def user_id(self) -> int: + user_id = self.cookie_dict.get("ds_user_id") + if not user_id and self.authorization_data: + user_id = self.authorization_data.get('ds_user_id') + if user_id: + return int(user_id) + return None + + @property + def device(self) -> dict: + return { + key: val + for key, val in self.device_settings.items() + if key in ["manufacturer", "model", "android_version", "android_release"] + } + + def get_settings(self) -> Dict: + """ + Get current session settings + + Returns + ------- + Dict + Current session settings as a Dict + """ + return { + "uuids": { + "phone_id": self.phone_id, + "uuid": self.uuid, + "client_session_id": self.client_session_id, + "advertising_id": self.advertising_id, + "android_device_id": self.android_device_id, + # "device_id": self.uuid, + "request_id": self.request_id, + "tray_session_id": self.tray_session_id, + }, + "mid": self.mid, + "ig_u_rur": self.ig_u_rur, + "ig_www_claim": self.ig_www_claim, + "authorization_data": self.authorization_data, + "cookies": requests.utils.dict_from_cookiejar(self.private.cookies), + "last_login": self.last_login, + "device_settings": self.device_settings, + "user_agent": self.user_agent, + "country": self.country, + "country_code": self.country_code, + "locale": self.locale, + "timezone_offset": self.timezone_offset, + } + + def set_settings(self, settings: Dict) -> bool: + """ + Set session settings + + Returns + ------- + Bool + """ + self.settings = settings + self.init() + return True + + def load_settings(self, path: Path) -> Dict: + """ + Load session settings + + Parameters + ---------- + path: Path + Path to storage file + + Returns + ------- + Dict + Current session settings as a Dict + """ + with open(path, 'r') as fp: + self.set_settings(json.load(fp)) + return self.settings + return None + + def dump_settings(self, path: Path) -> bool: + """ + Serialize and save session settings + + Parameters + ---------- + path: Path + Path to storage file + + Returns + ------- + Bool + """ + with open(path, 'w') as fp: + json.dump(self.get_settings(), fp) + return True + + def set_device(self, device: Dict = None, reset: bool = False) -> 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": "203.0.0.29.118", + "android_version": 26, + "android_release": "8.0.0", + "dpi": "480dpi", + "resolution": "1080x1920", + "manufacturer": "Xiaomi", + "device": "capricorn", + "model": "MI 5s", + "cpu": "qcom", + "version_code": "314665256", + } + self.settings["device_settings"] = self.device_settings + if reset: + self.set_uuids({}) + # self.settings = self.get_settings() + return True + + def set_user_agent(self, user_agent: str = "", reset: bool = False) -> bool: + """ + Helper to set user agent + + Parameters + ---------- + user_agent: str, optional + User agent, default is "" + + Returns + ------- + bool + A boolean value + """ + data = dict(self.device_settings, locale=self.locale) + self.user_agent = user_agent or config.USER_AGENT_BASE.format(**data) + # self.private.headers.update({"User-Agent": self.user_agent}) # changed in base_headers + # self.settings["user_agent"] = self.user_agent + if reset: + self.set_uuids({}) + # self.settings = self.get_settings() + return True + + 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.advertising_id = uuids.get("advertising_id", self.generate_uuid()) + self.android_device_id = uuids.get("android_device_id", self.generate_android_device_id()) + self.request_id = uuids.get("request_id", self.generate_uuid()) + self.tray_session_id = uuids.get("tray_session_id", self.generate_uuid()) + # self.device_id = uuids.get("device_id", self.generate_uuid()) + self.settings["uuids"] = uuids + return True + + def generate_uuid(self, prefix: str = '', suffix: str = '') -> str: + """ + Helper to generate uuids + + Returns + ------- + str + A stringified UUID + """ + return f'{prefix}{uuid.uuid4()}{suffix}' + + def generate_mutation_token(self) -> str: + """ + Token used when DM sending and upload media + + Returns + ------- + str + A stringified int + """ + return str(random.randint(6800011111111111111, 6800099999999999999)) + + def generate_android_device_id(self) -> str: + """ + Helper to generate Android Device ID + + Returns + ------- + str + A random android device id + """ + return "android-%s" % hashlib.sha256(str(time.time()).encode()).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_extra_data(self, data: Dict) -> Dict: + """ + Helper to get extra data + + Returns + ------- + Dict + A dictionary of default data + """ + return self.with_default_data({ + "phone_id": self.phone_id, + "_uid": str(self.user_id), + "guid": self.uuid, + **data + }) + + def with_default_data(self, data: Dict) -> Dict: + """ + Helper to get default data + + Returns + ------- + Dict + A dictionary of default data + """ + return { + "_uuid": self.uuid, + # "_uid": str(self.user_id), + # "_csrftoken": self.token, + "device_id": self.android_device_id, + **data, + } + + 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) + text_change_event_count = max(1, size / random.randint(3, 5)) + data = "{size!s} {elapsed!s} {count!s} {dt!s}".format( + **{ + "size": size, + "elapsed": time_elapsed, + "count": text_change_event_count, + "dt": dt, + } + ) + return "{!s}\n{!s}\n".format( + base64.b64encode( + hmac.new( + key.encode("ascii"), data.encode("ascii"), digestmod=hashlib.sha256 + ).digest() + ), + base64.b64encode(data.encode("ascii")), + ) + + def inject_sessionid_to_public(self) -> bool: + """ + Inject sessionid from private session to public session + + Returns + ------- + bool + A boolean value + """ + if self.sessionid: + self.public.cookies.set("sessionid", self.sessionid) + return True + return False + + def logout(self) -> bool: + result = self.private_request( + "accounts/logout/", + {'one_tap_app_login': True} + ) + return result["status"] == "ok" + + def parse_authorization(self, authorization) -> dict: + """Parse authorization header + """ + try: + b64part = authorization.rsplit(':', 1)[-1] + return json.loads(base64.b64decode(b64part)) + except Exception as e: + self.logger.exception(e) + return {} + + @property + def authorization(self) -> str: + """Build authorization header + Example: Bearer IGT:2:eaW9u.....aWQiOiI0NzM5= + """ + if self.authorization_data: + b64part = base64.b64encode( + dumps(self.authorization_data).encode() + ).decode() + return f'Bearer IGT:2:{b64part}' + return '' diff --git a/instagrapi/mixins/bloks.py b/instagrapi/mixins/bloks.py new file mode 100644 index 0000000..0435267 --- /dev/null +++ b/instagrapi/mixins/bloks.py @@ -0,0 +1,46 @@ +from instagrapi.utils import dumps + + +class BloksMixin: + bloks_versioning_id = "" + + def bloks_action(self, action: str, data: dict) -> bool: + """Performing actions for bloks + + Parameters + ---------- + action: str + Action, example "com.instagram.challenge.navigation.take_challenge" + data: dict + Additional data + + Returns + ------- + bool + """ + result = self.private_request(f"bloks/apps/{action}/", self.with_default_data(data)) + return result["status"] == "ok" + + def bloks_change_password(self, password: str, challenge_context: dict) -> bool: + """ + Change password for challenge + + Parameters + ---------- + passwrd: str + New password + + Returns + ------- + bool + """ + assert self.bloks_versioning_id, "Client.bloks_versioning_id is empty (hash is expected)" + enc_password = self.password_encrypt(password) + data = { + "bk_client_context": dumps({"bloks_version": self.bloks_versioning_id, "styles_id": "instagram"}), + "challenge_context": challenge_context, + "bloks_versioning_id": self.bloks_versioning_id, + "enc_new_password1": enc_password, + "enc_new_password2": enc_password, + } + return self.bloks_action("com.instagram.challenge.navigation.take_challenge", data) diff --git a/instagrapi/mixins/challenge.py b/instagrapi/mixins/challenge.py new file mode 100644 index 0000000..5779f84 --- /dev/null +++ b/instagrapi/mixins/challenge.py @@ -0,0 +1,426 @@ +import hashlib +import json +import time +from enum import Enum +from typing import Dict + +import requests + +from instagrapi.exceptions import ( + ChallengeError, + ChallengeRedirection, + ChallengeRequired, + ChallengeSelfieCaptcha, + ChallengeUnknownStep, + LegacyForceSetNewPasswordForm, + RecaptchaChallengeForm, + SelectContactPointRecoveryForm, + SubmitPhoneNumberForm, +) + +WAIT_SECONDS = 5 + + +class ChallengeChoice(Enum): + SMS = 0 + EMAIL = 1 + + +def extract_messages(challenge): + messages = [] + for item in challenge["extraData"].get("content"): + message = item.get("title", item.get("text")) + if message: + dot = "" if message.endswith(".") else "." + messages.append(f"{message}{dot}") + return messages + + +class ChallengeResolveMixin: + """ + Helpers for resolving login challenge + """ + + def challenge_resolve(self, last_json: Dict) -> bool: + """ + Start challenge resolve + + Returns + ------- + bool + A boolean value + """ + # START GET REQUEST to challenge_url + challenge_url = last_json["challenge"]["api_path"] + try: + user_id, nonce_code = challenge_url.split("/")[2:4] + challenge_context = last_json.get('challenge', {}).get('challenge_context') + if not challenge_context: + challenge_context = json.dumps({ + "step_name": "", + "nonce_code": nonce_code, + "user_id": int(user_id), + "is_stateless": False + }) + params = { + "guid": self.uuid, + "device_id": self.android_device_id, + "challenge_context": challenge_context, + } + except ValueError: + # not enough values to unpack (expected 2, got 1) + params = {} + try: + self._send_private_request(challenge_url[1:], params=params) + 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: 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 + enc_password = "#PWD_INSTAGRAM_BROWSER:0:%s:" % str(int(time.time())) + instagram_ajax = hashlib.sha256(enc_password.encode()).hexdigest()[:12] + session = requests.Session() + session.verify = False # fix SSLError/HTTPSConnectionPool + session.proxies = self.private.proxies + session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Linux; Android 8.0.0; MI 5s Build/OPR1.170623.032; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/80.0.3987.149 " + "Mobile Safari/537.36 %s" % self.user_agent, + "upgrade-insecure-requests": "1", + "sec-fetch-dest": "document", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "x-requested-with": "com.instagram.android", + "sec-fetch-site": "none", + "sec-fetch-mode": "navigate", + "sec-fetch-user": "?1", + "accept-encoding": "gzip, deflate", + "accept-language": "en-US,en;q=0.9,en-US;q=0.8,en;q=0.7", + "pragma": "no-cache", + "cache-control": "no-cache", + } + ) + for key, value in self.private.cookies.items(): + if key in ["mid", "csrftoken"]: + session.cookies.set(key, value) + time.sleep(WAIT_SECONDS) + result = session.get(challenge_url) # render html form + session.headers.update( + { + "x-ig-www-claim": "0", + "x-instagram-ajax": instagram_ajax, + "content-type": "application/x-www-form-urlencoded", + "accept": "*/*", + "sec-fetch-dest": "empty", + "x-requested-with": "XMLHttpRequest", + "x-csrftoken": session.cookies.get_dict().get("csrftoken"), + "x-ig-app-id": self.private.headers.get("X-IG-App-ID"), + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "referer": challenge_url, + } + ) + time.sleep(WAIT_SECONDS) + choice = ChallengeChoice.EMAIL + result = session.post(challenge_url, {"choice": choice}) + result = result.json() + for retry in range(8): + time.sleep(WAIT_SECONDS) + try: + # FORM TO ENTER CODE + result = self.handle_challenge_result(result) + break + except SelectContactPointRecoveryForm as e: + if choice == ChallengeChoice.SMS: # last iteration + raise e + choice = ChallengeChoice.SMS + result = session.post(challenge_url, {"choice": choice}) + result = result.json() + continue # next choice attempt + except SubmitPhoneNumberForm as e: + result = session.post( + challenge_url, + { + "phone_number": e.challenge["fields"]["phone_number"], + "challenge_context": e.challenge["challenge_context"], + }, + ) + result = result.json() + break + except ChallengeRedirection: + return True # instagram redirect + assert result.get("challengeType") in ( + "VerifyEmailCodeForm", + "VerifySMSCodeForm", + "VerifySMSCodeFormForSMSCaptcha", + ), result + for retry_code in range(5): + for attempt in range(1, 11): + code = self.challenge_code_handler(self.username, choice) + if code: + break + time.sleep(WAIT_SECONDS * attempt) + # SEND CODE + time.sleep(WAIT_SECONDS) + result = session.post(challenge_url, {"security_code": code}).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 + challenge_type = result.get("challengeType") + if challenge_type == "LegacyForceSetNewPasswordForm": + self.challenge_resolve_new_password_form(result) + assert result.get("challengeType") == "ReviewContactPointChangeForm", result + details = [] + for data in result["extraData"]["content"]: + for entry in data.get("labeled_list_entries", []): + val = entry["list_item_text"] + if "@" not in val: + val = val.replace(" ", "").replace("-", "") + details.append(val) + # CHECK ACCOUNT DATA + for detail in [self.username, self.email, self.phone_number]: + assert ( + not detail or detail in details + ), '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"), + { + "choice": 0, # I AGREE + "enc_new_password1": enc_password, + "new_password1": "", + "enc_new_password2": enc_password, + "new_password2": "", + }, + ).json() + assert result.get("type") == "CHALLENGE_REDIRECTION", result + assert result.get("status") == "ok", result + return True + + def challenge_resolve_new_password_form(self, result): + msg = ' '.join([ + 'Log into your Instagram account from smartphone and change password!', + *extract_messages(result) + ]) + raise LegacyForceSetNewPasswordForm(msg) + + 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") + if challenge_type == "SelectContactPointRecoveryForm": + """ + ΠŸΠΎΠΌΠΎΠ³ΠΈΡ‚Π΅ Π½Π°ΠΌ ΡƒΠ΄ΠΎΡΡ‚ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒΡΡ, Ρ‡Ρ‚ΠΎ Π²Ρ‹ Π²Π»Π°Π΄Π΅Π΅Ρ‚Π΅ этим Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚ΠΎΠΌ + Π§Ρ‚ΠΎΠ±Ρ‹ Π·Π°Ρ‰ΠΈΡ‚ΠΈΡ‚ΡŒ свой Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚, запроситС ΠΏΠΎΠΌΠΎΡ‰ΡŒ со Π²Ρ…ΠΎΠ΄ΠΎΠΌ. + {'message': '', + 'challenge': {'challengeType': 'SelectContactPointRecoveryForm', + 'errors': ['Select a valid choice. 1 is not one of the available choices.'], + 'experiments': {}, + 'extraData': {'__typename': 'GraphChallengePage', + 'content': [{'__typename': 'GraphChallengePageHeader', + 'description': None, + 'title': 'Help Us Confirm You Own This Account'}, + {'__typename': 'GraphChallengePageText', + 'alignment': 'center', + 'html': None, + 'text': 'To secure your account, you need to request help logging in.'}, + {'__typename': 'GraphChallengePageForm', + 'call_to_action': 'Get Help Logging In', + 'display': 'inline', + 'fields': None, + 'href': 'https://help.instagram.com/358911864194456'}]}, + 'fields': {'choice': 'None'}, + 'navigation': {'forward': '/challenge/8530598273/PlWAX2OMVk/', + 'replay': '/challenge/replay/8530598273/PlWAX2OMVk/', + 'dismiss': 'instagram://checkpoint/dismiss'}, + 'privacyPolicyUrl': '/about/legal/privacy/', + 'type': 'CHALLENGE'}, + 'status': 'fail'} + """ + if "extraData" in challenge: + messages += extract_messages(challenge) + if "errors" in challenge: + for error in challenge["errors"]: + messages.append(error) + raise SelectContactPointRecoveryForm( + " ".join(messages), challenge=challenge + ) + elif challenge_type == "RecaptchaChallengeForm": + """ + Example: + {'message': '', + 'challenge': { + 'challengeType': 'RecaptchaChallengeForm', + 'errors': ['ΠΠ΅ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Π°Ρ Captcha. ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π΅Ρ‰Π΅ Ρ€Π°Π·.'], + 'experiments': {}, + 'extraData': None, + 'fields': {'g-recaptcha-response': 'None', + 'disable_num_days_remaining': -60, + 'sitekey': '6LebnxwUAAAAAGm3yH06pfqQtcMH0AYDwlsXnh-u'}, + 'navigation': {'forward': '/challenge/32708972491/CE6QdsYZyB/', + 'replay': '/challenge/replay/32708972491/CE6QdsYZyB/', + 'dismiss': 'instagram://checkpoint/dismiss'}, + 'privacyPolicyUrl': '/about/legal/privacy/', + 'type': 'CHALLENGE'}, + 'status': 'fail'} + """ + raise RecaptchaChallengeForm(". ".join(challenge.get("errors", []))) + elif challenge_type in ("VerifyEmailCodeForm", "VerifySMSCodeForm"): + # Success. Next step + return challenge + elif challenge_type == "SubmitPhoneNumberForm": + raise SubmitPhoneNumberForm(challenge=challenge) + elif challenge_type: + # Unknown challenge_type + messages.append(challenge_type) + if "errors" in challenge: + messages.append("\n".join(challenge["errors"])) + messages.append("(Please manual login)") + raise ChallengeError(" ".join(messages)) + elif challenge.get("type") == "CHALLENGE_REDIRECTION": + """ + Example: + {'location': 'instagram://checkpoint/dismiss', + 'status': 'ok', + 'type': 'CHALLENGE_REDIRECTION'} + """ + raise ChallengeRedirection() + return challenge + + 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", "") + if step_name == "delta_login_review": + # IT WAS ME (by GEO) + self._send_private_request(challenge_url, {"choice": "0"}) + return True + elif step_name in ("verify_email", "select_verify_method"): + if step_name == "select_verify_method": + """ + {'step_name': 'select_verify_method', + 'step_data': {'choice': '0', + 'fb_access_token': 'None', + 'big_blue_token': 'None', + 'google_oauth_token': 'true', + 'vetted_device': 'None', + 'phone_number': '+7 *** ***-**-09', + 'email': 'x****g@y*****.com'}, <------------- choice + 'nonce_code': 'DrW8V4m5Ec', + 'user_id': 12060121299, + 'status': 'ok'} + """ + steps = self.last_json["step_data"].keys() + challenge_url = challenge_url[1:] + if "email" in steps: + self._send_private_request(challenge_url, {"choice": ChallengeChoice.EMAIL}) + elif "phone_number" in steps: + self._send_private_request(challenge_url, {"choice": ChallengeChoice.SMS}) + else: + raise ChallengeError(f'ChallengeResolve: Choice "email" or "phone_number" (sms) not available to this account {self.last_json}') + wait_seconds = 5 + for attempt in range(24): + code = self.challenge_code_handler(self.username, ChallengeChoice.EMAIL) + if code: + break + time.sleep(wait_seconds) + print(f'Code entered "{code}" for {self.username} ({attempt} attempts by {wait_seconds} seconds)') + self._send_private_request(challenge_url, {"security_code": code}) + # assert 'logged_in_user' in client.last_json + assert self.last_json.get("action", "") == "close" + assert self.last_json.get("status", "") == "ok" + return True + elif step_name == "": + assert self.last_json.get("action", "") == "close" + assert self.last_json.get("status", "") == "ok" + return True + elif step_name == "change_password": + # Example: {'step_name': 'change_password', + # 'step_data': {'new_password1': 'None', 'new_password2': 'None'}, + # 'flow_render_type': 3, + # 'bloks_action': 'com.instagram.challenge.navigation.take_challenge', + # 'cni': 18226879502000588, + # 'challenge_context': '{"step_name": "change_password", "cni": 18226879502000588, "is_stateless": false, "challenge_type_enum": "PASSWORD_RESET"}', + # 'challenge_type_enum_str': 'PASSWORD_RESET', + # 'status': 'ok'} + wait_seconds = 5 + for attempt in range(24): + pwd = self.change_password_handler(self.username) + if pwd: + break + time.sleep(wait_seconds) + print(f'Password entered "{pwd}" for {self.username} ({attempt} attempts by {wait_seconds} seconds)') + return self.bloks_change_password(pwd, self.last_json['challenge_context']) + elif step_name == "selfie_captcha": + raise ChallengeSelfieCaptcha(self.last_json) + else: + raise ChallengeUnknownStep(f'ChallengeResolve: Unknown step_name "{step_name}" for "{self.username}" in challenge resolver: {self.last_json}') + return True diff --git a/instagrapi/mixins/clip.py b/instagrapi/mixins/clip.py new file mode 100644 index 0000000..286adea --- /dev/null +++ b/instagrapi/mixins/clip.py @@ -0,0 +1,329 @@ +import json +import random +import time +from pathlib import Path +from typing import Dict, List +from uuid import uuid4 + +from instagrapi import config +from instagrapi.exceptions import ClientError, ClipConfigureError, ClipNotUpload +from instagrapi.extractors import extract_media_v1 +from instagrapi.types import Location, Media, Usertag +from instagrapi.utils import date_time_original + +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=8.1.1") + + +class DownloadClipMixin: + """ + Helpers to download CLIP videos + """ + + def clip_download(self, media_pk: int, folder: Path = "") -> str: + """ + Download CLIP 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 clip_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> str: + """ + Download CLIP 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 UploadClipMixin: + """ + Helpers to upload CLIP videos + """ + + def clip_upload( + self, + path: Path, + caption: str, + thumbnail: Path = None, + usertags: List[Usertag] = [], + location: Location = None, + configure_timeout: int = 10, + feed_show : str = '1', + extra_data: Dict[str, str] = {}, + ) -> Media: + """ + Upload CLIP to Instagram + + Parameters + ---------- + path: Path + Path to CLIP file + caption: str + Media caption + thumbnail: Path, optional + Path to thumbnail for CLIP. 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 + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Media + An object of Media class + """ + path = Path(path) + if thumbnail is not None: + thumbnail = Path(thumbnail) + upload_id = str(int(time.time() * 1000)) + thumbnail, width, height, duration = analyze_video(path, thumbnail) + waterfall_id = str(uuid4()) + # upload_name example: '1576102477530_0_7823256191' + upload_name = "{upload_id}_0_{rand}".format( + upload_id=upload_id, rand=random.randint(1000000000, 9999999999) + ) + # by segments bb2c1d0c127384453a2122e79e4c9a85-0-6498763 + # upload_name = "{hash}-0-{rand}".format( + # hash="bb2c1d0c127384453a2122e79e4c9a85", rand=random.randint(1111111, 9999999) + # ) + rupload_params = { + "is_clips_video": "1", + "retry_context": '{"num_reupload":0,"num_step_auto_retry":0,"num_step_manual_retry":0}', + "media_type": "2", + "xsharing_user_ids": json.dumps([self.user_id]), + "upload_id": upload_id, + "upload_media_duration_ms": str(int(duration * 1000)), + "upload_media_width": str(width), + "upload_media_height": str(height), + } + headers = { + "Accept-Encoding": "gzip", + "X-Instagram-Rupload-Params": json.dumps(rupload_params), + "X_FB_VIDEO_WATERFALL_ID": waterfall_id, + "X-Entity-Type": "video/mp4", + } + response = self.private.get( + "https://{domain}/rupload_igvideo/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + raise ClipNotUpload(response=self.last_response, **self.last_json) + with open(path, "rb") as fp: + clip_data = fp.read() + clip_len = str(len(clip_data)) + headers = { + "Offset": "0", + "X-Entity-Name": upload_name, + "X-Entity-Length": clip_len, + "Content-Type": "application/octet-stream", + "Content-Length": clip_len, + **headers, + } + response = self.private.post( + "https://{domain}/rupload_igvideo/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + data=clip_data, + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + raise ClipNotUpload(response=self.last_response, **self.last_json) + # CONFIGURE + # self.igtv_composer_session_id = self.generate_uuid() #issue + for attempt in range(50): + self.logger.debug(f"Attempt #{attempt} to configure CLIP: {path}") + time.sleep(configure_timeout) + try: + configured = self.clip_configure( + upload_id, + thumbnail, + width, + height, + duration, + caption, + usertags, + location, + feed_show, + extra_data=extra_data + ) + except ClientError as e: + if "Transcode not finished yet" in str(e): + """ + Response 202 status: + {"message": "Transcode not finished yet.", "status": "fail"} + """ + time.sleep(configure_timeout) + continue + raise e + else: + if configured: + media = self.last_json.get("media") + self.expose() + return extract_media_v1(media) + raise ClipConfigureError(response=self.last_response, **self.last_json) + + def clip_configure( + self, + upload_id: str, + thumbnail: Path, + width: int, + height: int, + duration: int, + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + feed_show : str = '1', + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Post Configure CLIP (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 + 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 + extra_data: Dict[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 + """ + self.photo_rupload(Path(thumbnail), upload_id) + usertags = [ + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags + ] + data = { + # "igtv_ads_toggled_on": "0", + "filter_type": "0", + "timezone_offset": str(self.timezone_offset), + "media_folder": "ScreenRecorder", + "location": self.location_build(location), + "source_type": "4", + # "title": title, + "caption": caption, + "usertags": json.dumps({"in": usertags}), + "date_time_original": date_time_original(time.localtime()), + "clips_share_preview_to_feed": feed_show, + "upload_id": upload_id, + # "igtv_composer_session_id": self.igtv_composer_session_id, + "device": self.device, + "length": duration, + "clips": [{"length": duration, "source_type": "4"}], + "extra": {"source_width": width, "source_height": height}, + "audio_muted": False, + "poster_frame_index": 70, + **extra_data + } + return self.private_request( + "media/configure_to_clips/?video=1", + self.with_default_data(data), + with_signature=True, + ) + + +def analyze_video(path: Path, thumbnail: Path = None) -> tuple: + """ + Analyze and crop thumbnail if need + + Parameters + ---------- + path: Path + Path to the video + thumbnail: Path + Path to thumbnail for CLIP + + 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") + + print(f'Analizing CLIP file "{path}"') + video = mp.VideoFileClip(str(path)) + width, height = video.size + if not thumbnail: + thumbnail = f"{path}.jpg" + print(f'Generating thumbnail "{thumbnail}"...') + video.save_frame(thumbnail, t=(video.duration / 2)) + crop_thumbnail(thumbnail) + return thumbnail, width, height, video.duration + + +def crop_thumbnail(path: Path) -> bool: + """ + 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 + offset = (height / 1.78) / 2 + center = width / 2 + # Crop the center of the image + im = im.crop((center - offset, 0, center + offset, height)) + with open(path, "w") as fp: + im.save(fp) + im.close() + return True diff --git a/instagrapi/mixins/collection.py b/instagrapi/mixins/collection.py new file mode 100644 index 0000000..24c6af9 --- /dev/null +++ b/instagrapi/mixins/collection.py @@ -0,0 +1,197 @@ +from typing import List + +from instagrapi.exceptions import CollectionNotFound +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 + + Returns + ------- + List[Collection] + A list of objects of Collection + """ + next_max_id = "" + total_items = [] + while True: + try: + result = self.private_request( + "collections/list/", + params={ + "collection_types": '["ALL_MEDIA_AUTO_COLLECTION","PRODUCT_AUTO_COLLECTION","MEDIA"]', + "max_id": next_max_id, + }, + ) + except Exception as e: + self.logger.exception(e) + return total_items + for item in result["items"]: + total_items.append(extract_collection(item)) + if not result.get("more_available"): + return total_items + next_max_id = result.get("next_max_id", "") + return total_items + + 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 == 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 liked_medias(self, amount: int = 21, last_media_pk: int = 0) -> List[Media]: + """ + Get media you have liked + + Parameters + ---------- + 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 + Returns + ------- + List[Media] + A list of objects of Media + """ + return self.collection_medias("liked", amount, last_media_pk) + + def collection_medias( + self, collection_pk: str, amount: int = 21, last_media_pk: int = 0 + ) -> List[Media]: + """ + Get media in a collection by collection_pk + + Parameters + ---------- + collection_pk: str + 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 + + Returns + ------- + List[Media] + A list of objects of Media + """ + if isinstance(collection_pk, int) or collection_pk.isdigit(): + private_request_endpoint = f"feed/collection/{collection_pk}/" + elif collection_pk.lower() == "liked": + private_request_endpoint = "feed/liked/" + else: + private_request_endpoint = "feed/saved/posts/" + + last_media_pk = last_media_pk and int(last_media_pk) + total_items = [] + next_max_id = "" + amount = int(amount) + found_last_media_pk = False + while True: + try: + result = self.private_request( + private_request_endpoint, + params={"include_igtv_preview": "false", "max_id": next_max_id}, + ) + except Exception as e: + self.logger.exception(e) + break + for item in result["items"]: + if last_media_pk and last_media_pk == item["media"]["pk"]: + found_last_media_pk = True + break + total_items.append(extract_media_v1(item.get("media", item))) + if (amount and len(total_items) >= amount) or found_last_media_pk: + break + if not result.get("more_available"): + break + next_max_id = result.get("next_max_id", "") + return total_items[:amount] if amount else 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 new file mode 100644 index 0000000..fd7ab45 --- /dev/null +++ b/instagrapi/mixins/comment.py @@ -0,0 +1,172 @@ +import random +from typing import List, Optional + +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, amount: int = 20) -> List[Comment]: + """ + Get comments on a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + amount: int, optional + Maximum number of media to return, default is 0 - Inf + + Returns + ------- + List[Comment] + A list of objects of Comment + """ + # TODO: to public or private + def get_comments(): + if result.get("comments"): + for comment in result.get("comments"): + comments.append(extract_comment(comment)) + media_id = self.media_id(media_id) + params = None + comments = [] + result = self.private_request( + f"media/{media_id}/comments/", params + ) + get_comments() + while ((result.get("has_more_comments") and result.get("next_max_id")) + or (result.get("has_more_headload_comments") and result.get("next_min_id"))): + try: + if result.get("has_more_comments"): + params = {"max_id": result.get("next_max_id")} + else: + params = {"min_id": result.get("next_min_id")} + if not (result.get("next_max_id") or result.get("next_min_id") + or result.get("comments")): + break + result = self.private_request( + f"media/{media_id}/comments/", params + ) + get_comments() + except ClientNotFoundError as e: + raise MediaNotFound(e, media_id=media_id, **self.last_json) + except ClientError as e: + if "Media not found" in str(e): + raise MediaNotFound(e, media_id=media_id, **self.last_json) + raise e + if amount and len(comments) >= amount: + break + if amount: + comments = comments[:amount] + return comments + + def media_comment(self, media_id: str, text: str, replied_to_comment_id: Optional[int] = None) -> Comment: + """ + 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) + data = { + "delivery_class": "organic", + "feed_position": "0", + "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, + } + if replied_to_comment_id: + data["replied_to_comment_id"] = int(replied_to_comment_id) + result = self.private_request( + f"media/{media_id}/comment/", + self.with_action_data(data), + ) + return extract_comment(result["comment"]) + + def comment_like(self, comment_pk: int, revert: bool = False) -> bool: + """ + Like a comment on a media + + Parameters + ---------- + comment_pk: int + 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)), + } + name = "unlike" if revert else "like" + result = self.private_request( + f"media/{comment_pk}/comment_{name}/", self.with_action_data(data) + ) + return result["status"] == "ok" + + def comment_unlike(self, comment_pk: int) -> bool: + """ + Unlike a comment on a media + + Parameters + ---------- + comment_pk: int + Unique identifier of a Comment + + Returns + ------- + bool + A boolean value + """ + return self.comment_like(comment_pk, revert=True) + + def comment_bulk_delete(self, media_id: str, comment_pks: List[int]) -> bool: + """ + Delete a comment on a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + comment_pks: List[int] + List of unique identifier of a Comment + + Returns + ------- + bool + A boolean value + """ + media_id = self.media_id(media_id) + data = { + "comment_ids_to_delete": ','.join([str(pk) for pk in comment_pks]), + "container_module": "self_comments_v2_newsfeed_you" + } + result = self.private_request( + f"media/{media_id}/comment/bulk_delete/", + self.with_action_data(data) + ) + return result["status"] == "ok" diff --git a/instagrapi/mixins/direct.py b/instagrapi/mixins/direct.py new file mode 100644 index 0000000..88898f4 --- /dev/null +++ b/instagrapi/mixins/direct.py @@ -0,0 +1,668 @@ +import random +import re +import time +from pathlib import Path +from typing import List, Optional + +from instagrapi.exceptions import ClientNotFoundError, DirectThreadNotFound +from instagrapi.extractors import ( + extract_direct_message, + extract_direct_response, + extract_direct_short_thread, + extract_direct_thread, +) +from instagrapi.types import ( + DirectMessage, + DirectResponse, + DirectShortThread, + DirectThread, +) +from instagrapi.utils import dumps + +SELECTED_FILTERS = ("flagged", "unread") + +try: + from typing import Literal + SELECTED_FILTER = Literal[SELECTED_FILTERS] +except ImportError: + # python <= 3.8 + SELECTED_FILTER = str + + +class DirectMixin: + """ + Helpers for managing Direct Messaging + """ + + def direct_threads(self, amount: int = 20, selected_filter: SELECTED_FILTER = "", + thread_message_limit: Optional[int] = None) -> List[DirectThread]: + """ + 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 = { + "visual_message_return_type": "unseen", + "thread_message_limit": "10", + "persistentBadging": "true", + "limit": "20", + } + if selected_filter: + assert selected_filter in SELECTED_FILTERS, \ + f'Unsupported selected_filter="{selected_filter}" {SELECTED_FILTERS}' + params.update({ + "selected_filter": selected_filter, + "fetch_reason": "manual_refresh", + }) + if thread_message_limit: + params.update({ + "thread_message_limit": thread_message_limit + }) + cursor = None + threads = [] + # self.private_request("direct_v2/get_presence/") + while True: + if cursor: + params["cursor"] = cursor + result = self.private_request("direct_v2/inbox/", params=params) + inbox = result.get("inbox", {}) + for thread in inbox.get("threads", []): + threads.append(extract_direct_thread(thread)) + cursor = inbox.get("oldest_cursor") + if not cursor or (amount and len(threads) >= amount): + break + if amount: + threads = threads[:amount] + return threads + + def direct_pending_inbox(self, amount: int = 20) -> List[DirectThread]: + """ + Get direct message pending 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 = { + "visual_message_return_type": "unseen", + "persistentBadging": "true", + } + cursor = None + threads = [] + # self.private_request("direct_v2/get_presence/") + while True: + if cursor: + params["cursor"] = cursor + result = self.private_request("direct_v2/pending_inbox/", params=params) + inbox = result.get("inbox", {}) + for thread in inbox.get("threads", []): + threads.append(extract_direct_thread(thread)) + cursor = inbox.get("oldest_cursor") + if not cursor or (amount and len(threads) >= amount): + break + if amount: + threads = threads[:amount] + return threads + + def direct_thread(self, thread_id: int, amount: int = 20) -> DirectThread: + """ + 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 = { + "visual_message_return_type": "unseen", + "direction": "older", + "seq_id": "40065", # 59663 + "limit": "20", + } + cursor = None + items = [] + while True: + if cursor: + params["cursor"] = cursor + try: + 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"]: + 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 + return extract_direct_thread(thread) + + def direct_messages(self, thread_id: int, amount: int = 20) -> List[DirectMessage]: + """ + 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: + """ + 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 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 id + + thread_ids: List[int] + List of unique identifier of Direct Message thread id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + assert self.user_id, "Login required" + assert (user_ids or thread_ids) and not (user_ids and thread_ids), "Specify user_ids or thread_ids, but not both" + method = "text" + token = self.generate_mutation_token() + kwargs = { + "action": "send_item", + "is_shh_mode": "0", + "send_attribution": "direct_thread", + "client_context": token, + "mutation_token": token, + "nav_chain": "1qT:feed_timeline:1,1qT:feed_timeline:2,1qT:feed_timeline:3,7Az:direct_inbox:4,7Az:direct_inbox:5,5rG:direct_thread:7", + "offline_threading_id": token, + } + if "http" in text: + method = "link" + kwargs["link_text"] = 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]]) + result = self.private_request( + f"direct_v2/threads/broadcast/{method}/", + data=self.with_default_data(kwargs), + with_signature=False + ) + return extract_direct_message(result["payload"]) + + def direct_send_photo(self, path: Path, user_ids: List[int] = [], thread_ids: List[int] = []) -> DirectMessage: + """ + Send a direct photo to list of users or threads + + Parameters + ---------- + path: Path + Path to photo that will be posted on the thread + user_ids: List[int] + List of unique identifier of Users id + thread_ids: List[int] + List of unique identifier of Direct Message thread id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + return self.direct_send_file(path, user_ids, thread_ids, content_type='photo') + + def direct_send_video(self, path: Path, user_ids: List[int] = [], thread_ids: List[int] = []) -> DirectMessage: + """ + Send a direct video to list of users or threads + + Parameters + ---------- + path: Path + Path to video that will be posted on the thread + user_ids: List[int] + List of unique identifier of Users id + thread_ids: List[int] + List of unique identifier of Direct Message thread id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + return self.direct_send_file(path, user_ids, thread_ids, content_type='video') + + def direct_send_file(self, path: Path, user_ids: List[int] = [], thread_ids: List[int] = [], content_type: str = 'photo') -> DirectMessage: + """ + Send a direct file to list of users or threads + + Parameters + ---------- + path: Path + Path to file that will be posted on the thread + user_ids: List[int] + List of unique identifier of Users id + thread_ids: List[int] + List of unique identifier of Direct Message thread id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + assert self.user_id, "Login required" + assert (user_ids or thread_ids) and not (user_ids and thread_ids), "Specify user_ids or thread_ids, but not both" + method = f"configure_{content_type}" + token = self.generate_mutation_token() + nav_chains = [ + "6xQ:direct_media_picker_photos_fragment:1,5rG:direct_thread:2,5ME:direct_quick_camera_fragment:3,5ME:direct_quick_camera_fragment:4,4ju:reel_composer_preview:5,5rG:direct_thread:6,5rG:direct_thread:7,6xQ:direct_media_picker_photos_fragment:8,5rG:direct_thread:9", + "1qT:feed_timeline:1,7Az:direct_inbox:2,7Az:direct_inbox:3,5rG:direct_thread:4,6xQ:direct_media_picker_photos_fragment:5,5rG:direct_thread:6,5rG:direct_thread:7,6xQ:direct_media_picker_photos_fragment:8,5rG:direct_thread:9", + ] + kwargs = {} + data = { + "action": "send_item", + "is_shh_mode": "0", + "send_attribution": "direct_thread", + "client_context": token, + "mutation_token": token, + "nav_chain": random.choices(nav_chains), + "offline_threading_id": token, + } + if content_type == "video": + data["video_result"] = "" + kwargs["to_direct"] = True + if content_type == "photo": + data["send_attribution"] = "inbox" + data["allow_full_aspect_ratio"] = "true" + if user_ids: + data["recipient_users"] = dumps([[int(uid) for uid in user_ids]]) + if thread_ids: + data["thread_ids"] = dumps([int(tid) for tid in thread_ids]) + path = Path(path) + upload_id = str(int(time.time() * 1000)) + upload_id, width, height = getattr(self, f'{content_type}_rupload')(path, upload_id, **kwargs)[:3] + data['upload_id'] = upload_id + # data['content_type'] = content_type + result = self.private_request( + f"direct_v2/threads/broadcast/{method}/", + data=self.with_default_data(data), + with_signature=False, + ) + return extract_direct_message(result["payload"]) + + def direct_send_seen(self, thread_id: int) -> DirectResponse: + """ + Send seen to thread + + Parameters + ---------- + thread_id: int + Id of thread which messages will be read + + Returns + ------- + An object of DirectResponse + """ + data = {} + + thread = self.direct_thread(thread_id=thread_id) + result = self.private_request( + f"direct_v2/threads/{thread_id}/items/{thread.messages[0].id}/seen/", + data=self.with_default_data(data), + with_signature=False, + ) + return extract_direct_response(result) + + def direct_search(self, query: str) -> List[DirectShortThread]: + """ + Search threads by query + + Parameters + ---------- + query: String + Text query, e.g. username + + Returns + ------- + List[DirectShortThread] + List of short version of DirectThread + """ + result = self.private_request( + "direct_v2/ranked_recipients/", + params={"mode": "raven", "show_threads": "true", "query": str(query)} + ) + return [ + extract_direct_short_thread(item.get('thread', {})) + for item in result.get('ranked_recipients', []) + if 'thread' in item + ] + + def direct_thread_by_participants(self, user_ids: List[int]) -> DirectThread: + """ + Get direct thread by participants + + Parameters + ---------- + user_ids: List[int] + List of unique identifier of Users id + + Returns + ------- + DirectThread + An object of DirectThread + """ + recipient_users = dumps([int(uid) for uid in user_ids]) + result = self.private_request( + "direct_v2/threads/get_by_participants/", + params={"recipient_users": recipient_users, "seq_id": 2580572, "limit": 20} + ) + if 'thread' not in result: + raise DirectThreadNotFound( + f'Thread not found by recipient_users={recipient_users}', + user_ids=user_ids, + **self.last_json + ) + return extract_direct_thread(result['thread']) + + def direct_thread_hide(self, thread_id: int) -> bool: + """ + Hide (delete) a thread + When you click delete, Instagram hides a thread + + Parameters + ---------- + thread_id: int + Id of thread which messages will be read + + Returns + ------- + bool + A boolean value + """ + data = self.with_default_data({}) + data.pop('_uid', None) + data.pop('device_id', None) + result = self.private_request( + f"direct_v2/threads/{thread_id}/hide/", + data=data + ) + return result["status"] == "ok" + + def direct_media_share(self, media_id: str, user_ids: List[int]) -> DirectMessage: + """ + Share a media to list of users + + Parameters + ---------- + media_id: str + Unique Media ID + user_ids: List[int] + List of unique identifier of Users id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + assert self.user_id, "Login required" + token = self.generate_mutation_token() + media_id = self.media_id(media_id) + recipient_users = dumps([[int(uid) for uid in user_ids]]) + data = { + 'recipient_users': recipient_users, + 'action': 'send_item', + 'is_shh_mode': 0, + 'send_attribution': 'feed_timeline', + 'client_context': token, + 'media_id': media_id, + 'mutation_token': token, + 'nav_chain': '1VL:feed_timeline:1,1VL:feed_timeline:2,1VL:feed_timeline:5,DirectShareSheetFragment:direct_reshare_sheet:6', + 'offline_threading_id': token, + } + result = self.private_request( + "direct_v2/threads/broadcast/media_share/", + # params={'media_type': 'video'}, + data=self.with_default_data(data), + with_signature=False, + ) + return extract_direct_message(result["payload"]) + + def direct_story_share(self, story_id: str, user_ids: List[int] = [], thread_ids: List[int] = []) -> DirectMessage: + """ + Share a story to list of users + + Parameters + ---------- + story_id: str + Unique Story ID + user_ids: List[int] + List of unique identifier of Users id + thread_ids: List[int] + List of unique identifier of Users id + + Returns + ------- + DirectMessage + An object of DirectMessage + """ + assert self.user_id, "Login required" + assert (user_ids or thread_ids) and not (user_ids and thread_ids), "Specify user_ids or thread_ids, but not both" + story_id = self.media_id(story_id) + story_pk = self.media_pk(story_id) + token = self.generate_mutation_token() + data = { + "action": "send_item", + "is_shh_mode": "0", + "send_attribution": "reel_feed_timeline", + "client_context": token, + "mutation_token": token, + "nav_chain": "1qT:feed_timeline:1,ReelViewerFragment:reel_feed_timeline:4,DirectShareSheetFragment:direct_reshare_sheet:5", + "reel_id": story_pk, + "containermodule": "reel_feed_timeline", + "story_media_id": story_id, + "offline_threading_id": token, + } + if user_ids: + data["recipient_users"] = dumps([[int(uid) for uid in user_ids]]) + if thread_ids: + data["thread_ids"] = dumps([int(tid) for tid in thread_ids]) + result = self.private_request( + "direct_v2/threads/broadcast/story_share/", + # params={'story_type': 'video'}, + data=self.with_default_data(data), + with_signature=False, + ) + return extract_direct_message(result["payload"]) + + def direct_thread_mark_unread(self, thread_id: int) -> bool: + """ + Mark a thread as unread + + Parameters + ---------- + thread_id: int + Id of thread + + Returns + ------- + bool + A boolean value + """ + data = self.with_default_data({}) + data.pop('_uid', None) + data.pop('device_id', None) + result = self.private_request( + f"direct_v2/threads/{thread_id}/mark_unread/", + data=data + ) + return result["status"] == "ok" + + def direct_message_delete(self, thread_id: int, message_id: int) -> bool: + """ + Delete a message from thread + + Parameters + ---------- + thread_id: int + Id of thread + message_id: int + Id of message + + Returns + ------- + bool + A boolean value + """ + data = self.with_default_data({}) + data.pop('_uid', None) + data.pop('device_id', None) + data['is_shh_mode'] = 0 + data['send_attribution'] = 'direct_thread' + data['original_message_client_context'] = self.generate_mutation_token() + result = self.private_request( + f"direct_v2/threads/{thread_id}/items/{message_id}/delete/", + data=data + ) + return result["status"] == "ok" + + def direct_thread_mute(self, thread_id: int, revert: bool = False) -> bool: + """ + Mute the thread + + Parameters + ---------- + thread_id: int + Id of thread + revert: bool, optional + If muted, whether or not to unmute. Default is False + + Returns + ------- + bool + A boolean value + """ + name = "unmute" if revert else "mute" + result = self.private_request( + f"direct_v2/threads/{thread_id}/{name}/", + data={'_uuid': self.uuid} + ) + return result["status"] == "ok" + + def direct_thread_unmute(self, thread_id: int) -> bool: + """ + Unmute the thread + + Parameters + ---------- + thread_id: int + Id of thread + + Returns + ------- + bool + A boolean value + """ + return self.direct_thread_mute(thread_id, revert=True) + + def direct_thread_mute_video_call(self, thread_id: int, revert: bool = False) -> bool: + """ + Mute video call for the thread + + Parameters + ---------- + thread_id: int + Id of thread + revert: bool, optional + If muted, whether or not to unmute. Default is False + + Returns + ------- + bool + A boolean value + """ + name = "unmute_video_call" if revert else "mute_video_call" + result = self.private_request( + f"direct_v2/threads/{thread_id}/{name}/", + data={'_uuid': self.uuid} + ) + return result["status"] == "ok" + + def direct_thread_unmute_video_call(self, thread_id: int) -> bool: + """ + Unmute video call for the thread + + Parameters + ---------- + thread_id: int + Id of thread + + Returns + ------- + bool + A boolean value + """ + return self.direct_thread_mute_video_call(thread_id, revert=True) diff --git a/instagrapi/mixins/fbsearch.py b/instagrapi/mixins/fbsearch.py new file mode 100644 index 0000000..da7fce1 --- /dev/null +++ b/instagrapi/mixins/fbsearch.py @@ -0,0 +1,73 @@ +from typing import List + +from instagrapi.extractors import ( + extract_hashtag_v1, + extract_location, + extract_track, + extract_user_short, +) +from instagrapi.types import Hashtag, Location, Track, UserShort + + +class FbSearchMixin: + + def fbsearch_places(self, query: str, lat: float = 40.74, lng: float = -73.94) -> List[Location]: + params = { + 'search_surface': 'places_search_page', + 'timezone_offset': self.timezone_offset, + 'lat': lat, + 'lng': lng, + 'count': 30, + 'query': query, + } + result = self.private_request("fbsearch/places/", params=params) + locations = [] + for item in result['items']: + locations.append(extract_location(item['location'])) + return locations + + def fbsearch_topsearch_flat(self, query: str) -> List[dict]: + params = { + "search_surface": "top_search_page", + "context": "blended", + "timezone_offset": self.timezone_offset, + "count": 30, + "query": query, + } + result = self.private_request("fbsearch/topsearch_flat/", params=params) + return result["list"] + + def search_users(self, query: str) -> List[UserShort]: + params = { + "search_surface": "user_search_page", + "timezone_offset": self.timezone_offset, + "count": 30, + "q": query, + } + result = self.private_request("users/search/", params=params) + return [extract_user_short(item) for item in result["users"]] + + def search_music(self, query: str) -> List[Track]: + params = { + "query": query, + "browse_session_id": self.generate_uuid(), + } + result = self.private_request("music/audio_global_search/", params=params) + return [extract_track(item["track"]) for item in result["items"]] + + def search_hashtags(self, query: str) -> List[Hashtag]: + params = { + "search_surface": "hashtag_search_page", + "timezone_offset": self.timezone_offset, + "count": 30, + "q": query, + } + result = self.private_request("tags/search/", params=params) + return [extract_hashtag_v1(ht) for ht in result["results"]] + def fbsearch_suggested_profiles(self, user_id: str) -> List[UserShort]: + params = { + "target_user_id": user_id, + "include_friendship_status": "true", + } + result = self.private_request("fbsearch/accounts_recs/", params=params) + return result["users"] diff --git a/instagrapi/mixins/hashtag.py b/instagrapi/mixins/hashtag.py new file mode 100644 index 0000000..adde111 --- /dev/null +++ b/instagrapi/mixins/hashtag.py @@ -0,0 +1,421 @@ +from typing import List, Tuple + +from instagrapi.exceptions import ClientError, 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 information about a hashtag by Public Web API + + 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) + 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 information about a hashtag by Public Graphql API + + 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 information about a hashtag by Private Mobile API + + 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 information about a hashtag + + Parameters + ---------- + name: str + Name of the hashtag + + Returns + ------- + Hashtag + An object of Hashtag + """ + try: + hashtag = self.hashtag_info_a1(name) + except Exception: + # Users do not understand the output of such information and create bug reports + # such this - https://github.com/adw0rd/instagrapi/issues/364 + # if not isinstance(e, ClientError): + # self.logger.exception(e) + hashtag = self.hashtag_info_v1(name) + return hashtag + + def hashtag_related_hashtags(self, name: str) -> List[Hashtag]: + """ + 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"] + ] + + def hashtag_medias_a1_chunk( + self, name: str, max_amount: int = 27, tab_key: str = "", end_cursor: str = None + ) -> Tuple[List[Media], str]: + """ + Get chunk of medias and end_cursor by Public Web API + + Parameters + ---------- + name: str + Name of the hashtag + max_amount: int, optional + Maximum number of media to return, default is 27 + tab_key: str, optional + Tab Key, default value is "" + end_cursor: str, optional + End Cursor, default value is None + + Returns + ------- + Tuple[List[Media], str] + List of objects of Media and end_cursor + """ + assert tab_key in ("edge_hashtag_to_top_posts", "edge_hashtag_to_media"), \ + 'You must specify one of the options for "tab_key" ("edge_hashtag_to_top_posts" or "edge_hashtag_to_media")' + unique_set = set() + medias = [] + while True: + data = self.public_a1_request( + 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"] + for edge in edges: + if max_amount and len(medias) >= max_amount: + break + # check uniq + media_pk = edge["node"]["id"] + if media_pk in unique_set: + continue + unique_set.add(media_pk) + # check contains hashtag in caption + media = extract_media_gql(edge["node"]) + if f"#{name}" not in media.caption_text: + continue + # Enrich media: Full user, usertags and video_url + medias.append(self.media_info_gql(media_pk)) + ###################################################### + # 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 max_amount and len(medias) >= max_amount: + # break + break + return medias, end_cursor + + def hashtag_medias_a1( + self, name: str, amount: int = 27, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a hashtag by Public Web API + + 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 + """ + medias, _ = self.hashtag_medias_a1_chunk(name, amount, tab_key) + if amount: + medias = medias[:amount] + return medias + + def hashtag_medias_v1_chunk( + self, name: str, max_amount: int = 27, tab_key: str = "", max_id: str = None + ) -> Tuple[List[Media], str]: + """ + Get chunk of medias for a hashtag and max_id (cursor) by Private Mobile API + + Parameters + ---------- + name: str + Name of the hashtag + max_amount: int, optional + Maximum number of media to return, default is 27 + tab_key: str, optional + Tab Key, default value is "" + max_id: str + Max ID, default value is None + + Returns + ------- + Tuple[List[Media], str] + List of objects of Media and max_id + """ + assert tab_key in ("top", "recent"), \ + 'You must specify one of the options for "tab_key" ("top" or "recent")' + data = { + "supported_tabs": dumps([tab_key]), + # 'lat': 59.8626416, + # 'lng': 30.5126682, + "include_persistent": "true", + "rank_token": self.rank_token, + "count": 10000, + } + medias = [] + while True: + result = self.private_request( + f"tags/{name}/sections/", + params={"max_id": max_id} if max_id else {}, + 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 node in nodes: + if max_amount and len(medias) >= max_amount: + break + media = extract_media_v1(node["media"]) + # check contains hashtag in caption + if f"#{name}" not in media.caption_text: + continue + medias.append(media) + if not result["more_available"]: + break + if max_amount and len(medias) >= max_amount: + break + max_id = result["next_max_id"] + return medias, max_id + + def hashtag_medias_v1( + self, name: str, amount: int = 27, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a hashtag by Private Mobile API + + 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 + """ + medias, _ = self.hashtag_medias_v1_chunk(name, amount, tab_key) + if amount: + medias = medias[:amount] + return medias + + def hashtag_medias_top_a1(self, name: str, amount: int = 9) -> List[Media]: + """ + Get top medias for a hashtag by Public Web API + + 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]: + """ + Get top medias for a hashtag by Private Mobile API + + 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]: + """ + 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: + medias = self.hashtag_medias_top_a1(name, amount) + except ClientError: + medias = self.hashtag_medias_top_v1(name, amount) + return medias + + def hashtag_medias_recent_a1(self, name: str, amount: int = 71) -> List[Media]: + """ + Get recent medias for a hashtag by Public Web API + + 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]: + """ + Get recent medias for a hashtag by Private Mobile API + + 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]: + """ + 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: + medias = self.hashtag_medias_recent_a1(name, amount) + except ClientError: + medias = self.hashtag_medias_recent_v1(name, amount) + return medias diff --git a/instagrapi/mixins/highlight.py b/instagrapi/mixins/highlight.py new file mode 100644 index 0000000..ccc4ba7 --- /dev/null +++ b/instagrapi/mixins/highlight.py @@ -0,0 +1,269 @@ +import json +import random +import time +from pathlib import Path +from typing import Dict, List +from urllib.parse import urlparse + +from instagrapi import config +from instagrapi.exceptions import HighlightNotFound +from instagrapi.extractors import extract_highlight_v1 +from instagrapi.types import Highlight +from instagrapi.utils import dumps + + +class HighlightMixin: + + def highlight_pk_from_url(self, url: str) -> str: + """ + Get Highlight PK from URL + + Parameters + ---------- + url: str + URL of highlight + + Returns + ------- + str + Highlight PK + + Examples + -------- + https://www.instagram.com/stories/highlights/17895485201104054/ -> 17895485201104054 + """ + assert '/highlights/' in url, 'URL must contain "/highlights/"' + path = urlparse(url).path + parts = [p for p in path.split("/") if p and p.isdigit()] + return str(parts[0]) + + def user_highlights_v1(self, user_id: int, amount: int = 0) -> List[Highlight]: + """ + Get a user's highlight + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of highlight to return, default is 0 (all highlights) + + Returns + ------- + List[Highlight] + A list of objects of Highlight + """ + amount = int(amount) + user_id = int(user_id) + params = { + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), + "phone_id": self.phone_id, + "battery_level": random.randint(25, 100), + "is_charging": random.randint(0, 1), + "will_sound_on": random.randint(0, 1), + } + result = self.private_request(f"highlights/{user_id}/highlights_tray/", params=params) + return [ + extract_highlight_v1(highlight) + for highlight in result.get("tray", []) + ] + + def user_highlights(self, user_id: int, amount: int = 0) -> List[Highlight]: + """ + Get a user's highlights + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of highlight to return, default is 0 (all highlights) + + Returns + ------- + List[Highlight] + A list of objects of Highlight + """ + return self.user_highlights_v1(user_id, amount) + + def highlight_info_v1(self, highlight_pk: str) -> Highlight: + """ + Get Highlight by pk or id (by Private Mobile API) + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + + Returns + ------- + Highlight + An object of Highlight type + """ + highlight_id = f"highlight:{highlight_pk}" + data = { + "exclude_media_ids": "[]", + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), + "source": "profile", + "_uid": str(self.user_id), + "_uuid": self.uuid, + "user_ids": [highlight_id] + } + result = self.private_request('feed/reels_media/', data) + data = result['reels'] + if highlight_id not in data: + raise HighlightNotFound(highlight_pk=highlight_pk, **data) + return extract_highlight_v1(data[highlight_id]) + + def highlight_info(self, highlight_pk: str) -> Highlight: + """ + Get Highlight by pk or id + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + + Returns + ------- + Highlight + An object of Highlight type + """ + return self.highlight_info_v1(highlight_pk) + + def highlight_create(self, title: str, story_ids: List[str], cover_story_id: str = "", crop_rect: List[float] = [0.0, 0.21830457, 1.0, 0.78094524]) -> Highlight: + """ + Create highlight + + Parameters + ---------- + title: str + Title + story_ids: List[str] + List of story ids + cover_story_id: str + User story as cover, default is first of story_ids + + Returns + ------- + Highlight + An object of Highlight type + """ + if not cover_story_id: + cover_story_id = story_ids[0] + data = { + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), + "source": "self_profile", + "creation_id": str(int(time.time())), + "_uid": str(self.user_id), + "_uuid": self.uuid, + "cover": dumps({ + "media_id": self.media_id(cover_story_id), + "crop_rect": dumps(crop_rect) + }), + "title": title, + "media_ids": dumps([self.media_id(sid) for sid in story_ids]) + } + result = self.private_request("highlights/create_reel/", data=data) + return extract_highlight_v1(result['reel']) + + def highlight_edit(self, highlight_pk: str, title: str = "", cover: Dict = {}, added_media_ids: List[str] = [], removed_media_ids: List[str] = []): + data = { + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), + "source": "self_profile", + "_uid": str(self.user_id), + "_uuid": self.uuid, + "added_media_ids": dumps(added_media_ids), + "removed_media_ids": dumps(removed_media_ids) + } + if title: + data["title"] = title + if cover: + data["cover"] = dumps(cover) + result = self.private_request(f"highlights/highlight:{highlight_pk}/edit_reel/", data=data) + return extract_highlight_v1(result['reel']) + + def highlight_change_title(self, highlight_pk: str, title: str) -> Highlight: + """ + Change title for highlight + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + title: str + Title of Highlight + + Returns + ------- + Highlight + """ + return self.highlight_edit(highlight_pk, title=title) + + def highlight_change_cover(self, highlight_pk: str, cover_path: Path) -> Highlight: + """ + Change cover for highlight + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + cover_path: Path + Path to photo + + Returns + ------- + Highlight + """ + upload_id, width, height = self.photo_rupload(Path(cover_path)) + cover = {"upload_id": str(upload_id), "crop_rect": "[0.0,0.0,1.0,1.0]"} + return self.highlight_edit(highlight_pk, cover=cover) + + def highlight_add_stories(self, highlight_pk: str, added_media_ids: List[str]) -> Highlight: + """ + Add stories to highlight + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + removed_media_ids: List[str] + Remove stories from highlight + + Returns + ------- + Highlight + """ + return self.highlight_edit(highlight_pk, added_media_ids=added_media_ids) + + def highlight_remove_stories(self, highlight_pk: str, removed_media_ids: List[str]) -> Highlight: + """ + Remove stories from highlight + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + removed_media_ids: List[str] + Remove stories from highlight + + Returns + ------- + Highlight + """ + return self.highlight_edit(highlight_pk, removed_media_ids=removed_media_ids) + + def highlight_delete(self, highlight_pk: str) -> bool: + """ + Delete highlight + + Parameters + ---------- + highlight_pk: str + Unique identifier of Highlight + + Returns + ------- + bool + """ + data = {"_uid": str(self.user_id), "_uuid": self.uuid} + result = self.private_request(f"highlights/highlight:{highlight_pk}/delete_reel/", data=data) + return result.get("status") == "ok" diff --git a/instagrapi/mixins/igtv.py b/instagrapi/mixins/igtv.py new file mode 100644 index 0000000..d3b04c5 --- /dev/null +++ b/instagrapi/mixins/igtv.py @@ -0,0 +1,333 @@ +import json +import random +import time +from pathlib import Path +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.types import Location, Media, Usertag +from instagrapi.utils import date_time_original + +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=8.1.1") + + +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: + """ + 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, + path: Path, + title: str, + caption: str, + thumbnail: Path = None, + usertags: List[Usertag] = [], + location: Location = None, + configure_timeout: int = 10, + extra_data: Dict[str, str] = {}, + ) -> 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 + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Media + An object of Media class + """ + path = Path(path) + if thumbnail is not None: + thumbnail = Path(thumbnail) + upload_id = str(int(time.time() * 1000)) + thumbnail, width, height, duration = analyze_video(path, thumbnail) + waterfall_id = str(uuid4()) + # upload_name example: '1576102477530_0_7823256191' + upload_name = "{upload_id}_0_{rand}".format( + upload_id=upload_id, rand=random.randint(1000000000, 9999999999) + ) + # by segments bb2c1d0c127384453a2122e79e4c9a85-0-6498763 + # upload_name = "{hash}-0-{rand}".format( + # hash="bb2c1d0c127384453a2122e79e4c9a85", rand=random.randint(1111111, 9999999) + # ) + rupload_params = { + "is_igtv_video": "1", + "retry_context": '{"num_step_auto_retry":0,"num_reupload":0,"num_step_manual_retry":0}', + "media_type": "2", + "xsharing_user_ids": json.dumps([self.user_id]), + "upload_id": upload_id, + "upload_media_duration_ms": str(int(duration * 1000)), + "upload_media_width": str(width), + "upload_media_height": str(height), + } + headers = { + "Accept-Encoding": "gzip", + "X-Instagram-Rupload-Params": json.dumps(rupload_params), + "X_FB_VIDEO_WATERFALL_ID": waterfall_id, + "X-Entity-Type": "video/mp4", + } + response = self.private.get( + "https://{domain}/rupload_igvideo/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + raise IGTVNotUpload(response=self.last_response, **self.last_json) + with open(path, "rb") as fp: + igtv_data = fp.read() + igtv_len = str(len(igtv_data)) + headers = { + "Offset": "0", + "X-Entity-Name": upload_name, + "X-Entity-Length": igtv_len, + "Content-Type": "application/octet-stream", + "Content-Length": igtv_len, + **headers, + } + response = self.private.post( + "https://{domain}/rupload_igvideo/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + data=igtv_data, + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + raise IGTVNotUpload(response=self.last_response, **self.last_json) + # CONFIGURE + self.igtv_composer_session_id = self.generate_uuid() + for attempt in range(50): + self.logger.debug(f"Attempt #{attempt} to configure IGTV: {path}") + time.sleep(configure_timeout) + try: + configured = self.igtv_configure( + upload_id, + thumbnail, + width, + height, + duration, + title, + caption, + usertags, + location, + extra_data=extra_data + ) + except ClientError as e: + if "Transcode not finished yet" in str(e): + """ + Response 202 status: + {"message": "Transcode not finished yet.", "status": "fail"} + """ + time.sleep(configure_timeout) + continue + raise e + else: + if configured: + media = self.last_json.get("media") + self.expose() + return extract_media_v1(media) + raise IGTVConfigureError(response=self.last_response, **self.last_json) + + def igtv_configure( + self, + upload_id: str, + thumbnail: Path, + width: int, + height: int, + duration: int, + title: str, + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> 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 + extra_data: Dict[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 + """ + self.photo_rupload(Path(thumbnail), upload_id) + usertags = [ + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags + ] + data = { + "igtv_ads_toggled_on": "0", + "filter_type": "0", + "timezone_offset": str(self.timezone_offset), + "media_folder": "ScreenRecorder", + "location": self.location_build(location), + "source_type": "4", + "title": title, + "caption": caption, + "usertags": json.dumps({"in": usertags}), + "date_time_original": date_time_original(time.localtime()), + "igtv_share_preview_to_feed": "1", + "upload_id": upload_id, + "igtv_composer_session_id": self.igtv_composer_session_id, + "device": self.device, + "length": duration, + "clips": [{"length": duration, "source_type": "4"}], + "extra": {"source_width": width, "source_height": height}, + "audio_muted": False, + "poster_frame_index": 70, + **extra_data + } + return self.private_request( + "media/configure_to_igtv/?video=1", + self.with_default_data(data), + with_signature=True, + ) + + +def analyze_video(path: Path, thumbnail: Path = None) -> tuple: + """ + 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") + + print(f'Analizing IGTV file "{path}"') + video = mp.VideoFileClip(str(path)) + width, height = video.size + if not thumbnail: + thumbnail = f"{path}.jpg" + print(f'Generating thumbnail "{thumbnail}"...') + video.save_frame(thumbnail, t=(video.duration / 2)) + crop_thumbnail(thumbnail) + return thumbnail, width, height, video.duration + + +def crop_thumbnail(path: Path) -> bool: + """ + 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 + offset = (height / 1.78) / 2 + center = width / 2 + # Crop the center of the image + im = im.crop((center - offset, 0, center + offset, height)) + with open(path, "w") as fp: + im.save(fp) + im.close() + return True diff --git a/instagrapi/mixins/insights.py b/instagrapi/mixins/insights.py new file mode 100644 index 0000000..91bacd0 --- /dev/null +++ b/instagrapi/mixins/insights.py @@ -0,0 +1,193 @@ +import time +from typing import Dict, List + +from instagrapi.exceptions import ClientError, MediaError, UserError +from instagrapi.utils import json_value + +POST_TYPES = ("ALL", "CAROUSEL_V2", "IMAGE", "SHOPPING", "VIDEO") +TIME_FRAMES = ( + "ONE_WEEK", "ONE_MONTH", "THREE_MONTHS", "SIX_MONTHS", + "ONE_YEAR", "TWO_YEARS" +) +DATA_ORDERS = ( + "REACH_COUNT", "LIKE_COUNT", "FOLLOW", "SHARE_COUNT", + "BIO_LINK_CLICK", "COMMENT_COUNT", "IMPRESSION_COUNT", + "PROFILE_VIEW", "VIDEO_VIEW_COUNT", "SAVE_COUNT" +) + +try: + from typing import Literal + POST_TYPE = Literal[POST_TYPES] + TIME_FRAME = Literal[TIME_FRAMES] + DATA_ORDERING = Literal[DATA_ORDERS] +except ImportError: + # python <= 3.8 + POST_TYPE = TIME_FRAME = DATA_ORDERING = str + + +class InsightsMixin: + """ + Helper class to get insights + """ + + def insights_media_feed_all( + self, + post_type: POST_TYPE = "ALL", + time_frame: TIME_FRAME = "TWO_YEARS", + data_ordering: DATA_ORDERING = "REACH_COUNT", + count: int = 0, + sleep: int = 2, + ) -> 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" + time_frame: str, optional + Time frame to pull media insights, default is "TWO_YEARS" + data_ordering: str, optional + Ordering strategy for the data, default is "REACH_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 post_type in POST_TYPES, \ + f'Unsupported post_type="{post_type}" {POST_TYPES}' + assert time_frame in TIME_FRAMES, \ + f'Unsupported time_frame="{time_frame}" {TIME_FRAMES}' + assert data_ordering in DATA_ORDERS, \ + f'Unsupported data_ordering="{data_ordering}" {DATA_ORDERS}' + assert self.user_id, "Login required" + medias = [] + cursor = None + data = { + "surface": "post_grid", + "doc_id": 2345520318892697, + "locale": "en_US", + "vc_policy": "insights_policy", + "strip_nulls": False, + "strip_defaults": False, + } + query_params = { + "IgInsightsGridMediaImage_SIZE": 480, + "count": 200, # TODO Try to detect max allowed value + # "cursor": "0", + "dataOrdering": data_ordering, + "postType": post_type, + "timeframe": time_frame, + "search_base": "USER", + "is_user": "true", + "queryParams": {"access_token": "", "id": self.user_id}, + } + while True: + if cursor: + query_params["cursor"] = cursor + result = self.private_request( + "ads/graphql/", + self.with_query_params(data, query_params), + ) + if not json_value( + result, + "data", + "shadow_instagram_user", + "business_manager", + default=None, + ): + raise UserError("Account is not business account", **self.last_json) + stats = json_value( + result, "data", "shadow_instagram_user", + "business_manager", "top_posts_unit", "top_posts" + ) + cursor = stats["page_info"]["end_cursor"] + medias.extend(stats["edges"]) + if not stats["page_info"]["has_next_page"]: + break + if count and len(medias) >= count: + break + time.sleep(sleep) + if count: + medias = medias[:count] + return medias + + """ + 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 = { + "surface": "account", + "doc_id": 2449243051851783, + "locale": "en_US", + "vc_policy": "insights_policy", + "strip_nulls": False, + "strip_defaults": False, + } + query_params = { + "IgInsightsGridMediaImage_SIZE": 360, + "activityTab": True, + "audienceTab": True, + "contentTab": True, + "query_params": {"access_token": "", "id": self.user_id}, + } + + result = self.private_request( + "ads/graphql/", + self.with_query_params(data, query_params), + ) + res = json_value(result, "data", "shadow_instagram_user", "business_manager") + if not res: + 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 + + 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) + data = { + "surface": "post", + "doc_id": 3221905377882880, + "locale": "en_US", + "vc_policy": "insights_policy", + "strip_nulls": False, + "strip_defaults": False, + } + query_params = { + "query_params": {"access_token": "", "id": media_pk}, + } + try: + result = self.private_request( + "ads/graphql/", + self.with_query_params(data, query_params), + ) + 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 new file mode 100644 index 0000000..7c887a5 --- /dev/null +++ b/instagrapi/mixins/location.py @@ -0,0 +1,482 @@ +import json +import time +from typing import List, Tuple + +from instagrapi.exceptions import ClientNotFoundError, LocationNotFound +from instagrapi.extractors import extract_location, extract_media_v1 +from instagrapi.types import Location, Media + +tab_keys_a1 = ('edge_location_to_top_posts', 'edge_location_to_media') +tab_keys_v1 = ('ranked', 'recent') + + +class LocationMixin: + """ + Helper class to get location + """ + + def location_search(self, lat: float, lng: float) -> 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 + """ + params = { + "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 + locations.append(extract_location(venue)) + return locations + + def location_complete(self, location: Location) -> 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) + location.lat = info.lat + location.lng = info.lng + if not location.external_id and location.lat: + # search extrernal_id and external_id_source + try: + venue = self.location_search(location.lat, location.lng)[0] + location.external_id = venue.external_id + location.external_id_source = venue.external_id_source + except IndexError: + 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 + ): + location.pk = location.external_id + return location + + def location_build(self, location: Location) -> str: + """ + Build correct location data + + Parameters + ---------- + location: Location + An object of location + + Returns + ------- + str + """ + 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 = { + "name": location.name, + "address": location.address, + "lat": location.lat, + "lng": location.lng, + "external_source": location.external_id_source, + "facebook_places_id": location.external_id, + } + return json.dumps(data, separators=(",", ":")) + + def location_info_a1(self, location_pk: int) -> 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}/") or {} + 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_v1(self, location_pk: int) -> Location: + """ + Get a location using location pk + + Parameters + ---------- + location_pk: int + Unique identifier for a location + + Returns + ------- + Location + An object of Location + """ + result = self.private_request(f"locations/{location_pk}/location_info/") + return extract_location(result) + + def location_info(self, location_pk: int) -> Location: + """ + Get a location using location pk + + Parameters + ---------- + location_pk: int + Unique identifier for a location + + Returns + ------- + Location + An object of Location + """ + try: + location = self.location_info_a1(location_pk) + except Exception: + # Users do not understand the output of such information and create bug reports + # such this - https://github.com/adw0rd/instagrapi/issues/364 + # if not isinstance(e, ClientError): + # self.logger.exception(e) + location = self.location_info_v1(location_pk) + return location + + def location_medias_a1_chunk( + self, location_pk: int, max_amount: int = 24, sleep: float = 0.5, tab_key: str = "", max_id: str = None + ) -> Tuple[List[Media], str]: + """ + Get chunk of medias and end_cursor by Public Web API + + Parameters + ---------- + location_pk: int + Unique identifier for a location + max_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 "" + end_cursor: str, optional + End Cursor, default value is None + + Returns + ------- + Tuple[List[Media], str] + List of objects of Media and end_cursor + """ + assert tab_key in tab_keys_a1, f'You must specify one of the options for "tab_key" {tab_keys_a1}' + unique_set = set() + 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"] + 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 max_amount and len(medias) >= max_amount: + break + node = edge["node"] + # check uniq + media_pk = node["id"] + if media_pk in unique_set: + continue + unique_set.add(media_pk) + # 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 max_amount and len(medias) >= max_amount: + break + time.sleep(sleep) + return medias, end_cursor + + 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 + """ + assert tab_key in tab_keys_a1, f'You must specify one of the options for "tab_key" {tab_keys_a1}' + medias, _ = self.location_medias_a1_chunk(location_pk, amount, sleep, tab_key) + if amount: + medias = medias[:amount] + return medias + + def location_medias_v1_chunk( + self, location_pk: int, max_amount: int = 63, tab_key: str = "", max_id: str = None + ) -> Tuple[List[Media], str]: + """ + Get chunk of medias for a location and max_id (cursor) by Private Mobile API + + Parameters + ---------- + location_pk: int + Unique identifier for a location + max_amount: int, optional + Maximum number of media to return, default is 27 + tab_key: str, optional + Tab Key, default value is "" + max_id: str + Max ID, default value is None + + Returns + ------- + Tuple[List[Media], str] + List of objects of Media and max_id + """ + assert tab_key in tab_keys_v1, f'You must specify one of the options for "tab_key" {tab_keys_a1}' + data = { + "_uuid": self.uuid, + "session_id": self.client_session_id, + "tab": tab_key + } + medias = [] + while True: + result = self.private_request( + f"locations/{location_pk}/sections/", + params={"max_id": max_id} if max_id else {}, + data=data, + ) + for section in result["sections"]: + layout_content = section.get("layout_content") or {} + nodes = layout_content.get("medias") or [] + for node in nodes: + if max_amount and len(medias) >= max_amount: + break + media = extract_media_v1(node["media"]) + medias.append(media) + if not result["more_available"]: + break + if max_amount and len(medias) >= max_amount: + break + max_id = result["next_max_id"] + return medias, max_id + + def location_medias_v1( + self, location_pk: int, amount: int = 63, tab_key: str = "" + ) -> List[Media]: + """ + Get medias for a location by Private Mobile API + + Parameters + ---------- + location_pk: int + Unique identifier for a location + amount: int, optional + Maximum number of media to return, default is 63 + tab_key: str, optional + Tab Key, default value is "" + + Returns + ------- + List[Media] + List of objects of Media + """ + assert tab_key in tab_keys_v1, f'You must specify one of the options for "tab_key" {tab_keys_a1}' + medias, _ = self.location_medias_v1_chunk(location_pk, amount, tab_key) + 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]: + """ + 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" + ) + + def location_medias_top_v1( + self, location_pk: int, amount: int = 21 + ) -> 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 21 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.location_medias_v1(location_pk, amount, tab_key="ranked") + + def location_medias_top( + self, location_pk: int, amount: int = 27, 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 27 + 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) + except Exception: + # Users do not understand the output of such information and create bug reports + # such this - https://github.com/adw0rd/instagrapi/issues/364 + # if not isinstance(e, ClientError): + # self.logger.exception(e) + return self.location_medias_top_v1(location_pk, amount) + + 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" + ) + + def location_medias_recent_v1( + self, location_pk: int, amount: int = 63 + ) -> 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 63 + + Returns + ------- + List[Media] + List of objects of Media + """ + return self.location_medias_v1(location_pk, amount, tab_key="recent") + + def location_medias_recent( + self, location_pk: int, amount: int = 63, 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 63 + 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) + except Exception: + # Users do not understand the output of such information and create bug reports + # such this - https://github.com/adw0rd/instagrapi/issues/364 + # if not isinstance(e, ClientError): + # self.logger.exception(e) + return self.location_medias_recent_v1(location_pk, amount) diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py new file mode 100644 index 0000000..88f9113 --- /dev/null +++ b/instagrapi/mixins/media.py @@ -0,0 +1,887 @@ +import json +import random +import time +from copy import deepcopy +from datetime import datetime +from typing import Dict, List, Tuple +from urllib.parse import urlparse + +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: + """ + 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: + assert media_id.isdigit(), ( + "media_id must been contain digits, now %s" % media_id + ) + user = self.media_user(media_id) + media_id = "%s_%s" % (media_id, user.pk) + return media_id + + @staticmethod + def media_pk(media_id: str) -> int: + """ + 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: + media_pk, _ = media_id.split("_") + return int(media_pk) + + def media_code_from_pk(self, media_pk: int) -> str: + """ + Get Code from Media PK + + Parameters + ---------- + media_pk: int + Media PK + + Returns + ------- + str + Code (aka shortcode) + + Examples + -------- + 2110901750722920960 -> B1LbfVPlwIA + 2278584739065882267 -> B-fKL9qpeab + """ + return InstagramIdCodec.encode(media_pk) + + def media_pk_from_code(self, code: str) -> int: + """ + 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: + """ + 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 by Public Web API + + 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 = self.media_code_from_pk(media_pk) + """Use Client.media_info + """ + params = {"max_id": max_id} if max_id else None + data = self.public_a1_request( + "/p/{shortcode!s}/".format(**{"shortcode": shortcode}), params=params + ) + if not data.get("shortcode_media"): + raise MediaNotFound(media_pk=media_pk, **data) + return extract_media_gql(data["shortcode_media"]) + + def media_info_gql(self, media_pk: int) -> Media: + """ + Get Media from PK by Public Graphql API + + Parameters + ---------- + media_pk: int + Unique identifier of the media + + Returns + ------- + Media + An object of Media type + """ + media_pk = self.media_pk(media_pk) + shortcode = self.media_code_from_pk(media_pk) + """Use Client.media_info + """ + variables = { + "shortcode": shortcode, + "child_comment_count": 3, + "fetch_comment_count": 40, + "parent_comment_count": 24, + "has_threaded_comments": False, + } + data = self.public_graphql_request( + variables, query_hash="477b65a610463740ccdb83135b2014db" + ) + 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"]) + ).dict() + return extract_media_gql(data["shortcode_media"]) + + def media_info_v1(self, media_pk: int) -> Media: + """ + Get Media from PK by Private Mobile API + + 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: + raise MediaNotFound(e, media_pk=media_pk, **self.last_json) + except ClientError as e: + if "Media not found" in str(e): + raise MediaNotFound(e, media_pk=media_pk, **self.last_json) + raise e + return extract_media_v1(result["items"].pop()) + + def media_info(self, media_pk: int, use_cache: bool = True) -> Media: + """ + 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: + try: + try: + media = self.media_info_gql(media_pk) + except ClientLoginRequired as e: + if not self.inject_sessionid_to_public(): + raise e + media = self.media_info_gql(media_pk) # retry + except Exception as e: + if not isinstance(e, ClientError): + self.logger.exception(e) # Register unknown error + # Restricted Video: This video is not available in your country. + # 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) + + def media_delete(self, media_id: str) -> bool: + """ + 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}) + ) + self._medias_cache.pop(self.media_pk(media_id), None) + return result.get("did_delete") + + def media_edit( + self, + media_id: str, + caption: str, + title: str = "", + usertags: List[Usertag] = [], + 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 + ] + data = { + "caption_text": caption, + "container_module": "edit_media_info", + "feed_position": "0", + "location": self.location_build(location), + "usertags": json.dumps({"in": usertags}), + "is_carousel_bumped_post": "false", + } + if media.product_type == "igtv": + if not title: + try: + title, caption = caption.split("\n", 1) + except ValueError: + title = caption[:75] + data = { + "caption_text": caption, + "title": title, + "igtv_ads_toggled_on": "0", + } + 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), + ) + return result + + def media_user(self, media_pk: int) -> UserShort: + """ + 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 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 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) + data = { + "inventory_source": "media_or_ad", + "media_id": media_id, + "radio_type": "wifi-none", + "is_carousel_bumped_post": "false", + "container_module": "feed_timeline", + "feed_position": str(random.randint(0, 6)), + } + name = "unlike" if revert else "like" + result = self.private_request( + f"media/{media_id}/{name}/", self.with_action_data(data) + ) + return result["status"] == "ok" + + def media_unlike(self, media_id: str) -> bool: + """ + 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_paginated_gql( + self, user_id: int, amount: int = 0, sleep: int = 2, + end_cursor=None + ) -> Tuple[List[Media], str]: + """ + Get a page of a user's media by Public Graphql API + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + sleep: int, optional + Timeout between pages iterations, default is 2 + end_cursor: str, optional + Cursor value to start at, obtained from previous call to this method + Returns + ------- + Tuple[List[Media], str] + A tuple containing a list of medias and the next end_cursor value + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + variables = { + "id": user_id, + "first": 50 if not amount or amount > 50 else amount, # These are Instagram restrictions, you can only specify <= 50 + } + 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 amount: + medias = medias[:amount] + return ( + [extract_media_gql(media) for media in medias], + end_cursor + ) + + def user_medias_gql( + self, user_id: int, amount: int = 0, sleep: int = 2 + ) -> List[Media]: + """ + Get a user's media by Public Graphql API + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + 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 if not amount or amount > 50 else amount, # These are Instagram restrictions, you can only specify <= 50 + } + while True: + self.logger.info(f"user_medias_gql: {amount}, {end_cursor}") + if end_cursor: + variables["after"] = end_cursor + medias_page, end_cursor = self.user_medias_paginated_gql( + user_id, amount, sleep, end_cursor=end_cursor + ) + medias.extend(medias_page) + if not end_cursor: + break + if amount and len(medias) >= amount: + break + time.sleep(sleep) + if amount: + medias = medias[:amount] + return medias + + def user_medias_paginated_v1(self, user_id: int, amount: int = 0, end_cursor: str = "") -> Tuple[List[Media], str]: + """ + Get a page of user's media by Private Mobile API + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + end_cursor: str, optional + Cursor value to start at, obtained from previous call to this method + + Returns + ------- + Tuple[List[Media], str] + A tuple containing a list of medias and the next end_cursor value + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + next_max_id = end_cursor + min_timestamp = None + try: + items = self.private_request( + f"feed/user/{user_id}/", + params={ + "max_id": next_max_id, + "count": 1000, + "min_timestamp": min_timestamp, + "rank_token": self.rank_token, + "ranked_content": "true", + }, + )["items"] + except Exception as e: + self.logger.exception(e) + return [], None + medias.extend(items) + next_max_id = self.last_json.get("next_max_id", "") + if amount: + medias = medias[:amount] + return ( + [extract_media_v1(media) for media in medias], + next_max_id + ) + + def user_medias_v1(self, user_id: int, amount: int = 0) -> List[Media]: + """ + Get a user's media by Private Mobile API + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + next_max_id = "" + while True: + try: + medias_page, next_max_id = self.user_medias_paginated_v1( + user_id, + amount, + end_cursor=next_max_id + ) + except Exception as e: + self.logger.exception(e) + break + medias.extend(medias_page) + if not self.last_json.get("more_available"): + break + if amount and len(medias) >= amount: + break + next_max_id = self.last_json.get("next_max_id", "") + if amount: + medias = medias[:amount] + return medias + + def user_medias_paginated(self, user_id: int, amount: int = 0, end_cursor: str = "") -> Tuple[List[Media], str]: + """ + Get a page of user's media + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + end_cursor: str, optional + Cursor value to start at, obtained from previous call to this method + + Returns + ------- + Tuple[List[Media], str] + A tuple containing a list of medias and the next end_cursor value + """ + + class EndCursorIsV1(Exception): + pass + + try: + if end_cursor and "_" in end_cursor: + # end_cursor is a v1 next_max_id, so we need to use v1 API + raise EndCursorIsV1 + try: + medias, end_cursor = self.user_medias_paginated_gql(user_id, amount, end_cursor=end_cursor) + except ClientLoginRequired as e: + if not self.inject_sessionid_to_public(): + raise e + medias, end_cursor = self.user_medias_paginated_gql(user_id, amount, end_cursor=end_cursor) + except Exception as e: + if isinstance(e, EndCursorIsV1): + pass + elif not isinstance(e, ClientError): + self.logger.exception(e) + medias, end_cursor = self.user_medias_paginated_v1(user_id, amount, end_cursor=end_cursor) + return medias, end_cursor + + def user_medias(self, user_id: int, amount: int = 0) -> List[Media]: + """ + Get a user's media + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + + 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]: + """ + Get user's likers + + Parameters + ---------- + media_id: str + + Returns + ------- + List[UserShort] + List of objects of User type + """ + 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']] + + def media_archive(self, media_id: str, revert: bool = False) -> bool: + """ + Archive a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + revert: bool, optional + Flag for archive or unarchive. Default is False + + Returns + ------- + bool + A boolean value + """ + media_id = self.media_id(media_id) + name = "undo_only_me" if revert else "only_me" + result = self.private_request( + f"media/{media_id}/{name}/", + self.with_action_data({"media_id": media_id}) + ) + return result["status"] == "ok" + + def media_unarchive(self, media_id: str) -> bool: + """ + Unarchive a media + + Parameters + ---------- + media_id: str + Unique identifier of a Media + + Returns + ------- + bool + A boolean value + """ + return self.media_archive(media_id, revert=True) + + def usertag_medias_gql( + self, user_id: int, amount: int = 0, sleep: int = 2 + ) -> List[Media]: + """ + Get medias where a user is tagged (by Public GraphQL API) + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + 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 if not amount or amount > 50 else amount, # These are Instagram restrictions, you can only specify <= 50 + } + while True: + if end_cursor: + variables["after"] = end_cursor + data = self.public_graphql_request( + variables, query_hash="be13233562af2d229b008d2976b998b5" + ) + page_info = json_value( + data, "user", "edge_user_to_photos_of_you", "page_info", default={} + ) + edges = json_value( + data, "user", "edge_user_to_photos_of_you", "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 amount and len(medias) >= amount: + break + time.sleep(sleep) + if amount: + medias = medias[:amount] + return [extract_media_gql(media) for media in medias] + + def usertag_medias_v1(self, user_id: int, amount: int = 0) -> List[Media]: + """ + Get medias where a user is tagged (by Private Mobile API) + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + medias = [] + next_max_id = "" + while True: + try: + items = self.private_request(f"usertags/{user_id}/feed/", params={"max_id": next_max_id})["items"] + except Exception as e: + self.logger.exception(e) + break + medias.extend(items) + if not self.last_json.get("more_available"): + break + if amount and len(medias) >= amount: + break + next_max_id = self.last_json.get("next_max_id", "") + if amount: + medias = medias[:amount] + return [extract_media_v1(media) for media in medias] + + def usertag_medias(self, user_id: int, amount: int = 0) -> List[Media]: + """ + Get medias where a user is tagged + + Parameters + ---------- + user_id: int + amount: int, optional + Maximum number of media to return, default is 0 (all medias) + + Returns + ------- + List[Media] + A list of objects of Media + """ + amount = int(amount) + user_id = int(user_id) + try: + medias = self.usertag_medias_gql(user_id, amount) + except ClientError: + medias = self.usertag_medias_v1(user_id, amount) + return medias diff --git a/instagrapi/mixins/notification.py b/instagrapi/mixins/notification.py new file mode 100644 index 0000000..752c468 --- /dev/null +++ b/instagrapi/mixins/notification.py @@ -0,0 +1,491 @@ +MUTE_ALL_ITEMS = ("cancel", "15_minutes", "1_hour", "2_hour", "4_hour", "8_hour") +SETTING_VALUE_ITEMS = ("off", "following_only", "everyone") + +try: + from typing import Literal + MUTE_ALL = Literal[MUTE_ALL_ITEMS] + SETTING_VALUE = Literal[SETTING_VALUE_ITEMS] +except ImportError: + # python <= 3.8 + MUTE_ALL = str + SETTING_VALUE = str + + +class NotificationMixin: + """ + Helpers for notification settings + """ + + def notification_settings(self, content_type: str, setting_value: str) -> bool: + data = { + "content_type": content_type, + "setting_value": setting_value, + "_uid": str(self.user_id), + "_uuid": self.uuid, + } + result = self.private_request( + "notifications/change_notification_settings/", + data=data + ) + return result.get("status") == "ok" + + def notification_disable(self) -> bool: + """ + Disable All Notification + + Returns + ------- + bool + """ + notifications = ( + self.notification_likes, + self.notification_like_and_comment_on_photo_user_tagged, + self.notification_user_tagged, + self.notification_comments, + self.notification_comment_likes, + self.notification_first_post, + self.notification_new_follower, + self.notification_follow_request_accepted, + self.notification_connection, + self.notification_tagged_in_bio, + self.notification_pending_direct_share, + self.notification_direct_share_activity, + self.notification_direct_group_requests, + self.notification_video_call, + self.notification_rooms, + self.notification_live_broadcast, + self.notification_felix_upload_result, + self.notification_view_count, + self.notification_fundraiser_creator, + self.notification_fundraiser_supporter, + self.notification_reminders, + self.notification_announcements, + self.notification_report_updated, + self.notification_login, + ) + return all(func("off") for func in notifications) + + def notification_mute_all(self, setting_value: MUTE_ALL = "8_hour") -> bool: + """ + Manage Mute All Notification Settings + + Parameters + ---------- + setting_value: MUTE_ALL + Value of settings, default "8_hour" + + Returns + ------- + bool + """ + assert setting_value in MUTE_ALL_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {MUTE_ALL_ITEMS}' + return self.notification_settings("mute_all", setting_value) + + def notification_likes(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Likes Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("likes", setting_value) + + def notification_like_and_comment_on_photo_user_tagged(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Like And Comment On Photo User Tagged Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("like_and_comment_on_photo_user_tagged", setting_value) + + def notification_user_tagged(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage User Tagged NotificationSettings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("user_tagged", setting_value) + + def notification_comments(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Comments Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("comments", setting_value) + + def notification_comment_likes(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Comment Likes Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("comment_likes", setting_value) + + def notification_first_post(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage First Post Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("first_post", setting_value) + + def notification_new_follower(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage New Follower Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("new_follower", setting_value) + + def notification_follow_request_accepted(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Follow Request Accepted Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("follow_request_accepted", setting_value) + + def notification_connection(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Connection Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("connection_notification", setting_value) + + def notification_tagged_in_bio(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Tagged In Bio Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("tagged_in_bio", setting_value) + + def notification_pending_direct_share(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Pending Direct Share Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("pending_direct_share", setting_value) + + def notification_direct_share_activity(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Direct Share Activity Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("direct_share_activity", setting_value) + + def notification_direct_group_requests(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Direct Group Requests Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("direct_group_requests", setting_value) + + def notification_video_call(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Video Call Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("video_call", setting_value) + + def notification_rooms(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Rooms Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("rooms", setting_value) + + def notification_live_broadcast(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Live Broadcast Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("live_broadcast", setting_value) + + def notification_felix_upload_result(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Felix Upload Result Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("felix_upload_result", setting_value) + + def notification_view_count(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage View Count Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("view_count", setting_value) + + def notification_fundraiser_creator(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Fundraiser Creator Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("fundraiser_creator", setting_value) + + def notification_fundraiser_supporter(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Fundraiser Supporter Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("fundraiser_supporter", setting_value) + + def notification_reminders(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Notification Reminders Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("notification_reminders", setting_value) + + def notification_announcements(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Announcements Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("announcements", setting_value) + + def notification_report_updated(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Report Updated Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("report_updated", setting_value) + + def notification_login(self, setting_value: SETTING_VALUE = "off") -> bool: + """ + Manage Login Notification Settings + + Parameters + ---------- + setting_value: SETTING_VALUE + Value of settings, default "off" + + Returns + ------- + bool + """ + assert setting_value in SETTING_VALUE_ITEMS, \ + f'Unsupported setting_value="{setting_value}" {SETTING_VALUE_ITEMS}' + return self.notification_settings("login_notification", setting_value) diff --git a/instagrapi/mixins/password.py b/instagrapi/mixins/password.py new file mode 100644 index 0000000..2665586 --- /dev/null +++ b/instagrapi/mixins/password.py @@ -0,0 +1,39 @@ +import base64 +import time + +from Cryptodome.Cipher import AES, PKCS1_v1_5 +from Cryptodome.PublicKey import RSA +from Cryptodome.Random import get_random_bytes + + +class PasswordMixin: + + def password_encrypt(self, password): + publickeyid, publickey = self.password_publickeys() + session_key = get_random_bytes(32) + iv = get_random_bytes(12) + timestamp = str(int(time.time())) + decoded_publickey = base64.b64decode(publickey.encode()) + recipient_key = RSA.import_key(decoded_publickey) + cipher_rsa = PKCS1_v1_5.new(recipient_key) + rsa_encrypted = cipher_rsa.encrypt(session_key) + cipher_aes = AES.new(session_key, AES.MODE_GCM, iv) + cipher_aes.update(timestamp.encode()) + aes_encrypted, tag = cipher_aes.encrypt_and_digest(password.encode("utf8")) + size_buffer = len(rsa_encrypted).to_bytes(2, byteorder='little') + payload = base64.b64encode(b''.join([ + b"\x01", + publickeyid.to_bytes(1, byteorder='big'), + iv, + size_buffer, + rsa_encrypted, + tag, + aes_encrypted + ])) + return f"#PWD_INSTAGRAM:4:{timestamp}:{payload.decode()}" + + def password_publickeys(self): + resp = self.public.get('https://i.instagram.com/api/v1/qe/sync/') + publickeyid = int(resp.headers.get('ig-set-password-encryption-key-id')) + publickey = resp.headers.get('ig-set-password-encryption-pub-key') + return publickeyid, publickey diff --git a/instagrapi/mixins/photo.py b/instagrapi/mixins/photo.py new file mode 100644 index 0000000..3407408 --- /dev/null +++ b/instagrapi/mixins/photo.py @@ -0,0 +1,615 @@ +import json +import random +import shutil +import time +from pathlib import Path +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.types import ( + Location, + Media, + Story, + StoryHashtag, + StoryLink, + StoryLocation, + StoryMedia, + StoryMention, + StorySticker, + Usertag, +) +from instagrapi.utils import date_time_original, dumps + +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=8.1.1") + + +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( + username=media.user.username, media_pk=media_pk + ) + return self.photo_download_by_url(media.thumbnail_url, filename, folder) + + def photo_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> Path: + """ + Download photo using URL + + 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() + with open(path, "wb") as f: + response.raw.decode_content = True + shutil.copyfileobj(response.raw, f) + return path.resolve() + + def photo_download_by_url_origin( + self, url: str + ) -> bytes: + """ + Download photo using URL + + Parameters + ---------- + url: str + URL for a media + + Returns + ------- + bytes + """ + response = requests.get(url, stream=True) + response.raise_for_status() + response.raw.decode_content = True + return response.content + + +class UploadPhotoMixin: + """ + Helpers for downloading photo + """ + + def photo_rupload( + self, path: Path, upload_id: str = "", to_album: bool = False + ) -> tuple: + """ + Upload photo to Instagram + + 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 + + Returns + ------- + tuple + (Upload ID for the media, width, height) + """ + assert isinstance(path, Path), f"Path must been Path, now {path} ({type(path)})" + # upload_id = 516057248854759 + upload_id = upload_id or str(int(time.time() * 1000)) + assert path, "Not specified path to photo" + waterfall_id = str(uuid4()) + # upload_name example: '1576102477530_0_7823256191' + 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", + "xsharing_user_ids": "[]", + "upload_id": upload_id, + "image_compression": json.dumps( + {"lib_name": "moz", "lib_version": "3.1.m", "quality": "80"} + ), + } + if to_album: + rupload_params["is_sidecar"] = "1" + with open(path, "rb") as fp: + photo_data = fp.read() + photo_len = str(len(photo_data)) + headers = { + "Accept-Encoding": "gzip", + "X-Instagram-Rupload-Params": json.dumps(rupload_params), + "X_FB_PHOTO_WATERFALL_ID": waterfall_id, + "X-Entity-Type": "image/jpeg", + "Offset": "0", + "X-Entity-Name": upload_name, + "X-Entity-Length": photo_len, + "Content-Type": "application/octet-stream", + "Content-Length": photo_len, + } + response = self.private.post( + "https://{domain}/rupload_igphoto/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + data=photo_data, + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + self.logger.error( + "Photo Upload failed with the following response: %s", response + ) + last_json = self.last_json # local variable for read in sentry + raise PhotoNotUpload(response.text, response=response, **last_json) + with Image.open(path) as im: + width, height = im.size + return upload_id, width, height + + def photo_upload( + self, + path: Path, + caption: str, + upload_id: str = "", + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> Media: + """ + Upload photo and configure to feed + + 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 + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + 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(3) + if self.photo_configure( + upload_id, width, height, caption, usertags, location, + extra_data=extra_data + ): + media = self.last_json.get("media") + self.expose() + return extract_media_v1(media) + raise PhotoConfigureError( + response=self.last_response, **self.last_json + ) + + def photo_configure( + self, + upload_id: str, + width: int, + height: int, + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Post Configure Photo (send caption 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 + 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 + extra_data: Dict[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 + """ + usertags = [ + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags + ] + data = { + "timezone_offset": str(self.timezone_offset), + "camera_model": self.device.get("model", ""), + "camera_make": self.device.get("manufacturer", ""), + "scene_type": "?", + "nav_chain": "8rL:self_profile:4,ProfileMediaTabFragment:self_profile:5,UniversalCreationMenuFragment:universal_creation_menu:7,ProfileMediaTabFragment:self_profile:8,MediaCaptureFragment:tabbed_gallery_camera:9,Dd3:photo_filter:10,FollowersShareFragment:metadata_followers_share:11", + "date_time_original": date_time_original(time.localtime()), + "date_time_digitalized": date_time_original(time.localtime()), + "creation_logger_session_id": self.client_session_id, + "scene_capture_type": "standard", + "software": config.SOFTWARE.format(**self.device_settings), + "multi_sharing": "1", + "location": self.location_build(location), + "media_folder": "Camera", + "source_type": "4", + "caption": caption, + "upload_id": upload_id, + "device": self.device, + "usertags": json.dumps({"in": usertags}), + "edits": { + "crop_original_size": [width * 1.0, height * 1.0], + "crop_center": [0.0, 0.0], + "crop_zoom": 1.0, + }, + "extra": {"source_width": width, "source_height": height}, + **extra_data + } + return self.private_request("media/configure/", self.with_default_data(data)) + + def photo_upload_to_story( + self, + path: Path, + caption: str = "", + upload_id: str = "", + mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + medias: List[StoryMedia] = [], + extra_data: Dict[str, str] = {}, + ) -> Story: + """ + Upload photo as a story and configure it + + 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 + 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. + medias: List[StoryMedia], optional + List of medias to be tagged on this upload, default is empty list. + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Story + 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(3) + if self.photo_configure_to_story( + upload_id, + width, + height, + caption, + mentions, + locations, + links, + hashtags, + stickers, + medias, + extra_data=extra_data + ): + media = self.last_json.get("media") + self.expose() + return Story( + links=links, + mentions=mentions, + hashtags=hashtags, + locations=locations, + stickers=stickers, + medias=medias, + **extract_media_v1(media).dict() + ) + raise PhotoConfigureStoryError( + response=self.last_response, **self.last_json + ) + + def photo_configure_to_story( + self, + upload_id: str, + width: int, + height: int, + caption: str, + mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + medias: List[StoryMedia] = [], + extra_data: Dict[str, str] = {}, + ) -> Dict: + """ + Post configure photo + + 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. + medias: List[StoryMedia], optional + List of medias to be tagged on this upload, default is empty list. + extra_data: Dict[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 = { + "text_metadata": '[{"font_size":40.0,"scale":1.0,"width":611.0,"height":169.0,"x":0.51414347,"y":0.8487708,"rotation":0.0}]', # REMOVEIT + "supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES), + "has_original_sound": "1", + "camera_session_id": self.client_session_id, + "scene_capture_type": "", + "timezone_offset": str(self.timezone_offset), + "client_shared_at": str(timestamp - 5), # 5 seconds ago + "story_sticker_ids": "", + "media_folder": "Camera", + "configure_mode": "1", + "source_type": "4", + "creation_surface": "camera", + "imported_taken_at": (timestamp - 3 * 24 * 3600), # 3 days ago + "capture_type": "normal", + "rich_text_format_types": '["default"]', # REMOVEIT + "upload_id": upload_id, + "client_timestamp": str(timestamp), + "device": self.device, + "_uid": self.user_id, + "_uuid": self.uuid, + "device_id": self.android_device_id, + "composition_id": self.generate_uuid(), + "app_attribution_android_namespace": "", + "media_transformation_info": dumps({ + "width": str(width), + "height": str(height), + "x_transform": "0", + "y_transform": "0", + "zoom": "1.0", + "rotation": "0.0", + "background_coverage": "0.0" + }), + "original_media_type": "photo", + "camera_entry_point": str(random.randint(25, 164)), # e.g. 25 + "edits": { + "crop_original_size": [width * 1.0, height * 1.0], + # "crop_center": [0.0, 0.0], + # "crop_zoom": 1.0, + 'filter_type': 0, + 'filter_strength': 1.0, + }, + "extra": {"source_width": width, "source_height": height}, + } + if caption: + data["caption"] = caption + data.update(extra_data) + tap_models = [] + static_models = [] + if 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 + ] + 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 links: + # instagram allow one link now + link = links[0] + self.private_request("media/validate_reel_url/", { + "url": str(link.webUri), + "_uid": str(self.user_id), + "_uuid": str(self.uuid), + }) + stickers.append( + StorySticker( + type="story_link", + x=link.x, + y=link.y, + z=link.z, + width=link.width, + height=link.height, + rotation=link.rotation, + extra=dict( + link_type="web", + url=str(link.webUri), + tap_state_str_id="link_sticker_default" + ) + ) + ) + story_sticker_ids.append("link_sticker_default") + if stickers: + for sticker in stickers: + sticker_extra = sticker.extra or {} + if sticker.id: + sticker_extra["str_id"] = sticker.id + story_sticker_ids.append(sticker.id) + tap_models.append({ + "x": sticker.x, + "y": sticker.y, + "z": sticker.z, + "width": sticker.width, + "height": sticker.height, + "rotation": sticker.rotation, + "type": sticker.type, + "is_sticker": True, + "selected_index": 0, + "tap_state": 0, + **sticker_extra + }) + if sticker.type == "gif": + data["has_animated_sticker"] = "1" + if medias: + for feed_media in medias: + assert feed_media.media_pk, 'Required StoryMedia.media_pk' + # if not feed_media.user_id: + # user = self.media_user(feed_media.media_pk) + # feed_media.user_id = user.pk + item = { + 'x': feed_media.x, + 'y': feed_media.y, + 'z': feed_media.z, + 'width': feed_media.width, + 'height': feed_media.height, + 'rotation': feed_media.rotation, + 'type': 'feed_media', + 'media_id': str(feed_media.media_pk), + 'media_owner_id': str(feed_media.user_id or ""), + 'product_type': 'feed', + 'is_sticker': True, + 'tap_state': 0, + 'tap_state_str_id': 'feed_post_sticker_square' + } + tap_models.append(item) + data["reshared_media_id"] = str(feed_media.media_pk) + if tap_models: + data["tap_models"] = dumps(tap_models) + if static_models: + data["static_models"] = dumps(static_models) + if story_sticker_ids: + data["story_sticker_ids"] = story_sticker_ids[0] + 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 new file mode 100644 index 0000000..92794c3 --- /dev/null +++ b/instagrapi/mixins/private.py @@ -0,0 +1,450 @@ +import json +import logging +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, + TwoFactorRequired, + UnknownError, + VideoTooLongException, +) +from instagrapi.utils import dumps, generate_signature + + +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 + while True: + code = input(f"Enter code (6 digits) for {username} ({choice}): ").strip() + if code and code.isdigit(): + break + return code # is not int, because it can start from 0 + + +def manual_change_password(self, username: str): + pwd = None + while not pwd: + pwd = input(f"Enter password for {username}: ").strip() + return pwd + + +class PrivateRequestMixin: + """ + Helpers for private request + """ + private_requests_count = 0 + handle_exception = None + challenge_code_handler = manual_input_code + change_password_handler = manual_change_password + request_logger = logging.getLogger("private_request") + request_timeout = 1 + last_response = None + last_json = {} + + def __init__(self, *args, **kwargs): + self.private = requests.Session() + self.private.verify = False # fix SSLError/HTTPSConnectionPool + self.email = kwargs.pop("email", None) + self.phone_number = kwargs.pop("phone_number", None) + self.request_timeout = kwargs.pop( + "request_timeout", self.request_timeout) + 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 + def base_headers(self): + locale = self.locale.replace("-", "_") + accept_language = ['en-US'] + if locale: + lang = locale.replace("_", "-") + if lang not in accept_language: + accept_language.insert(0, lang) + headers = { + "X-IG-App-Locale": locale, + "X-IG-Device-Locale": locale, + "X-IG-Mapped-Locale": locale, + "X-Pigeon-Session-Id": self.generate_uuid('UFS-', '-1'), + "X-Pigeon-Rawclienttime": str(round(time.time(), 3)), + # "X-IG-Connection-Speed": "-1kbps", + "X-IG-Bandwidth-Speed-KBPS": str(random.randint(2500000, 3000000) / 1000), # "-1.000" + "X-IG-Bandwidth-TotalBytes-B": str(random.randint(5000000, 90000000)), # "0" + "X-IG-Bandwidth-TotalTime-MS": str(random.randint(2000, 9000)), # "0" + # "X-IG-EU-DC-ENABLED": "true", # <- type of DC? Eu is euro, but we use US + # "X-IG-Prefetch-Request": "foreground", # OLD from instabot + "X-IG-App-Startup-Country": self.country.upper(), + "X-Bloks-Version-Id": self.bloks_versioning_id, + "X-IG-WWW-Claim": "0", + # X-IG-WWW-Claim: hmac.AR3zruvyGTlwHvVd2ACpGCWLluOppXX4NAVDV-iYslo9CaDd + "X-Bloks-Is-Layout-RTL": "false", + "X-Bloks-Is-Panorama-Enabled": "true", + "X-IG-Device-ID": self.uuid, + "X-IG-Family-Device-ID": self.phone_id, + "X-IG-Android-ID": self.android_device_id, + "X-IG-Timezone-Offset": str(self.timezone_offset), + "X-IG-Connection-Type": "WIFI", + "X-IG-Capabilities": "3brTvx0=", # "3brTvwE=" in instabot + "X-IG-App-ID": self.app_id, + "Priority": "u=3", + "User-Agent": self.user_agent, + "Accept-Language": ', '.join(accept_language), + "X-MID": self.mid, # e.g. X--ijgABABFjLLQ1NTEe0A6JSN7o, YRwa1QABBAF-ZA-1tPmnd0bEniTe + "Accept-Encoding": "gzip, deflate", # ignore zstd + "Host": config.API_DOMAIN, + "X-FB-HTTP-Engine": "Liger", + "Connection": "keep-alive", + # "Pragma": "no-cache", + # "Cache-Control": "no-cache", + "X-FB-Client-IP": "True", + "X-FB-Server-Cluster": "True", + "IG-INTENDED-USER-ID": str(self.user_id or 0), + "X-IG-Nav-Chain": "9MV:self_profile:2,ProfileMediaTabFragment:self_profile:3,9Xf:self_following:4", + "X-IG-SALT-IDS": str(random.randint(1061162222, 1061262222)), + } + if self.user_id: + next_year = time.time() + 31536000 # + 1 year in seconds + headers.update({ + "IG-U-DS-USER-ID": str(self.user_id), + # Direct: + "IG-U-IG-DIRECT-REGION-HINT": f"LLA,{self.user_id},{next_year}:01f7bae7d8b131877d8e0ae1493252280d72f6d0d554447cb1dc9049b6b2c507c08605b7", + "IG-U-SHBID": f"12695,{self.user_id},{next_year}:01f778d9c9f7546cf3722578fbf9b85143cd6e5132723e5c93f40f55ca0459c8ef8a0d9f", + "IG-U-SHBTS": f"{int(time.time())},{self.user_id},{next_year}:01f7ace11925d0388080078d0282b75b8059844855da27e23c90a362270fddfb3fae7e28", + "IG-U-RUR": f"RVA,{self.user_id},{next_year}:01f7f627f9ae4ce2874b2e04463efdb184340968b1b006fa88cb4cc69a942a04201e544c", + }) + if self.ig_u_rur: + headers.update({"IG-U-RUR": self.ig_u_rur}) + if self.ig_www_claim: + headers.update({"X-IG-WWW-Claim": self.ig_www_claim}) + return headers + + def set_country(self, country: str = "US"): + """Set you country code (ISO 3166-1/3166-2) + + Parameters + ---------- + country: str + Your country code (ISO 3166-1/3166-2) string identifier (e.g. US, UK, RU) + Advise to specify the country code of your proxy + + Returns + ------- + bool + A boolean value + """ + self.settings['country'] = self.country = str(country) + return True + + def set_country_code(self, country_code: int = 1): + """Set country calling code + + Parameters + ---------- + country_code: int + + Returns + ------- + bool + A boolean value + """ + self.settings['country_code'] = self.country_code = int(country_code) + return True + + def set_locale(self, locale: str = "en_US"): + """Set you locale (ISO 3166-1/3166-2) + + Parameters + ---------- + locale: str + Your locale code (ISO 3166-1/3166-2) string identifier (e.g. US, UK, RU) + Advise to specify the locale code of your proxy + + Returns + ------- + bool + A boolean value + """ + user_agent = (self.settings.get("user_agent") or "").replace(self.locale, locale) + self.settings['locale'] = self.locale = str(locale) + self.set_user_agent(user_agent) # update locale in user_agent + if '_' in locale: + self.set_country(locale.rsplit('_', 1)[1]) + return True + + def set_timezone_offset(self, seconds: int = 0): + """Set you timezone offset in seconds + + Parameters + ---------- + seconds: int + Specify the offset in seconds from UTC + + Returns + ------- + bool + A boolean value + """ + self.settings['timezone_offset'] = self.timezone_offset = int(seconds) + return True + + def set_ig_u_rur(self, value): + self.settings['ig_u_rur'] = self.ig_u_rur = value + return True + + def set_ig_www_claim(self, value): + self.settings['ig_www_claim'] = self.ig_www_claim = value + return True + + @staticmethod + def with_query_params(data, params): + return dict(data, **{"query_params": json.dumps(params, separators=(",", ":"))}) + + def _send_private_request( + self, + endpoint, + data=None, + params=None, + login=False, + with_signature=True, + headers=None, + extra_sig=None, + ): + self.last_response = None + self.last_json = last_json = {} # for Sentry context in traceback + self.private.headers.update(self.base_headers) + if headers: + self.private.headers.update(headers) + if not login: + time.sleep(self.request_timeout) + # if self.user_id and login: + # raise Exception(f"User already logged ({self.user_id})") + try: + 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) + self.private.headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8" + if with_signature: + # Client.direct_answer doesn't need a signature + data = generate_signature(dumps(data)) + if extra_sig: + data += "&".join(extra_sig) + response = self.private.post( + api_url, data=data, params=params + ) + else: # GET + self.private.headers.pop('Content-Type', None) + response = self.private.get(api_url, params=params) + self.logger.debug( + "private_request %s: %s (%s)", response.status_code, response.url, response.text + ) + mid = response.headers.get("ig-set-x-mid") + if mid: + self.mid = mid + self.request_log(response) + self.last_response = response + response.raise_for_status() + # last_json - for Sentry context in traceback + self.last_json = last_json = response.json() + self.logger.debug("last_json %s", last_json) + except JSONDecodeError as e: + self.logger.error( + "Status %s: JSONDecodeError in private_request (user_id=%s, endpoint=%s) >>> %s", + response.status_code, + self.user_id, + endpoint, + response.text, + ) + raise ClientJSONDecodeError( + "JSONDecodeError {0!s} while opening {1!s}".format( + e, response.url), + response=response, + ) + except requests.HTTPError as e: + try: + self.last_json = last_json = response.json() + except JSONDecodeError: + pass + message = last_json.get("message", "") + if e.response.status_code == 403: + if message == "login_required": + raise LoginRequired(response=e.response, **last_json) + if len(e.response.text) < 512: + last_json['message'] = e.response.text + raise ClientForbiddenError(e, response=e.response, **last_json) + elif e.response.status_code == 400: + error_type = last_json.get("error_type") + if message == "challenge_required": + raise ChallengeRequired(**last_json) + elif message == "feedback_required": + raise FeedbackRequired( + **dict( + last_json, + message="%s: %s" + % (message, last_json.get("feedback_message")), + ) + ) + elif error_type == "sentry_block": + raise SentryBlock(**last_json) + elif error_type == "rate_limit_error": + raise RateLimitError(**last_json) + elif error_type == "bad_password": + raise BadPassword(**last_json) + elif error_type == "two_factor_required": + if not last_json['message']: + last_json['message'] = "Two-factor authentication required" + raise TwoFactorRequired(**last_json) + elif "Please wait a few minutes before you try again" in message: + raise PleaseWaitFewMinutes(e, response=e.response, **last_json) + elif "VideoTooLongException" in message: + raise VideoTooLongException(e, response=e.response, **last_json) + elif error_type or message: + raise UnknownError(**last_json) + # TODO: Handle last_json with {'message': 'counter get error', 'status': 'fail'} + self.logger.exception(e) + self.logger.warning( + "Status 400: %s", message or "Empty response message. Maybe enabled Two-factor auth?" + ) + raise ClientBadRequestError( + e, response=e.response, **last_json) + elif e.response.status_code == 429: + self.logger.warning("Status 429: Too many requests") + if "Please wait a few minutes before you try again" in message: + raise PleaseWaitFewMinutes(e, response=e.response, **last_json) + raise ClientThrottledError(e, response=e.response, **last_json) + elif e.response.status_code == 404: + self.logger.warning( + "Status 404: Endpoint %s does not exists", endpoint) + raise ClientNotFoundError(e, response=e.response, **last_json) + elif e.response.status_code == 408: + self.logger.warning("Status 408: Request Timeout") + raise ClientRequestTimeout(e, response=e.response, **last_json) + raise ClientError(e, response=e.response, **last_json) + except requests.ConnectionError as e: + raise ClientConnectionError( + "{e.__class__.__name__} {e}".format(e=e)) + if last_json.get("status") == "fail": + raise ClientError(response=response, **last_json) + elif "error_title" in last_json: + """Example: { + 'error_title': 'bad image input extra:{}', <------------- + 'media': { + 'device_timestamp': '1588184737203', + 'upload_id': '1588184737203' + }, + 'message': 'media_needs_reupload', <------------- + 'status': 'ok' <------------- + }""" + raise ClientError(response=response, **last_json) + return last_json + + def request_log(self, response): + self.request_logger.info( + "%s [%s] %s %s (%s)", + self.username, + response.status_code, + response.request.method, + response.url, + "{app_version}, {manufacturer} {model}".format( + app_version=self.device_settings.get("app_version"), + manufacturer=self.device_settings.get("manufacturer"), + model=self.device_settings.get("model"), + ), + ) + + def private_request( + self, + endpoint, + data=None, + params=None, + login=False, + with_signature=True, + headers=None, + extra_sig=None, + ): + if self.authorization: + if not headers: + headers = {} + if 'authorization' not in headers: + headers.update({'Authorization': self.authorization}) + kwargs = dict( + data=data, + params=params, + login=login, + with_signature=with_signature, + headers=headers, + extra_sig=extra_sig, + ) + try: + self.private_requests_count += 1 + self._send_private_request(endpoint, **kwargs) + except ClientRequestTimeout: + self.logger.info('Wait 60 seconds and try one more time (ClientRequestTimeout)') + time.sleep(60) + return self._send_private_request(endpoint, **kwargs) + # except BadPassword as e: + # raise e + except Exception as e: + if self.handle_exception: + self.handle_exception(self, e) + elif isinstance(e, ChallengeRequired): + self.challenge_resolve(self.last_json) + else: + raise e + if login and self.user_id: + # After challenge resolve return last_json + return self.last_json + return self._send_private_request(endpoint, **kwargs) + return self.last_json diff --git a/instagrapi/mixins/public.py b/instagrapi/mixins/public.py new file mode 100644 index 0000000..1104b5b --- /dev/null +++ b/instagrapi/mixins/public.py @@ -0,0 +1,282 @@ +import json +import logging +import time + +try: + from simplejson.errors import JSONDecodeError +except ImportError: + 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 + + +class PublicRequestMixin: + public_requests_count = 0 + PUBLIC_API_URL = "https://www.instagram.com/" + GRAPHQL_PUBLIC_API_URL = "https://www.instagram.com/graphql/query/" + last_public_response = None + last_public_json = {} + request_logger = logging.getLogger("public_request") + request_timeout = 1 + + def __init__(self, *args, **kwargs): + self.public = requests.Session() + self.public.verify = False # fix SSLError/HTTPSConnectionPool + self.public.headers.update( + { + "Connection": "Keep-Alive", + "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", + } + ) + self.request_timeout = kwargs.pop("request_timeout", self.request_timeout) + super().__init__(*args, **kwargs) + + def public_request( + self, + url, + data=None, + params=None, + headers=None, + return_json=False, + retries_count=3, + retries_timeout=2, + ): + kwargs = dict( + 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" + for iteration in range(retries_count): + try: + return self._send_public_request(url, **kwargs) + except (ClientLoginRequired, ClientNotFoundError, ClientBadRequestError) as e: + raise e # Stop retries + # except JSONDecodeError as e: + # raise ClientJSONDecodeError(e, respones=self.last_public_response) + except ClientError as e: + msg = str(e) + if all(( + isinstance(e, ClientConnectionError), + "SOCKSHTTPSConnectionPool" in msg, + "Max retries exceeded with url" in msg, + "Failed to establish a new connection" in msg + )): + raise e + if retries_count > iteration + 1: + time.sleep(retries_timeout) + else: + raise e + continue + + def _send_public_request( + self, url, data=None, params=None, headers=None, return_json=False + ): + self.public_requests_count += 1 + if headers: + self.public.headers.update(headers) + if self.request_timeout: + time.sleep(self.request_timeout) + try: + if data is not None: # POST + response = self.public.data(url, data=data, params=params) + else: # GET + response = self.public.get(url, params=params) + + expected_length = int(response.headers.get("Content-Length")) + actual_length = response.raw.tell() + if actual_length < expected_length: + raise ClientIncompleteReadError( + "Incomplete read ({} bytes read, {} more expected)".format( + actual_length, expected_length + ), + response=response, + ) + + self.request_logger.debug( + "public_request %s: %s", response.status_code, response.url + ) + + self.request_logger.info( + "[%s] [%s] %s %s", + self.public.proxies.get("https"), + response.status_code, + "POST" if data else "GET", + response.url, + ) + self.last_public_response = response + response.raise_for_status() + if return_json: + self.last_public_json = response.json() + return self.last_public_json + return response.text + + except JSONDecodeError as e: + if "/login/" in response.url: + raise ClientLoginRequired(e, response=response) + + self.request_logger.error( + "Status %s: JSONDecodeError in public_request (url=%s) >>> %s", + response.status_code, + response.url, + response.text, + ) + raise ClientJSONDecodeError( + "JSONDecodeError {0!s} while opening {1!s}".format(e, url), + response=response, + ) + except requests.HTTPError as e: + if e.response.status_code == 403: + raise ClientForbiddenError(e, response=e.response) + + if e.response.status_code == 400: + raise ClientBadRequestError(e, response=e.response) + + if e.response.status_code == 429: + raise ClientThrottledError(e, response=e.response) + + if e.response.status_code == 404: + raise ClientNotFoundError(e, response=e.response) + + raise ClientError(e, response=e.response) + + except requests.ConnectionError as 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("/") + if params: + params.update({"__a": 1}) + else: + params = {"__a": 1} + + response = self.public_request( + url, data=data, params=params, headers=headers, return_json=True + ) + try: + return response["graphql"] + except KeyError as e: + error_type = response.get("error_type") + if error_type == "generic_request_error": + raise GenericRequestError( + json_value(response, "errors", "error", 0, default=error_type), + **response + ) + raise e + + def public_graphql_request( + self, + variables, + query_hash=None, + query_id=None, + data=None, + params=None, + headers=None, + ): + assert query_id or query_hash, "Must provide valid one of: query_id, query_hash" + default_params = {"variables": json.dumps(variables, separators=(",", ":"))} + if query_id: + default_params["query_id"] = query_id + + if query_hash: + default_params["query_hash"] = query_hash + + if params: + params.update(default_params) + else: + params = default_params + + try: + body_json = self.public_request( + self.GRAPHQL_PUBLIC_API_URL, + data=data, + params=params, + headers=headers, + return_json=True, + ) + + 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) + ), + response=body_json, + ) + + return body_json["data"] + + except ClientBadRequestError as e: + message = None + try: + body_json = e.response.json() + message = body_json.get("message", None) + except JSONDecodeError: + pass + raise ClientGraphqlError( + "Error: '{}'. Message: '{}'".format(e, message), response=e.response + ) + + +class TopSearchesPublicMixin: + def top_search(self, query): + """Anonymous IG search request""" + url = "https://www.instagram.com/web/search/topsearch/" + params = { + "context": "blended", + "query": query, + "rank_token": 0.7763938004511706, + "include_reel": "true", + } + response = self.public_request(url, params=params, return_json=True) + 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") + variables = { + "id": location_id, + "first": int(count), + } + if end_cursor: + variables["after"] = end_cursor + data = self.public_graphql_request( + variables, query_hash="1b84447a4d8b6d6d0426fefb34514485" + ) + return data["location"] + + def profile_related_info(self, profile_id): + variables = { + "user_id": profile_id, + "include_chaining": True, + "include_reel": True, + "include_suggested_users": True, + "include_logged_out_extras": True, + "include_highlight_reels": True, + "include_related_profiles": True, + } + data = self.public_graphql_request( + variables, query_hash="e74d51c10ecc0fe6250a295b9bb9db74" + ) + return data["user"] diff --git a/instagrapi/mixins/share.py b/instagrapi/mixins/share.py new file mode 100644 index 0000000..55fa192 --- /dev/null +++ b/instagrapi/mixins/share.py @@ -0,0 +1,65 @@ +import base64 +from urllib.parse import urlparse + +from instagrapi.types import Share + + +class ShareMixin: + + def share_info(self, code: str) -> Share: + """ + Get Share object by code + + Parameters + ---------- + code: str + Share code + + Returns + ------- + Share + Share object + """ + if isinstance(code, str): + code = code.encode() + # ignore example from instagram: b'highli\xb1\xdb\x1dght:17988089629383770' + data = base64.b64decode(code)\ + .decode(errors="ignore")\ + .replace("\x1d", "")\ + .split(":") + return Share(type=data[0], pk=data[1]) + + def share_info_by_url(self, url: str) -> Share: + """ + Get Share object by URL + + Parameters + ---------- + url: str + URL of the share object + + Returns + ------- + Share + Share object + """ + return self.share_info(self.share_code_from_url(url)) + + def share_code_from_url(self, url: str) -> str: + """ + Get Share code from URL + + Parameters + ---------- + url: str + URL of the share object + + Returns + ------- + str + Share code + """ + path = urlparse(url).path + parts = [p for p in path.split("/") if p] + return parts.pop() + diff --git a/instagrapi/mixins/story.py b/instagrapi/mixins/story.py new file mode 100644 index 0000000..1e23544 --- /dev/null +++ b/instagrapi/mixins/story.py @@ -0,0 +1,326 @@ +import json +import shutil +from copy import deepcopy +from pathlib import Path +from typing import List +from urllib.parse import urlparse + +import requests + +from instagrapi import config +from instagrapi.exceptions import ClientNotFoundError, 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_pk_from_url(self, url: str) -> str: + """ + Get Story (media) PK from URL + + Parameters + ---------- + url: str + URL of the story + + Returns + ------- + str + Media PK + + Examples + -------- + https://www.instagram.com/stories/dhbastards/2581281926631793076/ -> 2581281926631793076 + """ + path = urlparse(url).path + parts = [p for p in path.split("/") if p and p.isdigit()] + return str(parts[0]) + + # def story_info_gql(self, story_pk: str): + # # GQL havent video_url :-( + # return self.media_info_gql(self, str(story_pk)) + + def story_info_v1(self, story_pk: str) -> Story: + """ + Get Story by pk or id + + Parameters + ---------- + story_pk: str + 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("_") + result = self.private_request(f"media/{story_pk}/info/") + story = extract_story_v1(result["items"][0]) + self._stories_cache[story.pk] = story + return deepcopy(story) + + def story_info(self, story_pk: str, use_cache: bool = True) -> Story: + """ + Get Story by pk or id + + Parameters + ---------- + story_pk: str + 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: str) -> bool: + """ + Delete story + + Parameters + ---------- + story_pk: str + 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], amount: int = 0) -> List[UserShort]: + """ + Get a user's stories (Public API) + + Parameters + ---------- + user_ids: List[int] + List of users + amount: int + Max amount of stories + + 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']) + items = media['items'] + if amount: + items = items[:amount] + user.stories = [extract_story_gql(m) for m in 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], amount=amount)[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).get("reel") or {} + stories = [] + for item in reel.get("items", []): + stories.append(extract_story_v1(item)) + if amount: + stories = stories[:int(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] + ) + + def story_download(self, story_pk: int, filename: str = "", folder: Path = "") -> Path: + """ + Download story media by media_type + + Parameters + ---------- + story_pk: int + + Returns + ------- + Path + Path for the file downloaded + """ + story_pk = int(story_pk) + story = self.story_info(story_pk) + url = story.thumbnail_url if story.media_type == 1 else story.video_url + return self.story_download_by_url(url, filename, folder) + + def story_download_by_url(self, url: str, filename: str = "", folder: Path = "") -> Path: + """ + Download story media using URL + + 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].strip() + assert fname, """The URL must contain the path to the file (mp4 or jpg).\n"""\ + """Read the documentation https://adw0rd.github.io/instagrapi/usage-guide/story.html""" + 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() + with open(path, "wb") as f: + response.raw.decode_content = True + shutil.copyfileobj(response.raw, f) + return path.resolve() + + def story_viewers(self, story_pk: int, amount: int = 0) -> List[UserShort]: + """ + List of story viewers (Private API) + + Parameters + ---------- + story_pk: int + amount: int, optional + Maximum number of story viewers + + Returns + ------- + List[UserShort] + A list of objects of UserShort + """ + users = [] + next_max_id = None + story_pk = self.media_pk(story_pk) + params = {"supported_capabilities_new": json.dumps(config.SUPPORTED_CAPABILITIES)} + while True: + try: + if next_max_id: + params["max_id"] = next_max_id + result = self.private_request(f"media/{story_pk}/list_reel_media_viewer/", params=params) + for item in result['users']: + users.append(extract_user_short(item)) + if amount and len(users) >= amount: + break + next_max_id = result.get('next_max_id') + if not next_max_id: + break + except Exception as e: + self.logger.exception(e) + break + if amount: + users = users[:int(amount)] + return users diff --git a/instagrapi/mixins/timeline.py b/instagrapi/mixins/timeline.py new file mode 100644 index 0000000..2007a18 --- /dev/null +++ b/instagrapi/mixins/timeline.py @@ -0,0 +1,96 @@ +from typing import List + +from instagrapi.extractors import extract_media_v1 +from instagrapi.types import Media + + +class ReelsMixin: + """ + Helpers for Reels + """ + + def reels(self, amount: int = 10, last_media_pk: int = 0) -> List[Media]: + """ + Get connected reels media + + Parameters + ---------- + amount: int, optional + Maximum number of media to return, default is 10 + last_media_pk: int, optional + Last PK user has seen, function will return medias after this pk. Default is 0 + Returns + ------- + List[Media] + A list of objects of Media + """ + return self.reels_timeline_media("reels", amount, last_media_pk) + + def explore_reels(self, amount: int = 10, last_media_pk: int = 0) -> List[Media]: + """ + Get discover reels media + + Parameters + ---------- + amount: int, optional + Maximum number of media to return, default is 10 + last_media_pk: int, optional + Last PK user has seen, function will return medias after this pk. Default is 0 + Returns + ------- + List[Media] + A list of objects of Media + """ + return self.reels_timeline_media("explore_reels", amount, last_media_pk) + + def reels_timeline_media( + self, collection_pk: str, amount: int = 10, last_media_pk: int = 0 + ) -> List[Media]: + """ + Get reels timeline media in a collection + + Parameters + ---------- + collection_pk: str + Unique identifier of a timeline + amount: int, optional + Maximum number of media to return, default is 10 + last_media_pk: int, optional + Last PK user has seen, function will return medias after this pk. Default is 0 + + Returns + ------- + List[Media] + A list of objects of Media + """ + + if collection_pk == "reels": + private_request_endpoint = "clips/connected/" + elif collection_pk == 'explore_reels': + private_request_endpoint = "clips/discover/" + + last_media_pk = last_media_pk and int(last_media_pk) + total_items = [] + next_max_id = "" + while True: + if len(total_items) >= float(amount): + return total_items[:amount] + try: + result = self.private_request( + private_request_endpoint, + data = ' ', + params={"max_id": next_max_id}, + ) + except Exception as e: + self.logger.exception(e) + return total_items + + for item in result["items"]: + if last_media_pk and last_media_pk == item["media"]["pk"]: + return total_items + total_items.append(extract_media_v1(item.get("media"))) + + if not result.get('paging_info',{}).get("more_available"): + return total_items + + next_max_id = result.get('paging_info',{}).get("more_available") diff --git a/instagrapi/mixins/totp.py b/instagrapi/mixins/totp.py new file mode 100644 index 0000000..d69893a --- /dev/null +++ b/instagrapi/mixins/totp.py @@ -0,0 +1,138 @@ +import base64 +import datetime +import hashlib +import hmac +import time +from typing import Any, List, Optional + + +class TOTP: + """ + Base class for OTP handlers. + """ + def __init__(self, s: str, digits: int = 6, digest: Any = hashlib.sha1, name: Optional[str] = None, + issuer: Optional[str] = None) -> None: + self.digits = digits + self.digest = digest + self.secret = s + self.name = name or 'Secret' + self.issuer = issuer + self.interval = 30 + + def generate_otp(self, input: int) -> str: + """ + :param input: the HMAC counter value to use as the OTP input. + Usually either the counter, or the computed integer based on the Unix timestamp + """ + if input < 0: + raise ValueError('input must be positive integer') + hasher = hmac.new(self.byte_secret(), self.int_to_bytestring(input), self.digest) + hmac_hash = bytearray(hasher.digest()) + offset = hmac_hash[-1] & 0xf + code = ((hmac_hash[offset] & 0x7f) << 24 | + (hmac_hash[offset + 1] & 0xff) << 16 | + (hmac_hash[offset + 2] & 0xff) << 8 | + (hmac_hash[offset + 3] & 0xff)) + str_code = str(code % 10 ** self.digits) + while len(str_code) < self.digits: + str_code = '0' + str_code + return str_code + + def byte_secret(self) -> bytes: + secret = self.secret + missing_padding = len(secret) % 8 + if missing_padding != 0: + secret += '=' * (8 - missing_padding) + return base64.b32decode(secret, casefold=True) + + @staticmethod + def int_to_bytestring(i: int, padding: int = 8) -> bytes: + """ + Turns an integer to the OATH specified + bytestring, which is fed to the HMAC + along with the secret + """ + result = bytearray() + while i != 0: + result.append(i & 0xFF) + i >>= 8 + # It's necessary to convert the final result from bytearray to bytes + # because the hmac functions in python 2.6 and 3.3 don't work with + # bytearray + return bytes(bytearray(reversed(result)).rjust(padding, b'\0')) + + def code(self): + """ + Generate TOTP code + """ + now = datetime.datetime.now() + timecode = int(time.mktime(now.timetuple()) / self.interval) + return self.generate_otp(timecode) + + +class TOTPMixin: + + def totp_generate_seed(self) -> str: + """ + Generate 2FA TOTP seed + + Returns + ------- + str + TOTP seed (also known as "token" and "secret key") + """ + result = self.private_request( + "accounts/generate_two_factor_totp_key/", + data=self.with_default_data({}) + ) + return result["totp_seed"] + + def totp_enable(self, verification_code: str) -> List[str]: + """ + Enable TOTP 2FA + + Parameters + ---------- + verification_code: str + 2FA verification code + + Returns + ------- + List[str] + Backup codes + """ + result = self.private_request( + "accounts/enable_totp_two_factor/", + data=self.with_default_data({'verification_code': verification_code}) + ) + return result["backup_codes"] + + def totp_disable(self) -> bool: + """ + Disable TOTP 2FA + + Returns + ------- + bool + """ + result = self.private_request( + "accounts/disable_totp_two_factor/", + data=self.with_default_data({}) + ) + return result["status"] == "ok" + + def totp_generate_code(self, seed: str) -> str: + """ + Generate 2FA TOTP code + + Parameters + ---------- + seed: str + TOTP seed (token, secret key) + + Returns + ------- + str + TOTP code + """ + return TOTP(seed).code() diff --git a/instagrapi/mixins/user.py b/instagrapi/mixins/user.py new file mode 100644 index 0000000..142ce8d --- /dev/null +++ b/instagrapi/mixins/user.py @@ -0,0 +1,1026 @@ +from copy import deepcopy +from json.decoder import JSONDecodeError +from typing import Dict, List, Tuple + +from instagrapi.exceptions import ( + ClientError, + ClientJSONDecodeError, + ClientLoginRequired, + ClientNotFoundError, + UserNotFound, +) +from instagrapi.extractors import extract_user_gql, extract_user_short, extract_user_v1 +from instagrapi.types import Relationship, 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 + _users_following = {} # user_pk -> dict(user_pk -> "short user object") + _users_followers = {} # user_pk -> dict(user_pk -> "short user object") + + def user_id_from_username(self, username: str) -> str: + """ + Get full media id + + Parameters + ---------- + username: str + Username for an Instagram account + + Returns + ------- + str + User PK + + Example + ------- + 'adw0rd' -> 1903424587 + """ + username = str(username).lower() + return str(self.user_info_by_username(username).pk) + + def user_short_gql(self, user_id: str, use_cache: bool = True) -> UserShort: + """ + Get full media id + + Parameters + ---------- + user_id: str + 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) + if cache: + return cache + variables = { + "user_id": str(user_id), + "include_reel": True, + } + data = self.public_graphql_request( + variables, query_hash="ad99dd9d3646cc3c0dda65debcd266a7" + ) + if not data["user"]: + raise UserNotFound(user_id=user_id, **data) + user = extract_user_short(data["user"]["reel"]["user"]) + self._userhorts_cache[user_id] = user + return user + + def username_from_user_id_gql(self, user_id: str) -> str: + """ + Get username from user id + + Parameters + ---------- + user_id: str + 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: str) -> str: + """ + Get username from user id + + Parameters + ---------- + user_id: str + User ID + + Returns + ------- + str + User name + + Example + ------- + 1903424587 -> 'adw0rd' + """ + user_id = str(user_id) + try: + username = self.username_from_user_id_gql(user_id) + except ClientError: + username = self.user_info_v1(user_id).username + return username + + def user_info_by_username_gql(self, username: str) -> User: + """ + Get user object from user name + + Parameters + ---------- + username: str + User name of an instagram account + + Returns + ------- + User + An object of User type + """ + username = str(username).lower() + return extract_user_gql(self.public_a1_request(f"/{username!s}/")["user"]) + + def user_info_by_username_v1(self, username: str) -> User: + """ + Get user object from user name + + Parameters + ---------- + username: str + User name of an instagram account + + Returns + ------- + User + An object of User type + """ + username = str(username).lower() + try: + result = self.private_request(f"users/{username}/usernameinfo/") + except ClientNotFoundError as e: + raise UserNotFound(e, username=username, **self.last_json) + except ClientError as e: + if "User not found" in str(e): + raise UserNotFound(e, username=username, **self.last_json) + raise e + return extract_user_v1(result["user"]) + + def user_info_by_username(self, username: str, use_cache: bool = True) -> User: + """ + 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 + """ + username = str(username).lower() + if not use_cache or username not in self._usernames_cache: + try: + try: + user = self.user_info_by_username_gql(username) + except ClientLoginRequired as e: + if not self.inject_sessionid_to_public(): + raise e + user = self.user_info_by_username_gql(username) # retry + except Exception as e: + if not isinstance(e, ClientError): + self.logger.exception(e) # Register unknown error + user = self.user_info_by_username_v1(username) + self._users_cache[user.pk] = user + self._usernames_cache[user.username] = user.pk + return self.user_info(self._usernames_cache[username]) + + def user_info_gql(self, user_id: str) -> User: + """ + Get user object from user id + + Parameters + ---------- + user_id: str + User id of an instagram account + + Returns + ------- + User + An object of User type + """ + user_id = str(user_id) + try: + # GraphQL haven't method to receive user by id + return self.user_info_by_username_gql( + self.username_from_user_id_gql(user_id) + ) + except JSONDecodeError as e: + raise ClientJSONDecodeError(e, user_id=user_id) + + def user_info_v1(self, user_id: str) -> User: + """ + Get user object from user id + + Parameters + ---------- + user_id: str + User id of an instagram account + + Returns + ------- + User + An object of User type + """ + user_id = str(user_id) + try: + result = self.private_request(f"users/{user_id}/info/") + except ClientNotFoundError as e: + raise UserNotFound(e, user_id=user_id, **self.last_json) + except ClientError as e: + if "User not found" in str(e): + raise UserNotFound(e, user_id=user_id, **self.last_json) + raise e + return extract_user_v1(result["user"]) + + def user_info(self, user_id: str, use_cache: bool = True) -> User: + """ + Get user object from user id + + Parameters + ---------- + user_id: str + 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 = str(user_id) + if not use_cache or user_id not in self._users_cache: + try: + try: + user = self.user_info_gql(user_id) + except ClientLoginRequired as e: + if not self.inject_sessionid_to_public(): + raise e + user = self.user_info_gql(user_id) # retry + except Exception as e: + if not isinstance(e, ClientError): + self.logger.exception(e) + 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) + + def new_feed_exist(self) -> bool: + """ + Returns bool + ------- + Check if new feed exist + ------- + True if new feed exist , + After Login or load Settings always return False + """ + results = self.private_request("feed/new_feed_posts_exist/") + return results.get("new_feed_posts_exist", False) + + def user_friendships_v1(self, user_ids: List[str]) -> dict: + """ + Get user friendship status + + Parameters + ---------- + user_ids: List[str] + List of user id of an instagram account + + Returns + ------- + dict + """ + result = self.private_request( + "friendships/show_many/", + data={"user_ids": user_ids} + ) + return result["friendship_statuses"] + + def user_friendship_v1(self, user_id: str) -> Relationship: + """ + Get user friendship status + + Parameters + ---------- + user_id: str + User id of an instagram account + + Returns + ------- + Relationship + An object of Relationship type + """ + + try: + results = self.private_request(f"friendships/show/{user_id}/") + return Relationship(**results) + except ClientError as e: + self.logger.exception(e) + return None + + def search_followers_v1(self, user_id: str, query: str) -> List[UserShort]: + """ + Search users by followers (Private Mobile API) + + Parameters + ---------- + user_id: str + User id of an instagram account + query: str + Query to search + + Returns + ------- + List[UserShort] + List of users + """ + results = self.private_request( + f"friendships/{user_id}/followers/", + params={ + "search_surface": "follow_list_page", + "query": query, + "enable_groups": "true" + } + ) + users = results.get("users", []) + return [extract_user_short(user) for user in users] + + def search_followers(self, user_id: str, query: str) -> List[UserShort]: + """ + Search by followers + + Parameters + ---------- + user_id: str + User id of an instagram account + query: str + Query string + + Returns + ------- + List[UserShort] + List of User short object + """ + return self.search_followers_v1(user_id, query) + + def search_following_v1(self, user_id: str, query: str) -> List[UserShort]: + """ + Search following users (Private Mobile API) + + Parameters + ---------- + user_id: str + User id of an instagram account + query: str + Query to search + + Returns + ------- + List[UserShort] + List of users + """ + results = self.private_request( + f"friendships/{user_id}/following/", + params={ + "includes_hashtags": "false", + "search_surface": "follow_list_page", + "query": query, + "enable_groups": "true" + } + ) + users = results.get("users", []) + return [extract_user_short(user) for user in users] + + def search_following(self, user_id: str, query: str) -> List[UserShort]: + """ + Search by following + + Parameters + ---------- + user_id: str + User id of an instagram account + query: str + Query string + + Returns + ------- + List[UserShort] + List of User short object + """ + return self.search_following_v1(user_id, query) + + def user_following_gql(self, user_id: str, amount: int = 0) -> List[UserShort]: + """ + Get user's following information by Public Graphql API + + Parameters + ---------- + user_id: str + 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 = str(user_id) + end_cursor = None + users = [] + variables = { + "id": user_id, + "include_reel": True, + "fetch_mutual": False, + "first": 24, + } + self.inject_sessionid_to_public() + while True: + if end_cursor: + variables["after"] = end_cursor + data = self.public_graphql_request( + variables, query_hash="e7e2f4da4b02303f74f0841279e52d76" + ) + 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=[]) + for edge in edges: + users.append(extract_user_short(edge["node"])) + end_cursor = page_info.get("end_cursor") + if not page_info.get("has_next_page") or not end_cursor: + break + if amount and len(users) >= amount: + break + # time.sleep(sleep) + if amount: + users = users[:amount] + return users + + def user_following_v1(self, user_id: str, amount: int = 0) -> List[UserShort]: + """ + Get user's following users information by Private Mobile API + + Parameters + ---------- + user_id: str + 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 = str(user_id) + max_id = "" + users = [] + while True: + if amount and len(users) >= amount: + break + params = { + "rank_token": self.rank_token, + "search_surface": "follow_list_page", + "includes_hashtags": "true", + "enable_groups": "true", + "query": "", + "count": 10000 + } + if max_id: + params["max_id"] = max_id + result = self.private_request(f"friendships/{user_id}/following/", params=params) + for user in result["users"]: + users.append(extract_user_short(user)) + max_id = result.get("next_max_id") + if not max_id: + break + if amount: + users = users[:amount] + return users + + def user_following( + self, user_id: str, use_cache: bool = True, amount: int = 0 + ) -> Dict[str, UserShort]: + """ + Get user's followers information + + Parameters + ---------- + user_id: str + 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[str, UserShort] + Dict of user_id and User object + """ + user_id = str(user_id) + users = self._users_following.get(user_id, {}) + if not use_cache or not users or (amount and len(users) < amount): + # 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: + # if not isinstance(e, ClientError): + # 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} + following = self._users_following[user_id] + if amount and len(following) > amount: + following = dict(list(following.items())[:amount]) + return following + + def user_followers_gql_chunk(self, user_id: str, max_amount: int = 0, end_cursor: str = None) -> Tuple[ + List[UserShort], str]: + """ + Get user's followers information by Public Graphql API and end_cursor + + Parameters + ---------- + user_id: str + User id of an instagram account + max_amount: int, optional + Maximum number of media to return, default is 0 - Inf + end_cursor: str, optional + The cursor from which it is worth continuing to receive the list of followers + + Returns + ------- + Tuple[List[UserShort], str] + List of objects of User type with cursor + """ + user_id = str(user_id) + users = [] + variables = { + "id": user_id, + "include_reel": True, + "fetch_mutual": False, + "first": 12 + } + self.inject_sessionid_to_public() + while True: + if end_cursor: + variables["after"] = end_cursor + data = self.public_graphql_request( + variables, query_hash="5aefa9893005572d237da5068082d8d5" + ) + if not data["user"] and not users: + raise UserNotFound(user_id=user_id, **data) + page_info = json_value(data, "user", "edge_followed_by", "page_info", default={}) + edges = json_value(data, "user", "edge_followed_by", "edges", default=[]) + for edge in edges: + users.append(extract_user_short(edge["node"])) + end_cursor = page_info.get("end_cursor") + if not page_info.get("has_next_page") or not end_cursor: + break + if max_amount and len(users) >= max_amount: + break + return users, end_cursor + + def user_followers_gql(self, user_id: str, amount: int = 0) -> List[UserShort]: + """ + Get user's followers information by Public Graphql API + + Parameters + ---------- + user_id: str + User id of an instagram account + amount: int, optional + Maximum number of media to return, default is 0 - Inf + + Returns + ------- + List[UserShort] + List of objects of User type + """ + users, _ = self.user_followers_gql_chunk(str(user_id), amount) + if amount: + users = users[:amount] + return users + + def user_followers_v1_chunk(self, user_id: str, max_amount: int = 0, max_id: str = "") -> Tuple[ + List[UserShort], str]: + """ + Get user's followers information by Private Mobile API and max_id (cursor) + + Parameters + ---------- + user_id: str + User id of an instagram account + max_amount: int, optional + Maximum number of media to return, default is 0 - Inf + max_id: str, optional + Max ID, default value is empty String + + Returns + ------- + Tuple[List[UserShort], str] + Tuple of List of users and max_id + """ + unique_set = set() + users = [] + while True: + result = self.private_request(f"friendships/{user_id}/followers/", params={ + "max_id": max_id, + "count": 10000, + "rank_token": self.rank_token, + "search_surface": "follow_list_page", + "query": "", + "enable_groups": "true" + }) + for user in result["users"]: + user = extract_user_short(user) + if user.pk in unique_set: + continue + unique_set.add(user.pk) + users.append(user) + max_id = result.get("next_max_id") + if not max_id or (max_amount and len(users) >= max_amount): + break + return users, max_id + + def user_followers_v1(self, user_id: str, amount: int = 0) -> List[UserShort]: + """ + Get user's followers information by Private Mobile API + + Parameters + ---------- + user_id: str + User id of an instagram account + amount: int, optional + Maximum number of media to return, default is 0 - Inf + + Returns + ------- + List[UserShort] + List of objects of User type + """ + users, _ = self.user_followers_v1_chunk(str(user_id), amount) + if amount: + users = users[:amount] + return users + + def user_followers( + self, user_id: str, use_cache: bool = True, amount: int = 0 + ) -> Dict[str, UserShort]: + """ + Get user's followers + + Parameters + ---------- + user_id: str + 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 - Inf + + Returns + ------- + Dict[str, UserShort] + Dict of user_id and User object + """ + user_id = str(user_id) + users = self._users_followers.get(user_id, {}) + if not use_cache or not users or (amount and len(users) < amount): + try: + users = self.user_followers_gql(user_id, amount) + except Exception as e: + if not isinstance(e, ClientError): + self.logger.exception(e) + users = self.user_followers_v1(user_id, amount) + 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: str) -> bool: + """ + Follow a user + + Parameters + ---------- + user_id: str + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + if user_id in self._users_following.get(self.user_id, []): + self.logger.debug("User %s already followed", user_id) + return False + data = self.with_action_data({"user_id": user_id}) + result = self.private_request(f"friendships/create/{user_id}/", data) + if self.user_id in self._users_following: + self._users_following.pop(self.user_id) # reset + return result["friendship_status"]["following"] is True + + def user_unfollow(self, user_id: str) -> bool: + """ + Unfollow a user + + Parameters + ---------- + user_id: str + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": user_id}) + result = self.private_request(f"friendships/destroy/{user_id}/", data) + 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_remove_follower(self, user_id: str) -> bool: + """ + Remove a follower + + Parameters + ---------- + user_id: str + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": str(user_id)}) + result = self.private_request(f"friendships/remove_follower/{user_id}/", data) + if self.user_id in self._users_followers: + self._users_followers[self.user_id].pop(user_id, None) + return result["friendship_status"]["followed_by"] is False + + def mute_posts_from_follow(self, user_id: str, revert: bool = False) -> bool: + """ + Mute posts from following user + + Parameters + ---------- + user_id: str + Unique identifier of a User + revert: bool, optional + Unmute when True + + Returns + ------- + bool + A boolean value + """ + user_id = str(user_id) + name = "unmute" if revert else "mute" + result = self.private_request( + f"friendships/{name}_posts_or_story_from_follow/", + { + # "media_id": media_pk, # when feed_timeline + "target_posts_author_id": str(user_id), + "container_module": "media_mute_sheet" # or "feed_timeline" + } + ) + return result["status"] == "ok" + + def unmute_posts_from_follow(self, user_id: str) -> bool: + """ + Unmute posts from following user + + Parameters + ---------- + user_id: str + Unique identifier of a User + + Returns + ------- + bool + A boolean value + """ + return self.mute_posts_from_follow(user_id, True) + + def mute_stories_from_follow(self, user_id: str, revert: bool = False) -> bool: + """ + Mute stories from following user + + Parameters + ---------- + user_id: str + Unique identifier of a User + revert: bool, optional + Unmute when True + + Returns + ------- + bool + A boolean value + """ + user_id = str(user_id) + name = "unmute" if revert else "mute" + result = self.private_request( + f"friendships/{name}_posts_or_story_from_follow/", + { + # "media_id": media_pk, # when feed_timeline + "target_reel_author_id": str(user_id), + "container_module": "media_mute_sheet" # or "feed_timeline" + } + ) + return result["status"] == "ok" + + def unmute_stories_from_follow(self, user_id: str) -> bool: + """ + Unmute stories from following user + + Parameters + ---------- + user_id: str + Unique identifier of a User + + Returns + ------- + bool + A boolean value + """ + return self.mute_stories_from_follow(user_id, True) + + def enable_posts_notifications(self, user_id: str, disable: bool = False) -> bool: + """ + Enable post notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + disable: bool, optional + Unfavorite when True + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": user_id, "_uid": self.user_id}) + name = "unfavorite" if disable else "favorite" + result = self.private_request(f"friendships/{name}/{user_id}/", data) + return result["status"] == "ok" + + def disable_posts_notifications(self, user_id: str) -> bool: + """ + Disable post notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + Returns + ------- + bool + A boolean value + """ + return self.enable_posts_notifications(user_id, True) + + def enable_videos_notifications(self, user_id: str, revert: bool = False) -> bool: + """ + Enable videos notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + revert: bool, optional + Unfavorite when True + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": user_id, "_uid": self.user_id}) + name = "unfavorite" if revert else "favorite" + result = self.private_request(f"friendships/{name}_for_igtv/{user_id}/", data) + return result["status"] == "ok" + + def disable_videos_notifications(self, user_id: str) -> bool: + """ + Disable videos notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + Returns + ------- + bool + A boolean value + """ + return self.enable_videos_notifications(user_id, True) + + def enable_reels_notifications(self, user_id: str, revert: bool = False) -> bool: + """ + Enable reels notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + revert: bool, optional + Unfavorite when True + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": user_id, "_uid": self.user_id}) + name = "unfavorite" if revert else "favorite" + result = self.private_request(f"friendships/{name}_for_clips/{user_id}/", data) + return result["status"] == "ok" + + def disable_reels_notifications(self, user_id: str) -> bool: + """ + Disable reels notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + Returns + ------- + bool + A boolean value + """ + return self.enable_reels_notifications(user_id, True) + + def enable_stories_notifications(self, user_id: str, revert: bool = False) -> bool: + """ + Enable stories notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + revert: bool, optional + Unfavorite when True + + Returns + ------- + bool + A boolean value + """ + assert self.user_id, "Login required" + user_id = str(user_id) + data = self.with_action_data({"user_id": user_id, "_uid": self.user_id}) + name = "unfavorite" if revert else "favorite" + result = self.private_request(f"friendships/{name}_for_stories/{user_id}/", data) + return result["status"] == "ok" + + def disable_stories_notifications(self, user_id: str) -> bool: + """ + Disable stories notifications of a user + + Parameters + ---------- + user_id: str + Unique identifier of a User + Returns + ------- + bool + A boolean value + """ + return self.enable_stories_notifications(user_id, True) diff --git a/instagrapi/mixins/video.py b/instagrapi/mixins/video.py new file mode 100644 index 0000000..755ac4d --- /dev/null +++ b/instagrapi/mixins/video.py @@ -0,0 +1,876 @@ +import random +import time +from pathlib import Path +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_direct_message, extract_media_v1 +from instagrapi.types import ( + DirectMessage, + Location, + Media, + Story, + StoryHashtag, + StoryLink, + StoryLocation, + StoryMedia, + StoryMention, + StorySticker, + Usertag, +) +from instagrapi.utils import date_time_original, 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( + username=media.user.username, media_pk=media_pk + ) + return self.video_download_by_url(media.video_url, filename, folder) + + def video_download_by_url( + self, url: str, filename: str = "", folder: Path = "" + ) -> Path: + """ + Download video using URL + + 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() + content_length = int(response.headers.get("Content-Length")) + file_length = len(response.content) + if content_length != file_length: + raise VideoNotDownload( + f'Broken file "{path}" (Content-length={content_length}, but file length={file_length})' + ) + with open(path, "wb") as f: + f.write(response.content) + f.close() + return path.resolve() + + def video_download_by_url_origin( + self, url: str + ) -> bytes: + """ + Download video using URL + + Parameters + ---------- + url: str + URL for a media + + Returns + ------- + bytes + Bytes for the file downloaded + """ + response = requests.get(url, stream=True) + response.raise_for_status() + content_length = int(response.headers.get("Content-Length")) + file_length = len(response.content) + if content_length != file_length: + raise VideoNotDownload( + f'Broken file from url "{url}" (Content-length={content_length}, but file length={file_length})' + ) + return response.content + + +class UploadVideoMixin: + """ + Helpers for downloading video + """ + + def video_rupload( + self, + path: Path, + thumbnail: Path = None, + to_album: bool = False, + to_story: bool = False, + to_direct: bool = False, + ) -> tuple: + """ + Upload video to Instagram + + 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 + to_direct: bool, optional + + 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)) + width, height, duration, thumbnail = analyze_video(path, thumbnail) + waterfall_id = str(uuid4()) + # upload_name example: '1576102477530_0_7823256191' + upload_name = "{upload_id}_0_{rand}".format( + upload_id=upload_id, rand=random.randint(1000000000, 9999999999) + ) + rupload_params = { + "retry_context": '{"num_step_auto_retry":0,"num_reupload":0,"num_step_manual_retry":0}', + "media_type": "2", + "xsharing_user_ids": dumps([self.user_id]), + "upload_id": upload_id, + "upload_media_duration_ms": str(int(duration * 1000)), + "upload_media_width": str(width), + "upload_media_height": str(height), # "1138" for Mi5s + } + if to_direct: + rupload_params["direct_v2"] = "1" + # "hflip": "false", + # "rotate":"3", + if to_album: + rupload_params["is_sidecar"] = "1" + if to_story: + rupload_params = { + "extract_cover_frame": "1", + "content_tags": "has-overlay", + "for_album": "1", + **rupload_params, + } + headers = { + "Accept-Encoding": "gzip, deflate", + "X-Instagram-Rupload-Params": dumps(rupload_params), + "X_FB_VIDEO_WATERFALL_ID": waterfall_id, + # "X_FB_VIDEO_WATERFALL_ID": "88732215909430_55CF262450C9_Mixed_0", # ALBUM + # "X_FB_VIDEO_WATERFALL_ID": "1594919079102", # VIDEO + } + if to_album: + 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, + ) + self.request_log(response) + if response.status_code != 200: + raise VideoNotUpload(response.text, response=response, **self.last_json) + with open(path, "rb") as fp: + video_data = fp.read() + video_len = str(len(video_data)) + headers = { + "Offset": "0", + "X-Entity-Name": upload_name, + "X-Entity-Length": video_len, + "Content-Type": "application/octet-stream", + "Content-Length": video_len, + "X-Entity-Type": "video/mp4", + **headers, + } + response = self.private.post( + "https://{domain}/rupload_igvideo/{name}".format( + domain=config.API_DOMAIN, name=upload_name + ), + data=video_data, + headers=headers, + ) + self.request_log(response) + if response.status_code != 200: + raise VideoNotUpload(response.text, response=response, **self.last_json) + return upload_id, width, height, duration, Path(thumbnail) + + def video_upload( + self, + path: Path, + caption: str, + thumbnail: Path = None, + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> 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 + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + 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=False + ) + for attempt in range(50): + self.logger.debug(f"Attempt #{attempt} to configure Video: {path}") + time.sleep(3) + try: + configured = self.video_configure( + upload_id, + width, + height, + duration, + thumbnail, + caption, + usertags, + location, + extra_data=extra_data + ) + 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 + else: + if configured: + media = configured.get("media") + self.expose() + return extract_media_v1(media) + raise VideoConfigureError( + response=self.last_response, + **self.last_json + ) + + def video_configure( + self, + upload_id: str, + width: int, + height: int, + duration: int, + thumbnail: Path, + caption: str, + usertags: List[Usertag] = [], + location: Location = None, + extra_data: Dict[str, str] = {}, + ) -> 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 + extra_data: Dict[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 + """ + self.photo_rupload(Path(thumbnail), upload_id) + usertags = [ + {"user_id": tag.user.pk, "position": [tag.x, tag.y]} for tag in usertags + ] + data = { + "multi_sharing": "1", + "creation_logger_session_id": self.client_session_id, + "upload_id": upload_id, + "source_type": "4", + "location": self.location_build(location), + "poster_frame_index": 0, + "length": duration, + "audio_muted": False, + "usertags": dumps({"in": usertags}), + "filter_type": "0", + "date_time_original": date_time_original(time.localtime()), + "timezone_offset": str(self.timezone_offset), + "clips": [{"length": duration, "source_type": "4"}], + "extra": {"source_width": width, "source_height": height}, + "device": self.device, + "caption": caption, + **extra_data + } + return self.private_request( + "media/configure/?video=1", self.with_default_data(data) + ) + + def video_upload_to_story( + self, + path: Path, + caption: str = "", + thumbnail: Path = None, + mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + medias: List[StoryMedia] = [], + extra_data: Dict[str, str] = {}, + ) -> Story: + """ + Upload video as a story and configure it + + Parameters + ---------- + path: Path + Path to the media + caption: str + Story 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. + medias: List[StoryMedia], optional + List of medias to be tagged on this upload, default is empty list. + extra_data: Dict[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + Story + 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=True + ) + for attempt in range(50): + 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, + medias, + extra_data=extra_data + ) + 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, + medias=medias, + **extract_media_v1(media).dict() + ) + raise VideoConfigureStoryError( + response=self.last_response, **self.last_json + ) + + def video_configure_to_story( + self, + upload_id: str, + width: int, + height: int, + duration: int, + thumbnail: Path, + caption: str, + mentions: List[StoryMention] = [], + locations: List[StoryLocation] = [], + links: List[StoryLink] = [], + hashtags: List[StoryHashtag] = [], + stickers: List[StorySticker] = [], + medias: List[StoryMedia] = [], + thread_ids: List[int] = [], + 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. + medias: List[StoryMedia], optional + List of medias to be tagged on this upload, default is empty list. + thread_ids: List[int], optional + List of Direct Message Thread ID (to send a story to a thread) + extra_data: Dict[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 = { + # USE extra_data TO EXTEND THE SETTINGS OF THE LOADED STORY, USE FOR EXAMPLE THE PROPERTIES SPECIFIED IN THE COMMENT: + # --------------------------------- + # When send to DIRECT: + # "allow_multi_configures": "1", + # "client_context":"6823316152962778207", <-- token = random.randint(6800011111111111111, 6800099999999999999) from direct.py + # "is_shh_mode":"0", + # "mutation_token":"6824688191453546273", + # "nav_chain":"1qT:feed_timeline:1,1qT:feed_timeline:7,ReelViewerFragment:reel_feed_timeline:21,5HT:attribution_quick_camera_fragment:22,4ji:reel_composer_preview:23,8wg:direct_story_audience_picker:24,4ij:reel_composer_camera:25,ReelViewerFragment:reel_feed_timeline:26", + # "recipient_users":"[]", + # "send_attribution":"direct_story_audience_picker", + # "thread_ids":"[\"340282366841710300949128149448121770626\"]", <-- send story to direct + # "view_mode": "replayable", + # --------------------------------- + # Optional (markup for caption field) when tagging: + # "story_captions":"[{\"text\":\"@user1+\\n\\n@user2+\",\"position_data\":{\"x\":0.5,\"y\":0.5,\"height\":272.0,\"width\":670.0,\"rotation\":0.0},\"scale\":1.0,\"font_size\":24.0,\"format_type\":\"classic_v2\",\"effects\":[\"disabled\"],\"colors\":[\"#ffffff\"],\"alignment\":\"center\",\"animation\":\"\"}]", + # --------------------------------- + # SEGMENT MODE (when file is too big): + # "allow_multi_configures": "1", + # "segmented_video_group_id": str(uuid4()), + # "multi_upload_session_id": str(uuid4()), + # "segmented_video_count": "4", # "4" # SEGMENT MODE + # "segmented_video_index": "0", # 0,1,2,3 # SEGMENT MODE + # "is_multi_upload": "1", # SEGMENT MODE + # "is_segmented_video": "1", # SEGMENT MODE + # --------------------------------- + # COMMON properties: + "_uid": str(self.user_id), + "supported_capabilities_new": dumps(config.SUPPORTED_CAPABILITIES), + "has_original_sound": "1", + "filter_type": "0", + "camera_session_id": self.client_session_id, + "camera_entry_point": str(random.randint(35, 164)), + "composition_id": self.generate_uuid(), + # "camera_make": self.device_settings.get("manufacturer", "Xiaomi"), + # "camera_model": self.device_settings.get("model", "MI+5s"), + "timezone_offset": str(self.timezone_offset), + "client_timestamp": str(timestamp), + "client_shared_at": str(timestamp - 7), # 7 seconds ago + # "imported_taken_at": str(timestamp - 5 * 24 * 3600), # 5 days ago + "date_time_original": date_time_original(time.localtime()), + # "date_time_digitalized": date_time_original(time.localtime()), + # "story_sticker_ids": "", + # "media_folder": "Camera", + "configure_mode": "1", + # "configure_mode": "2", <- when direct + "source_type": "3", # "3" + "video_result": "", + "creation_surface": "camera", + # "software": config.SOFTWARE.format(**self.device_settings), + # "caption": caption, + "capture_type": "normal", + # "rich_text_format_types": '["classic_v2"]', # default, typewriter + "upload_id": upload_id, + # "scene_capture_type": "standard", + # "scene_type": "", + "original_media_type": "video", + "camera_position": "back", + # Facebook Sharing Part: + # "xpost_surface": "auto_xpost", + # "share_to_fb_destination_type": "USER", + # "share_to_fb_destination_id":"832928543", + # "share_to_facebook":"1", + # "fb_access_token":"EAABwzLixnjYBACVgqBfLyDuPWs6RN2sTZC........cnNkjHCH2", + # "attempt_id": str(uuid4()), + "device": self.device, + "length": duration, + "clips": [{"length": duration, "source_type": "3", "camera_position": "back"}], + # "edits": { + # "filter_type": 0, + # "filter_strength": 1.0, + # "crop_original_size": [width, height], + # # "crop_center": [0, 0], + # # "crop_zoom": 1 + # }, + "media_transformation_info": dumps({ + "width": str(width), + "height": str(height), + "x_transform": "0", + "y_transform": "0", + "zoom": "1.0", + "rotation": "0.0", + "background_coverage": "0.0" + }), + "extra": {"source_width": width, "source_height": height}, + "audio_muted": False, + "poster_frame_index": 0, + # "app_attribution_android_namespace": "", + } + data.update(extra_data) + 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", + "tap_state": 0, + "tap_state_str_id": "mention_text", + } + ) + text_metadata.append( + { + "font_size": 24.0, + "scale": 1.0, + "width": 366.0, + "height": 102.0, + "x": mention.x, + "y": mention.y, + "rotation": 0.0, + } + ) + data["text_metadata"] = dumps(text_metadata) + # 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 links: + # instagram allow one link now + link = links[0] + self.private_request("media/validate_reel_url/", { + "url": str(link.webUri), + "_uid": str(self.user_id), + "_uuid": str(self.uuid), + }) + stickers.append( + StorySticker( + type="story_link", + x=link.x, + y=link.y, + z=link.z, + width=link.width, + height=link.height, + rotation=link.rotation, + extra=dict( + link_type="web", + url=str(link.webUri), + tap_state_str_id="link_sticker_default" + ) + ) + ) + story_sticker_ids.append("link_sticker_default") + if stickers: + for sticker in stickers: + sticker_extra = sticker.extra or {} + if sticker.id: + sticker_extra["str_id"] = sticker.id + story_sticker_ids.append(sticker.id) + tap_models.append({ + "x": round(sticker.x, 7), + "y": round(sticker.y, 7), + "z": sticker.z, + "width": round(sticker.width, 7), + "height": round(sticker.height, 7), + "rotation": sticker.rotation, + "type": sticker.type, + "is_sticker": True, + "selected_index": 0, + "tap_state": 0, + **sticker_extra + }) + if sticker.type == "gif": + data["has_animated_sticker"] = "1" + if medias: + for feed_media in medias: + assert feed_media.media_pk, "Required StoryMedia.media_pk" + # if not feed_media.user_id: + # user = self.media_user(feed_media.media_pk) + # feed_media.user_id = user.pk + item = { + "x": feed_media.x, + "y": feed_media.y, + "z": feed_media.z, + "width": feed_media.width, + "height": feed_media.height, + "rotation": feed_media.rotation, + "type": "feed_media", + "media_id": str(feed_media.media_pk), + "media_owner_id": str(feed_media.user_id or ""), + "product_type": "feed", + "is_sticker": True, + "tap_state": 0, + "tap_state_str_id": "feed_post_sticker_square" + } + tap_models.append(item) + data["reshared_media_id"] = str(feed_media.media_pk) + if thread_ids: + # Send to direct thread + token = self.generate_mutation_token() + data.update({ + "configure_mode": "2", + "allow_multi_configures": "1", + "client_context": token, + "is_shh_mode": "0", + "mutation_token": token, + "nav_chain": "1qT:feed_timeline:1,1qT:feed_timeline:7,ReelViewerFragment:reel_feed_timeline:21,5HT:attribution_quick_camera_fragment:22,4ji:reel_composer_preview:23,8wg:direct_story_audience_picker:24,4ij:reel_composer_camera:25,ReelViewerFragment:reel_feed_timeline:26", + "recipient_users": "[]", + "send_attribution": "direct_story_audience_picker", + "thread_ids": dumps([str(tid) for tid in thread_ids]), + "view_mode": "replayable" + }) + if tap_models: + data["tap_models"] = dumps(tap_models) + if static_models: + data["static_models"] = dumps(static_models) + if story_sticker_ids: + data["story_sticker_ids"] = story_sticker_ids[0] + return self.private_request("media/configure_to_story/?video=1", self.with_default_data(data)) + + def video_upload_to_direct( + self, + path: Path, + caption: str = "", + thumbnail: Path = None, + mentions: List[StoryMention] = [], + medias: List[StoryMedia] = [], + thread_ids: List[int] = [], + extra_data: Dict[str, str] = {}, + ) -> DirectMessage: + """ + Upload video to direct thread as a story and configure it + + Parameters + ---------- + path: Path + Path to the media + caption: str + Story 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. + thread_ids: List[int], optional + List of Direct Message Thread ID (to send a story to a thread) + extra_data: List[str, str], optional + Dict of extra data, if you need to add your params, like {"share_to_facebook": 1}. + + Returns + ------- + DirectMessage + An object of DirectMessage 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=True + ) + for attempt in range(50): + 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=mentions, + medias=medias, + thread_ids=thread_ids, + extra_data=extra_data + ) + 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 and thread_ids: + return extract_direct_message(configured.get("message_metadata", [])[0]) + raise VideoConfigureStoryError( + response=self.last_response, **self.last_json + ) + + +def analyze_video(path: Path, thumbnail: Path = None) -> tuple: + """ + 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") + + print(f'Analizing video file "{path}"') + video = mp.VideoFileClip(str(path)) + width, height = video.size + if not thumbnail: + thumbnail = f"{path}.jpg" + print(f'Generating thumbnail "{thumbnail}"...') + video.save_frame(thumbnail, t=(video.duration / 2)) + # duration = round(video.duration + 0.001, 3) + video.close() + return width, height, video.duration, thumbnail diff --git a/instagrapi/story.py b/instagrapi/story.py new file mode 100644 index 0000000..e1f0f57 --- /dev/null +++ b/instagrapi/story.py @@ -0,0 +1,238 @@ +import tempfile +from pathlib import Path +from typing import List +from urllib.parse import urlparse + +from .types import StoryBuild, StoryMention, StorySticker + +try: + from moviepy.editor import CompositeVideoClip, ImageClip, TextClip, VideoFileClip +except ImportError: + raise Exception("Please install moviepy==1.0.3 and retry") + +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=8.1.1") + + +class StoryBuilder: + """ + Helpers for Story building + """ + + width = 720 + height = 1280 + + 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 + self.mentions = mentions + self.bgpath = Path(bgpath) if bgpath else None + + def build_main(self, clip, max_duration: int = 0, font: str = 'Arial', fontsize: int = 100, color: str = 'white', link: str = "") -> StoryBuild: + """ + 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 + font: str, optional + Name of font for text clip + fontsize: int, optional + Size of font + color: str, optional + Color of text + + Returns + ------- + StoryBuild + An object of StoryBuild + """ + clips = [] + stickers = [] + # Background + if self.bgpath: + assert self.bgpath.exists(), f"Wrong path to background {self.bgpath}" + background = ImageClip(str(self.bgpath)) + clips.append(background) + # Media clip + clip_left = (self.width - clip.size[0]) / 2 + clip_top = (self.height - clip.size[1]) / 2 + if clip_top > 90: + clip_top -= 50 + media_clip = clip.set_position((clip_left, clip_top)) + clips.append(media_clip) + mention = self.mentions[0] if self.mentions else None + # Text clip + caption = self.caption + if self.mentions: + mention = self.mentions[0] + if getattr(mention, 'user', None): + caption = f"@{mention.user.username}" + if caption: + text_clip = TextClip( + caption, + color=color, + font=font, + kerning=-1, + fontsize=fontsize, + 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) + ) + clips.append(text_clip) + if link: + url = urlparse(link) + link_clip = TextClip( + url.netloc, + color="blue", + bg_color="white", + font=font, + kerning=-1, + fontsize=32, + method="label", + ) + link_clip_left = (self.width - 400) / 2 + link_clip_top = clip.size[1] / 2 + link_clip = ( + link_clip.resize(width=400) + .set_position((link_clip_left, link_clip_top)) + .fadein(3) + ) + link_sticker = StorySticker( + # x=160.0, y=641.0, z=0, width=400.0, height=88.0, + x=round(link_clip_left / self.width, 7), # e.g. 0.49953705 + y=round(link_clip_top / self.height, 7), # e.g. 0.5 + z=0, + width=round(link_clip.size[0] / self.width, 7), # e.g. 0.50912 + height=round(link_clip.size[1] / self.height, 7), # e.g. 0.06875 + rotation=0.0, + # id="link_sticker_default", + type="story_link", + extra=dict( + link_type="web", + url=str(link), # e.g. "https//github.com/" + tap_state_str_id="link_sticker_default", + ) + ) + stickers.append(link_sticker) + clips.append(link_clip) + # Mentions + mentions = [] + if mention: + mention.x = 0.49892962 # approximately center + mention.y = (text_clip_top + text_clip.size[1] / 2) / self.height + mention.width = text_clip.size[0] / self.width + mention.height = text_clip.size[1] / self.height + mentions = [mention] + duration = max_duration + if getattr(clip, 'duration', None): + if duration > int(clip.duration) or not duration: + duration = int(clip.duration) + destination = tempfile.mktemp(".mp4") + cvc = CompositeVideoClip(clips, size=(self.width, self.height))\ + .set_fps(24)\ + .set_duration(duration) + cvc.write_videofile(destination, codec="libx264", audio=True, audio_codec="aac") + paths = [] + if duration > 15: + for i in range(duration // 15 + (1 if duration % 15 else 0)): + path = tempfile.mktemp(".mp4") + start = i * 15 + rest = duration - start + end = start + (rest if rest < 15 else 15) + sub = cvc.subclip(start, end) + sub.write_videofile(path, codec="libx264", audio=True, audio_codec="aac") + paths.append(path) + return StoryBuild(mentions=mentions, path=destination, paths=paths, stickers=stickers) + + def video(self, max_duration: int = 0, font: str = 'Arial', fontsize: int = 100, color: str = 'white', link: str = ''): + """ + Build CompositeVideoClip from source video + + Parameters + ---------- + max_duration: int, optional + Duration of the clip if a video clip, default value is 0 + font: str, optional + Name of font for text clip + fontsize: int, optional + Size of font + color: str, optional + Color of text + + Returns + ------- + StoryBuild + An object of StoryBuild + """ + clip = VideoFileClip(str(self.path), has_mask=True) + build = self.build_main(clip, max_duration, font, fontsize, color, link) + clip.close() + return build + + def photo(self, max_duration: int = 0, font: str = 'Arial', fontsize: int = 100, color: str = 'white', link: str = ''): + """ + Build CompositeVideoClip from source video + + Parameters + ---------- + max_duration: int, optional + Duration of the clip if a video clip, default value is 0 + font: str, optional + Name of font for text clip + fontsize: int, optional + Size of font + color: str, optional + Color of text + + Returns + ------- + StoryBuild + An object of StoryBuild + """ + + with Image.open(self.path) as im: + image_width, image_height = im.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, font, fontsize, color, link) diff --git a/instagrapi/types.py b/instagrapi/types.py new file mode 100644 index 0000000..6a72af2 --- /dev/null +++ b/instagrapi/types.py @@ -0,0 +1,399 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, FilePath, HttpUrl, ValidationError, validator + + +def validate_external_url(cls, v): + if v is None or (v.startswith('http') and '://' in v) or isinstance(v, str): + return v + raise ValidationError('external_url must been URL or string') + + +class Resource(BaseModel): + pk: str + video_url: Optional[HttpUrl] # for Video and IGTV + thumbnail_url: HttpUrl + media_type: int + + +class User(BaseModel): + pk: str + username: str + full_name: str + is_private: bool + profile_pic_url: HttpUrl + profile_pic_url_hd: Optional[HttpUrl] + is_verified: bool + media_count: int + follower_count: int + following_count: int + biography: Optional[str] = "" + external_url: Optional[str] + is_business: bool + + public_email: Optional[str] + contact_phone_number: Optional[str] + business_contact_method: Optional[str] + business_category_name: Optional[str] + category_name: Optional[str] + + _external_url = validator('external_url', allow_reuse=True)(validate_external_url) + + +class Account(BaseModel): + pk: str + username: str + full_name: str + is_private: bool + profile_pic_url: HttpUrl + is_verified: bool + biography: Optional[str] = "" + external_url: Optional[str] + is_business: bool + birthday: Optional[str] + phone_number: Optional[str] + gender: Optional[int] + email: Optional[str] + + _external_url = validator('external_url', allow_reuse=True)(validate_external_url) + + +class UserShort(BaseModel): + pk: str + username: Optional[str] + full_name: Optional[str] = "" + profile_pic_url: Optional[HttpUrl] + profile_pic_url_hd: Optional[HttpUrl] + is_private: Optional[bool] + # is_verified: bool # not found in hashtag_medias_v1 + stories: List = [] + + +class Usertag(BaseModel): + user: UserShort + x: float + y: float + + +class Location(BaseModel): + pk: Optional[int] + name: str + phone: Optional[str] = "" + website: Optional[str] = "" + category: Optional[str] = "" + hours: Optional[dict] = {} # opening hours + address: Optional[str] = "" + city: Optional[str] = "" + zip: Optional[str] = "" + lng: Optional[float] + lat: Optional[float] + external_id: Optional[int] + external_id_source: Optional[str] + # address_json: Optional[dict] = {} + # profile_pic_url: Optional[HttpUrl] + # directory: Optional[dict] = {} + + +class Media(BaseModel): + pk: str + id: str + code: str + taken_at: datetime + media_type: int + product_type: Optional[str] = "" # igtv or feed + thumbnail_url: Optional[HttpUrl] + location: Optional[Location] = None + user: UserShort + comment_count: Optional[int] = 0 + like_count: int + has_liked: Optional[bool] + caption_text: str + accessibility_caption: Optional[str] + usertags: List[Usertag] + 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] = "" + resources: List[Resource] = [] + clips_metadata: dict = {} + + +class MediaOembed(BaseModel): + title: str + author_name: str + author_url: str + author_id: str + media_id: str + provider_name: str + provider_url: HttpUrl + type: str + width: Optional[int] = None + height: Optional[int] = None + html: str + thumbnail_url: HttpUrl + thumbnail_width: int + thumbnail_height: int + can_view: bool + + +class Collection(BaseModel): + id: str + name: str + type: str + media_count: int + + +class Comment(BaseModel): + pk: str + text: str + user: UserShort + created_at_utc: datetime + content_type: str + status: str + has_liked: Optional[bool] + like_count: Optional[int] + + +class Hashtag(BaseModel): + id: str + name: str + media_count: Optional[int] + profile_pic_url: Optional[HttpUrl] + + +class StoryMention(BaseModel): + user: UserShort + x: Optional[float] + y: Optional[float] + width: Optional[float] + height: Optional[float] + + +class StoryMedia(BaseModel): + # Instagram does not return the feed_media object when requesting story, + # so you will have to make an additional request to get media and this is overhead: + # media: Media + x: float = 0.5 + y: float = 0.4997396 + z: float = 0 + width: float = 0.8 + height: float = 0.60572916 + rotation: float = 0.0 + is_pinned: Optional[bool] + is_hidden: Optional[bool] + is_sticker: Optional[bool] + is_fb_sticker: Optional[bool] + media_pk: int + user_id: Optional[int] + product_type: Optional[str] + media_code: Optional[str] + + +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: Optional[str] + type: Optional[str] = 'gif' + x: float + y: float + z: Optional[int] = 1000005 + width: float + height: float + rotation: Optional[float] = 0.0 + extra: Optional[dict] = {} + + +class StoryBuild(BaseModel): + mentions: List[StoryMention] + path: FilePath + paths: List[FilePath] = [] + stickers: List[StorySticker] = [] + + +class StoryLink(BaseModel): + webUri: HttpUrl + x: float = 0.5126011 + y: float = 0.5168225 + z: float = 0.0 + width: float = 0.50998676 + height: float = 0.25875 + rotation: float = 0.0 + + +class Story(BaseModel): + pk: str + 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] + medias: List[StoryMedia] = [] + + +class DirectMedia(BaseModel): + id: str + media_type: int + user: Optional[UserShort] + thumbnail_url: Optional[HttpUrl] + video_url: Optional[HttpUrl] + + +class DirectMessage(BaseModel): + id: str # e.g. 28597946203914980615241927545176064 + user_id: Optional[int] + thread_id: Optional[int] # e.g. 340282366841710300949128531777654287254 + timestamp: datetime + item_type: Optional[str] + is_shh_mode: Optional[bool] + reactions: Optional[dict] + text: Optional[str] + link: Optional[dict] + media: Optional[DirectMedia] + media_share: Optional[Media] + reel_share: Optional[dict] + story_share: Optional[dict] + felix_share: Optional[dict] + clip: Optional[Media] + placeholder: Optional[dict] + + +class DirectResponse(BaseModel): + unseen_count: Optional[int] + unseen_count_ts: Optional[int] + status: Optional[str] + + +class DirectShortThread(BaseModel): + id: str + users: List[UserShort] + named: bool + thread_title: str + pending: bool + thread_type: str + viewer_id: str + is_group: bool + + +class DirectThread(BaseModel): + pk: str # thread_v2_id, e.g. 17898572618026348 + id: str # thread_id, e.g. 340282366841510300949128268610842297468 + messages: List[DirectMessage] + users: List[UserShort] + inviter: Optional[UserShort] + left_users: List[UserShort] = [] + admin_user_ids: list + last_activity_at: datetime + muted: bool + is_pin: Optional[bool] + named: bool + canonical: bool + pending: bool + archived: bool + thread_type: str + thread_title: str + folder: int + vc_muted: bool + is_group: bool + mentions_muted: bool + approval_required_for_new_members: bool + input_mode: int + business_thread_folder: int + read_state: int + is_close_friend_thread: bool + assigned_admin_id: int + shh_mode_enabled: bool + last_seen_at: dict + + def is_seen(self, user_id: str): + """Have I seen this thread? + :param user_id: You account user_id + """ + user_id = str(user_id) + own_timestamp = int(self.last_seen_at[user_id]["timestamp"]) + timestamps = [ + (int(v["timestamp"]) - own_timestamp) > 0 + for k, v in self.last_seen_at.items() + if k != user_id + ] + return not any(timestamps) + + +class Relationship(BaseModel): + blocking: bool + followed_by: bool + following: bool + incoming_request: bool + is_bestie: bool + is_blocking_reel: bool + is_muting_reel: bool + is_private: bool + is_restricted: bool + muting: bool + outgoing_request: bool + status: str + + +class Highlight(BaseModel): + pk: str # 17895485401104052 + id: str # highlight:17895485401104052 + latest_reel_media: int + cover_media: dict + user: UserShort + title: str + created_at: datetime + is_pinned_highlight: bool + media_count: int + media_ids: List[int] = [] + items: List[Story] = [] + + +class Share(BaseModel): + pk: str + type: str + + +class Track(BaseModel): + id: str + title: str + subtitle: str + display_artist: str + audio_cluster_id: int + artist_id: Optional[int] + cover_artwork_uri: HttpUrl + cover_artwork_thumbnail_uri: HttpUrl + progressive_download_url: HttpUrl + fast_start_progressive_download_url: HttpUrl + reactive_audio_download_url: Optional[HttpUrl] + highlight_start_times_in_ms: List[int] + is_explicit: bool + dash_manifest: str + has_lyrics: bool + audio_asset_id: int + duration_in_ms: int + dark_message: Optional[str] + allows_saving: bool + territory_validity_periods: dict diff --git a/instagrapi/utils.py b/instagrapi/utils.py new file mode 100644 index 0000000..740e745 --- /dev/null +++ b/instagrapi/utils.py @@ -0,0 +1,109 @@ +import datetime +import enum +import json +import random +import string +import time +import urllib + + +class InstagramIdCodec: + ENCODING_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + + @staticmethod + def encode(num, alphabet=ENCODING_CHARS): + """Covert a numeric value to a shortcode.""" + num = int(num) + if num == 0: + return alphabet[0] + arr = [] + base = len(alphabet) + while num: + rem = num % base + num //= base + arr.append(alphabet[rem]) + arr.reverse() + return "".join(arr) + + @staticmethod + def decode(shortcode, alphabet=ENCODING_CHARS): + """Covert a shortcode to a numeric value.""" + base = len(alphabet) + strlen = len(shortcode) + num = 0 + idx = 0 + for char in shortcode: + power = strlen - (idx + 1) + num += alphabet.index(char) * (base ** power) + idx += 1 + return num + + +class InstagrapiJSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, enum.Enum): + return obj.value + elif isinstance(obj, datetime.time): + return obj.strftime("%H:%M") + elif isinstance(obj, (datetime.datetime, datetime.date)): + return int(obj.strftime("%s")) + elif isinstance(obj, set): + return list(obj) + return json.JSONEncoder.default(self, obj) + + +def generate_signature(data): + """Generate signature of POST data for Private API + + Returns + ------- + str + e.g. "signed_body=SIGNATURE.test" + """ + return "signed_body=SIGNATURE.{data}".format( + data=urllib.parse.quote_plus(data) + ) + + +def json_value(data, *args, default=None): + cur = data + for a in args: + try: + if isinstance(a, int): + cur = cur[a] + else: + cur = cur.get(a) + except (IndexError, KeyError, TypeError, AttributeError): + return default + return cur + + +def gen_token(size=10, symbols=False): + """Gen CSRF or something else token + """ + chars = string.ascii_letters + string.digits + if symbols: + chars += string.punctuation + return "".join(random.choice(chars) for _ in range(size)) + + +def gen_password(size=10): + """Gen password + """ + return gen_token(size) + + +def dumps(data): + """Json dumps format as required Instagram + """ + return InstagrapiJSONEncoder(separators=(",", ":")).encode(data) + + +def generate_jazoest(symbols: str) -> str: + amount = sum(ord(s) for s in symbols) + return f'2{amount}' + + +def date_time_original(localtime): + # return time.strftime("%Y:%m:%d+%H:%M:%S", localtime) + return time.strftime("%Y%m%dT%H%M%S.000Z", localtime) diff --git a/instagrapi/zones.py b/instagrapi/zones.py new file mode 100644 index 0000000..2e9c7b3 --- /dev/null +++ b/instagrapi/zones.py @@ -0,0 +1,17 @@ +from datetime import timedelta, tzinfo + + +class CET(tzinfo): + def utcoffset(self, dt): + return timedelta(hours=1) + + def dst(self, dt): + return timedelta(hours=2) + + +class UTC(tzinfo): + def utcoffset(self, dt): + return timedelta(0) + + def dst(self, dt): + return timedelta(0) diff --git a/requirements.txt b/requirements.txt index 352368c..6afe335 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,21 @@ -InstagramAPI -urllib3 +# THIS IS AN AUTOGENERATED LOCKFILE. DO NOT EDIT MANUALLY. +certifi==2020.12.5 +chardet==4.0.0 +decorator==4.4.2 +idna==2.10 +imageio==2.9.0 +imageio-ffmpeg==0.4.3 +moviepy==1.0.3 +numpy==1.20.2 +Pillow==8.2.0 +pip==21.0.1 +proglog==0.1.9 +pycryptodomex==3.9.9 +pydantic==1.8.1 +PySocks==1.7.1 +requests==2.25.1 +setuptools==53.0.0 +tqdm==4.60.0 +typing-extensions==3.7.4.3 +urllib3==1.26.4 +wheel==0.36.2