diff --git a/config-examples-extra/interact.json b/config-examples-extra/interact.json index 058985e..77609a5 100644 --- a/config-examples-extra/interact.json +++ b/config-examples-extra/interact.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -95,16 +119,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "interact_targets", @@ -168,8 +192,14 @@ { "parameter-name": "total_interactions_limit", "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples-extra/interact_targets.json b/config-examples-extra/interact_targets.json index 70d9495..84d1d4e 100644 --- a/config-examples-extra/interact_targets.json +++ b/config-examples-extra/interact_targets.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -95,16 +119,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "interact_targets", @@ -168,8 +192,14 @@ { "parameter-name": "total_interactions_limit", "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples-extra/interact_with_filters.json b/config-examples-extra/interact_with_filters.json index a325397..e766970 100644 --- a/config-examples-extra/interact_with_filters.json +++ b/config-examples-extra/interact_with_filters.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -95,16 +119,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "interact_targets", @@ -168,8 +192,14 @@ { "parameter-name": "total_interactions_limit", "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples-extra/scrape.json b/config-examples-extra/scrape.json index 6ced4e2..fdce1e9 100644 --- a/config-examples-extra/scrape.json +++ b/config-examples-extra/scrape.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -95,16 +119,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "interact_targets", @@ -167,9 +191,15 @@ }, { "parameter-name": "total_interactions_limit", - "enabled": false, + "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples-extra/unfollow.json b/config-examples-extra/unfollow.json index afe076b..19617e4 100644 --- a/config-examples-extra/unfollow.json +++ b/config-examples-extra/unfollow.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -95,16 +119,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "interact_targets", @@ -167,9 +191,15 @@ }, { "parameter-name": "total_interactions_limit", - "enabled": false, + "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples/interact.json b/config-examples/interact.json index 5d981ff..3d9f475 100644 --- a/config-examples/interact.json +++ b/config-examples/interact.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -89,16 +113,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "total_likes_limit", @@ -109,8 +133,14 @@ { "parameter-name": "total_interactions_limit", "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples/interact_targets.json b/config-examples/interact_targets.json index 80838b9..26ab976 100644 --- a/config-examples/interact_targets.json +++ b/config-examples/interact_targets.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -89,16 +113,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "total_likes_limit", @@ -109,8 +133,14 @@ { "parameter-name": "total_interactions_limit", "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/config-examples/unfollow.json b/config-examples/unfollow.json index 255df56..735e05d 100644 --- a/config-examples/unfollow.json +++ b/config-examples/unfollow.json @@ -5,6 +5,30 @@ "value": "emulator-5554", "description" : "device identifier. Should be used only when multiple devices are connected at once" }, + { + "parameter-name": "app_id", + "enabled": false, + "value": "com.instagram.android", + "description" : "apk package identifier. Should be used only if you are using cloned-app. Using 'com.instagram.android' by default" + }, + { + "parameter-name": "wait_for_device", + "enabled": true, + "value": "True", + "description" : "keep waiting for ADB-device to be ready for connection (if no device-id is provided using --device flag, will wait for any available device)" + }, + { + "parameter-name": "dont_indicate_softban", + "enabled": false, + "value": "True", + "description" : "by default, Insomniac tried to indicate if there is a softban on your account. set this flag in order to ignore those soft-ban indicators" + }, + { + "parameter-name": "debug", + "enabled": false, + "value": "True", + "description" : "add this flag to insomniac in debug mode (more verbose logs)" + }, { "parameter-name": "old", "enabled": false, @@ -83,16 +107,16 @@ "description" : "maximum amount of followings, after reaching this amount follow stops. disabled by default" }, { - "parameter-name": "unfollow_non_followers", + "parameter-name": "unfollow_followed_by_anyone", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users, that don\\'t follow you back. Only users followed by this script will be unfollowed. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "By default, only profiles that been followed by the bot will be unfollowed. Set this parameter if you want to unfollow any profile (even if not been followed by the bot)" }, { - "parameter-name": "unfollow_any", + "parameter-name": "unfollow_non_followers", "enabled": false, - "value": "20-40", - "description" : "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 100) or a range (e.g. 100-200)" + "value": "True", + "description" : "unfollow only profiles that are not following you" }, { "parameter-name": "total_likes_limit", @@ -102,9 +126,15 @@ }, { "parameter-name": "total_interactions_limit", - "enabled": false, + "enabled": true, + "value": "100-120", + "description" : "number of total interactions (successful & unsuccessful) per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" + }, + { + "parameter-name": "total_successful_interactions_limit", + "enabled": true, "value": "50-60", - "description" : "number of total interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count" + "description" : "number of total successful interactions per session, disabled by default. It can be a number (e.g. 70) or a range (e.g. 60-80)" }, { "parameter-name": "total_follow_limit", diff --git a/insomniac/__version__.py b/insomniac/__version__.py index 77a40d3..e8e5811 100644 --- a/insomniac/__version__.py +++ b/insomniac/__version__.py @@ -13,7 +13,7 @@ __title__ = 'insomniac' __description__ = 'Simple Instagram bot for automated Instagram interaction using Android.' __url__ = 'https://github.com/alexal1/Insomniac/' -__version__ = '3.4.2' +__version__ = '3.5.0' __debug_mode__ = False __author__ = 'Insomniac Team' __author_email__ = 'info@insomniac-bot.com' diff --git a/insomniac/action_get_my_profile_info.py b/insomniac/action_get_my_profile_info.py index 0da48de..352e222 100644 --- a/insomniac/action_get_my_profile_info.py +++ b/insomniac/action_get_my_profile_info.py @@ -1,48 +1,37 @@ -from insomniac.actions_impl import update_interaction_rect -from insomniac.counters_parser import parse -from insomniac.device_facade import DeviceFacade -from insomniac.navigation import navigate, Tabs, LanguageChangedException +from insomniac.navigation import switch_to_english from insomniac.sleeper import sleeper from insomniac.utils import * - -TITLE_VIEW_ID_REGEX = 'com.instagram.android:id/title_view|com.instagram.android:id/action_bar_large_title' +from insomniac.views import TabBarView, ActionBarView def get_my_profile_info(device): - navigate(device, Tabs.PROFILE) - sleeper.random_sleep() - - print("Refreshing your profile status...") - coordinator_layout = device.find(resourceId='com.instagram.android:id/coordinator_root_layout') - if coordinator_layout.exists(): - coordinator_layout.scroll(DeviceFacade.Direction.TOP) + try: + profile_view = TabBarView(device).navigate_to_profile() + sleeper.random_sleep() - sleeper.random_sleep() + print("Refreshing your profile status...") + profile_view.refresh() + sleeper.random_sleep() - update_interaction_rect(device) + ActionBarView.update_interaction_rect(device) - username = None - title_view = device.find(resourceIdMatches=TITLE_VIEW_ID_REGEX, - className='android.widget.TextView') - if title_view.exists(): - username = title_view.get_text() - else: - print(COLOR_FAIL + "Failed to get username" + COLOR_ENDC) - save_crash(device) + username, followers, following = profile_view.get_profile_info() + except Exception as e: + print(COLOR_FAIL + f"Exception: {e}" + COLOR_ENDC) + save_crash(device, e) + switch_to_english(device) - try: - followers = _get_followers_count(device) - except LanguageChangedException: # Try again on the correct language - navigate(device, Tabs.PROFILE) - followers = _get_followers_count(device) + profile_view = TabBarView(device).navigate_to_profile() + sleeper.random_sleep() - try: - following = get_following_count(device) - except LanguageChangedException: - # Try again on the correct language - navigate(device, Tabs.PROFILE) - following = get_following_count(device) + print("Refreshing your profile status...") + profile_view.refresh() + sleeper.random_sleep() + + ActionBarView.update_interaction_rect(device) + + username, followers, following = profile_view.get_profile_info() report_string = "" if username: @@ -57,35 +46,3 @@ def get_my_profile_info(device): print(report_string) return username, followers, following - - -def _get_followers_count(device): - followers = None - followers_text_view = device.find(resourceId='com.instagram.android:id/row_profile_header_textview_followers_count', - className='android.widget.TextView') - if followers_text_view.exists(): - followers_text = followers_text_view.get_text() - if followers_text: - followers = parse(device, followers_text) - else: - print(COLOR_FAIL + "Cannot get your followers count text" + COLOR_ENDC) - else: - print(COLOR_FAIL + "Cannot find your followers count view" + COLOR_ENDC) - - return followers - - -def get_following_count(device): - following = None - following_text_view = device.find(resourceId='com.instagram.android:id/row_profile_header_textview_following_count', - className='android.widget.TextView') - if following_text_view.exists(): - following_text = following_text_view.get_text() - if following_text: - following = parse(device, following_text) - else: - print(COLOR_FAIL + "Cannot get your following count text" + COLOR_ENDC) - else: - print(COLOR_FAIL + "Cannot find your following count view" + COLOR_ENDC) - - return following diff --git a/insomniac/action_runners/__init__.py b/insomniac/action_runners/__init__.py new file mode 100644 index 0000000..c4d66e8 --- /dev/null +++ b/insomniac/action_runners/__init__.py @@ -0,0 +1,7 @@ +from insomniac.action_runners.core import ActionsRunner, CoreActionsRunner, ActionStatus, ActionState +from insomniac.action_runners.interact import InteractBySourceActionRunner, InteractByTargetsActionRunner +from insomniac.action_runners.unfollow import UnfollowActionRunner + + +def get_core_action_runners_classes(): + return CoreActionsRunner.__subclasses__() diff --git a/insomniac/action_runners/actions_runners_manager.py b/insomniac/action_runners/actions_runners_manager.py new file mode 100644 index 0000000..d0f54b5 --- /dev/null +++ b/insomniac/action_runners/actions_runners_manager.py @@ -0,0 +1,41 @@ +from insomniac.action_runners import * +from insomniac.utils import * + + +class ActionRunnersManager(object): + action_runners = {} + + def __init__(self): + for clazz in get_core_action_runners_classes(): + instance = clazz() + self.action_runners[instance.ACTION_ID] = instance + + def get_actions_args(self): + actions_args = {} + + for key, action_runner in self.action_runners.items(): + for arg, info in action_runner.ACTION_ARGS.items(): + actions_args.update({arg: info}) + + return actions_args + + def select_action_runner(self, args): + selected_action_runners = [] + + for action_runner in self.action_runners.values(): + if action_runner.is_action_selected(args): + selected_action_runners.append(action_runner) + + if len(selected_action_runners) == 0: + print_timeless(COLOR_FAIL + "You have to specify one of the actions: --interact, --unfollow" + COLOR_ENDC) + return None + + if len(selected_action_runners) > 1: + print_timeless(COLOR_FAIL + "Running Insomniac with two or more actions is not supported yet." + COLOR_ENDC) + return None + + print(COLOR_BOLD + + "Running Insomniac with the \"{0}\" action.".format(selected_action_runners[0].ACTION_ID) + + COLOR_ENDC) + + return selected_action_runners[0] diff --git a/insomniac/action_runners/core.py b/insomniac/action_runners/core.py new file mode 100644 index 0000000..83f261e --- /dev/null +++ b/insomniac/action_runners/core.py @@ -0,0 +1,51 @@ +from abc import ABC +from enum import unique, Enum + + +@unique +class ActionState(Enum): + PRE_RUN = 0 + RUNNING = 1 + DONE = 2 + SOURCE_LIMIT_REACHED = 3 + SESSION_LIMIT_REACHED = 4 + + +class ActionStatus(object): + def __init__(self, state): + self.state = state + self.limit_state = None + + def set(self, state): + self.state = state + + def get(self): + return self.state + + def set_limit(self, limit_state): + self.limit_state = limit_state + + def get_limit(self): + return self.limit_state + + +class ActionsRunner(object): + """An interface for actions-runner object""" + + ACTION_ID = "OVERRIDE" + ACTION_ARGS = {"OVERRIDE": "OVERRIDE"} + + action_status = None + + def is_action_selected(self, args): + raise NotImplementedError() + + def set_params(self, args): + raise NotImplementedError() + + def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): + raise NotImplementedError() + + +class CoreActionsRunner(ActionsRunner, ABC): + """An interface for extra-actions-runner object""" diff --git a/insomniac/actions_runners.py b/insomniac/action_runners/interact/__init__.py similarity index 55% rename from insomniac/actions_runners.py rename to insomniac/action_runners/interact/__init__.py index e832819..0fb762f 100644 --- a/insomniac/actions_runners.py +++ b/insomniac/action_runners/interact/__init__.py @@ -1,100 +1,10 @@ import random -from abc import ABC -from enum import unique, Enum +from insomniac.action_runners import * from insomniac.safely_runner import run_safely from insomniac.utils import * -class ActionRunnersManager(object): - action_runners = {} - - def __init__(self): - for clazz in get_core_action_runners_classes(): - instance = clazz() - self.action_runners[instance.ACTION_ID] = instance - - def get_actions_args(self): - actions_args = {} - - for key, action_runner in self.action_runners.items(): - for arg, info in action_runner.ACTION_ARGS.items(): - actions_args.update({arg: info}) - - return actions_args - - def select_action_runner(self, args): - selected_action_runners = [] - - for action_runner in self.action_runners.values(): - if action_runner.is_action_selected(args): - selected_action_runners.append(action_runner) - - if len(selected_action_runners) == 0: - print_timeless(COLOR_FAIL + "You have to specify one of the actions: --interact, --unfollow, " - "--unfollow-non-followers, --unfollow-any, --remove-mass-followers" + COLOR_ENDC) - return None - - if len(selected_action_runners) > 1: - print_timeless(COLOR_FAIL + "Running Insomniac with two or more actions is not supported yet." + COLOR_ENDC) - return None - - print(COLOR_BOLD + - "Running Insomniac with the \"{0}\" action.".format(selected_action_runners[0].ACTION_ID) + - COLOR_ENDC) - - return selected_action_runners[0] - - -@unique -class ActionState(Enum): - PRE_RUN = 0 - RUNNING = 1 - DONE = 2 - SOURCE_LIMIT_REACHED = 3 - SESSION_LIMIT_REACHED = 4 - - -class ActionStatus(object): - def __init__(self, state): - self.state = state - self.limit_state = None - - def set(self, state): - self.state = state - - def get(self): - return self.state - - def set_limit(self, limit_state): - self.limit_state = limit_state - - def get_limit(self): - return self.limit_state - - -class ActionsRunner(object): - """An interface for actions-runner object""" - - ACTION_ID = "OVERRIDE" - ACTION_ARGS = {"OVERRIDE": "OVERRIDE"} - - action_status = None - - def is_action_selected(self, args): - raise NotImplementedError() - - def set_params(self, args): - raise NotImplementedError() - - def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - raise NotImplementedError() - - -class CoreActionsRunner(ActionsRunner, ABC): - """An interface for extra-actions-runner object""" - - class InteractBySourceActionRunner(CoreActionsRunner): ACTION_ID = "interact" ACTION_ARGS = { @@ -173,8 +83,8 @@ def set_params(self, args): self.interact.remove(random.choice(self.interact)) def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - from insomniac.action_handle_blogger import handle_blogger, extract_blogger_instructions - from insomniac.action_handle_hashtag import handle_hashtag, extract_hashtag_instructions + from insomniac.action_runners.interact.action_handle_blogger import handle_blogger, extract_blogger_instructions + from insomniac.action_runners.interact.action_handle_hashtag import handle_hashtag, extract_hashtag_instructions random.shuffle(self.interact) @@ -238,122 +148,6 @@ def job(): break -class UnfollowActionRunner(CoreActionsRunner): - ACTION_ID = "unfollow" - ACTION_ARGS = { - "unfollow": { - "help": 'unfollow at most given number of users. Only users followed by this script will ' - 'be unfollowed. The order is from oldest to newest followings. ' - 'It can be a number (e.g. 100) or a range (e.g. 100-200)', - "metavar": '100-200' - } - } - - def is_action_selected(self, args): - return args.unfollow is not None - - def set_params(self, args): - pass - - def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - from insomniac.action_unfollow import unfollow, UnfollowRestriction - - self.action_status = ActionStatus(ActionState.PRE_RUN) - - @run_safely(device_wrapper=device_wrapper) - def job(): - self.action_status.set(ActionState.RUNNING) - unfollow(device=device_wrapper.get(), - on_action=on_action, - storage=storage, - unfollow_restriction=UnfollowRestriction.FOLLOWED_BY_SCRIPT, - session_state=session_state, - is_limit_reached=is_limit_reached, - action_status=self.action_status) - print("Unfollowed " + str(session_state.totalUnfollowed) + ", finish.") - self.action_status.set(ActionState.DONE) - - while not self.action_status.get() == ActionState.DONE: - job() - - -class UnfollowNonFollowersActionRunner(CoreActionsRunner): - ACTION_ID = "unfollow_non_followers" - ACTION_ARGS = { - "unfollow_non_followers": { - "help": 'unfollow at most given number of users, that don\'t follow you back. Only users followed ' - 'by this script will be unfollowed. The order is from oldest to newest followings. ' - 'It can be a number (e.g. 100) or a range (e.g. 100-200)', - "metavar": '100-200' - } - } - - def is_action_selected(self, args): - return args.unfollow_non_followers is not None - - def set_params(self, args): - pass - - def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - from insomniac.action_unfollow import unfollow, UnfollowRestriction - - self.action_status = ActionStatus(ActionState.PRE_RUN) - - @run_safely(device_wrapper=device_wrapper) - def job(): - self.action_status.set(ActionState.RUNNING) - unfollow(device=device_wrapper.get(), - on_action=on_action, - storage=storage, - unfollow_restriction=UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS, - session_state=session_state, - is_limit_reached=is_limit_reached, - action_status=self.action_status) - print("Unfollowed " + str(session_state.totalUnfollowed) + " non followers, finish.") - self.action_status.set(ActionState.DONE) - - while not self.action_status.get() == ActionState.DONE: - job() - - -class UnfollowAnyActionRunner(CoreActionsRunner): - ACTION_ID = "unfollow_any" - ACTION_ARGS = { - "unfollow_any": { - "help": 'unfollow at most given number of users. The order is from oldest to newest followings. ' - 'It can be a number (e.g. 100) or a range (e.g. 100-200)', - "metavar": '100-200' - } - } - - def is_action_selected(self, args): - return args.unfollow_any is not None - - def set_params(self, args): - pass - - def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - from insomniac.action_unfollow import unfollow, UnfollowRestriction - - self.action_status = ActionStatus(ActionState.PRE_RUN) - - @run_safely(device_wrapper=device_wrapper) - def job(): - self.action_status.set(ActionState.RUNNING) - unfollow(device=device_wrapper.get(), - on_action=on_action, - storage=storage, - unfollow_restriction=UnfollowRestriction.ANY, - session_state=session_state, - is_limit_reached=is_limit_reached, - action_status=self.action_status) - print("Unfollowed any " + str(session_state.totalUnfollowed) + ", finish.") - self.action_status.set(ActionState.DONE) - - while not self.action_status.get() == ActionState.DONE: - job() - - class InteractByTargetsActionRunner(CoreActionsRunner): ACTION_ID = "interact_targets" ACTION_ARGS = { @@ -406,9 +200,10 @@ def set_params(self, args): self.like_percentage = int(args.like_percentage) def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): - from insomniac.action_handle_target import handle_target + from insomniac.action_runners.interact.action_handle_target import handle_target - for target in storage.targets: + target = storage.get_target() + while target is not None: self.action_status = ActionStatus(ActionState.PRE_RUN) print_timeless("") @@ -416,7 +211,6 @@ def run(self, device_wrapper, storage, session_state, on_action, is_limit_reache @run_safely(device_wrapper=device_wrapper) def job(): - storage.read_targets() self.action_status.set(ActionState.RUNNING) handle_target(device_wrapper.get(), target, @@ -445,6 +239,4 @@ def job(): if self.action_status.get_limit() == ActionState.SESSION_LIMIT_REACHED: break - -def get_core_action_runners_classes(): - return CoreActionsRunner.__subclasses__() + target = storage.get_target() diff --git a/insomniac/action_handle_blogger.py b/insomniac/action_runners/interact/action_handle_blogger.py similarity index 75% rename from insomniac/action_handle_blogger.py rename to insomniac/action_runners/interact/action_handle_blogger.py index ea2174f..fe07c33 100644 --- a/insomniac/action_handle_blogger.py +++ b/insomniac/action_runners/interact/action_handle_blogger.py @@ -1,15 +1,17 @@ from functools import partial -from insomniac.actions_impl import interact_with_user, open_user_followers, \ - scroll_to_bottom, iterate_over_followers, InteractionStrategy, is_private_account, do_have_story, \ - open_user_followings -from insomniac.actions_runners import ActionState +from insomniac.action_runners.actions_runners_manager import ActionState +from insomniac.actions_impl import interact_with_user, InteractionStrategy +from insomniac.actions_providers import Provider from insomniac.actions_types import LikeAction, FollowAction, InteractAction, GetProfileAction, StoryWatchAction, \ BloggerInteractionType from insomniac.limits import process_limits from insomniac.report import print_short_report, print_interaction_types +from insomniac.sleeper import sleeper +from insomniac.softban_indicator import softban_indicator from insomniac.storage import FollowingStatus from insomniac.utils import * +from insomniac.views import TabBarView, ProfileView def extract_blogger_instructions(source): @@ -55,15 +57,27 @@ def handle_blogger(device, my_username=session_state.my_username, on_action=on_action) + search_view = TabBarView(device).navigate_to_search() + blogger_profile_view = search_view.navigate_to_username(username, on_action) + + if blogger_profile_view is None: + return + + sleeper.random_sleep() + is_profile_empty = softban_indicator.detect_empty_profile(device) + + if is_profile_empty: + return + + followers_following_list_view = None if instructions == BloggerInteractionType.FOLLOWERS: - if not open_user_followers(device=device, username=username, on_action=on_action): - return + followers_following_list_view = blogger_profile_view.navigate_to_followers() elif instructions == BloggerInteractionType.FOLLOWING: - if not open_user_followings(device=device, username=username, on_action=on_action): - return + followers_following_list_view = blogger_profile_view.navigate_to_following() if is_myself: - scroll_to_bottom(device) + followers_following_list_view.scroll_to_bottom() + followers_following_list_view.scroll_to_top() def pre_conditions(follower_name, follower_name_view): if storage.is_user_in_blacklist(follower_name): @@ -79,7 +93,7 @@ def pre_conditions(follower_name, follower_name_view): print("@" + follower_name + ": already interacted in the last week. Skip.") return False elif is_passed_filters is not None: - if not is_passed_filters(device, follower_name, ['BEFORE_PROFILE_CLICK']): + if not is_passed_filters(device, follower_name, reset=True, filters_tags=['BEFORE_PROFILE_CLICK']): storage.add_filtered_user(follower_name) return False @@ -104,8 +118,18 @@ def interact_with_follower(follower_name, follower_name_view): follower_name_view.click() on_action(GetProfileAction(user=follower_name)) + sleeper.random_sleep() + is_profile_empty = softban_indicator.detect_empty_profile(device) + + if is_profile_empty: + print("Back to followers list") + device.back() + return True + + follower_profile_view = ProfileView(device, follower_name == session_state.my_username) + if is_passed_filters is not None: - if not is_passed_filters(device, follower_name): + if not is_passed_filters(device, follower_name, reset=False): storage.add_filtered_user(follower_name) # Continue to next follower print("Back to profiles list") @@ -121,11 +145,11 @@ def interact_with_follower(follower_name, follower_name_view): is_watch_limit_reached, watch_reached_source_limit, watch_reached_session_limit = \ is_limit_reached(StoryWatchAction(user=follower_name), session_state) - is_private = is_private_account(device) + is_private = follower_profile_view.is_private_account() if is_private: print("@" + follower_name + ": Private account - images wont be liked.") - do_have_stories = do_have_story(device) + do_have_stories = follower_profile_view.is_story_available() if not do_have_stories: print("@" + follower_name + ": seems there are no stories to be watched.") @@ -143,7 +167,11 @@ def interact_with_follower(follower_name, follower_name_view): if not can_interact: print("@" + follower_name + ": Cant be interacted (due to limits / already followed). Skip.") - storage.add_interacted_user(follower_name, followed=False) + storage.add_interacted_user(follower_name, + followed=False, + source=f"@{username}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=username, user=follower_name, succeed=False)) else: print_interaction_types(follower_name, can_like, can_follow, can_watch) @@ -157,11 +185,19 @@ def interact_with_follower(follower_name, follower_name_view): is_liked, is_followed, is_watch = interaction(username=follower_name, interaction_strategy=interaction_strategy) if is_liked or is_followed or is_watch: - storage.add_interacted_user(follower_name, followed=is_followed) + storage.add_interacted_user(follower_name, + followed=is_followed, + source=f"@{username}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=username, user=follower_name, succeed=True)) print_short_report(username, session_state) else: - storage.add_interacted_user(follower_name, followed=False) + storage.add_interacted_user(follower_name, + followed=False, + source=f"@{username}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=username, user=follower_name, succeed=False)) can_continue = True @@ -186,4 +222,4 @@ def interact_with_follower(follower_name, follower_name_view): return can_continue - iterate_over_followers(device, is_myself, interact_with_follower, pre_conditions) + followers_following_list_view.iterate_over_followers(is_myself, interact_with_follower, pre_conditions) diff --git a/insomniac/action_handle_hashtag.py b/insomniac/action_runners/interact/action_handle_hashtag.py similarity index 85% rename from insomniac/action_handle_hashtag.py rename to insomniac/action_runners/interact/action_handle_hashtag.py index 39acb55..8fba183 100644 --- a/insomniac/action_handle_hashtag.py +++ b/insomniac/action_runners/interact/action_handle_hashtag.py @@ -1,8 +1,9 @@ from functools import partial +from insomniac.action_runners.actions_runners_manager import ActionState from insomniac.actions_impl import interact_with_user, ScrollEndDetector, open_likers, iterate_over_likers, \ is_private_account, InteractionStrategy, do_have_story -from insomniac.actions_runners import ActionState +from insomniac.actions_providers import Provider from insomniac.actions_types import InteractAction, LikeAction, FollowAction, GetProfileAction, StoryWatchAction, \ HashtagInteractionType from insomniac.device_facade import DeviceFacade @@ -10,6 +11,7 @@ from insomniac.navigation import search_for from insomniac.report import print_short_report, print_interaction_types from insomniac.sleeper import sleeper +from insomniac.softban_indicator import softban_indicator from insomniac.storage import FollowingStatus from insomniac.utils import * @@ -68,7 +70,7 @@ def pre_conditions(liker_username, liker_username_view): print("@" + liker_username + ": already interacted. Skip.") return False elif is_passed_filters is not None: - if not is_passed_filters(device, liker_username, ['BEFORE_PROFILE_CLICK']): + if not is_passed_filters(device, liker_username, reset=True, filters_tags=['BEFORE_PROFILE_CLICK']): storage.add_filtered_user(liker_username) return False @@ -93,8 +95,16 @@ def interact_with_profile(liker_username, liker_username_view): liker_username_view.click() on_action(GetProfileAction(user=liker_username)) + sleeper.random_sleep() + is_profile_empty = softban_indicator.detect_empty_profile(device) + + if is_profile_empty: + print("Back to followers list") + device.back() + return True + if is_passed_filters is not None: - if not is_passed_filters(device, liker_username): + if not is_passed_filters(device, liker_username, reset=False): storage.add_filtered_user(liker_username) # Continue to next follower print("Back to followers list") @@ -132,7 +142,11 @@ def interact_with_profile(liker_username, liker_username_view): if not can_interact: print("@" + liker_username + ": Cant be interacted (due to limits / already followed). Skip.") - storage.add_interacted_user(liker_username, followed=False) + storage.add_interacted_user(liker_username, + followed=False, + source=f"#{hashtag}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=interaction_source, user=liker_username, succeed=False)) else: print_interaction_types(liker_username, can_like, can_follow, can_watch) @@ -147,11 +161,19 @@ def interact_with_profile(liker_username, liker_username_view): is_liked, is_followed, is_watch = interaction(username=liker_username, interaction_strategy=interaction_strategy) if is_liked or is_followed or is_watch: - storage.add_interacted_user(liker_username, followed=is_followed) + storage.add_interacted_user(liker_username, + followed=is_followed, + source=f"#{hashtag}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=interaction_source, user=liker_username, succeed=True)) print_short_report(interaction_source, session_state) else: - storage.add_interacted_user(liker_username, followed=False) + storage.add_interacted_user(liker_username, + followed=False, + source=f"#{hashtag}", + interaction_type=instructions.value, + provider=Provider.INTERACTION) on_action(InteractAction(source=interaction_source, user=liker_username, succeed=False)) can_continue = True @@ -198,7 +220,7 @@ def extract_hashtag_profiles_and_interact(device, # Switch to Recent tab if instructions == HashtagInteractionType.RECENT_LIKERS: print("Switching to Recent tab") - tab_layout = device.find(resourceId='com.instagram.android:id/tab_layout', + tab_layout = device.find(resourceId=f'{device.app_id}:id/tab_layout', className='android.widget.LinearLayout') if tab_layout.exists(): tab_layout.child(index=1).click() @@ -211,7 +233,7 @@ def extract_hashtag_profiles_and_interact(device, print("Opening the first post") # Index 1 is reserved for hot Reels by this tag first_post_index = 2 if instructions == HashtagInteractionType.TOP_LIKERS else 1 - first_post_view = device.find(resourceId='com.instagram.android:id/image_button', + first_post_view = device.find(resourceId=f'{device.app_id}:id/image_button', className='android.widget.ImageView', index=first_post_index) first_post_view.click() @@ -235,7 +257,10 @@ def pre_conditions(liker_username, liker_username_view): posts_end_detector.notify_new_page() sleeper.random_sleep() - iterate_over_likers(device, iteration_callback, pre_conditions) + should_continue_using_source = iterate_over_likers(device, iteration_callback, pre_conditions) + + if not should_continue_using_source: + break if posts_end_detector.is_the_end(): break diff --git a/insomniac/action_handle_target.py b/insomniac/action_runners/interact/action_handle_target.py similarity index 94% rename from insomniac/action_handle_target.py rename to insomniac/action_runners/interact/action_handle_target.py index 8c37548..f0d2066 100644 --- a/insomniac/action_handle_target.py +++ b/insomniac/action_runners/interact/action_handle_target.py @@ -1,7 +1,7 @@ from functools import partial +from insomniac.action_runners.actions_runners_manager import ActionState from insomniac.actions_impl import interact_with_user, InteractionStrategy, is_private_account, open_user, do_have_story -from insomniac.actions_runners import ActionState from insomniac.actions_types import LikeAction, FollowAction, InteractAction, GetProfileAction, StoryWatchAction from insomniac.limits import process_limits from insomniac.report import print_short_report, print_interaction_types @@ -39,7 +39,7 @@ def pre_conditions(target_name, target_name_view): print("@" + target_name + ": already interacted. Skip.") return False elif is_passed_filters is not None: - if not is_passed_filters(device, target_name, ['BEFORE_PROFILE_CLICK']): + if not is_passed_filters(device, target_name, reset=True, filters_tags=['BEFORE_PROFILE_CLICK']): storage.add_filtered_user(target_name) return False @@ -63,7 +63,7 @@ def interact_with_target(target_name, target_name_view): print("@" + target_name + ": interact") if is_passed_filters is not None: - if not is_passed_filters(device, target_name): + if not is_passed_filters(device, target_name, reset=False): storage.add_filtered_user(target_name) print("Moving to next target") return @@ -143,3 +143,6 @@ def interact_with_target(target_name, target_name_view): if pre_conditions(username, None): if open_user(device=device, username=username, refresh=False, on_action=on_action): interact_with_target(username, None) + else: + print("@" + username + " profile couldn't be opened. Skip.") + storage.add_interacted_user(username, followed=False) diff --git a/insomniac/action_runners/unfollow/__init__.py b/insomniac/action_runners/unfollow/__init__.py new file mode 100644 index 0000000..5d96001 --- /dev/null +++ b/insomniac/action_runners/unfollow/__init__.py @@ -0,0 +1,60 @@ +from insomniac.action_runners import * +from insomniac.safely_runner import run_safely +from insomniac.utils import * + + +class UnfollowActionRunner(CoreActionsRunner): + ACTION_ID = "unfollow" + ACTION_ARGS = { + "unfollow": { + "help": 'unfollow at most given number of users. Only users followed by this script will ' + 'be unfollowed. The order is from oldest to newest followings. ' + 'It can be a number (e.g. 100) or a range (e.g. 100-200)', + "metavar": '100-200' + }, + "unfollow_followed_by_anyone": { + 'help': 'By default, only profiles that been followed by the bot will be unfollowed. Set this parameter ' + 'if you want to unfollow any profile (even if not been followed by the bot)', + 'action': 'store_true' + }, + "unfollow_non_followers": { + 'help': 'unfollow only profiles that are not following you', + 'action': 'store_true' + } + } + + unfollow_followed_by_anyone = False + unfollow_non_followers = False + + def is_action_selected(self, args): + return args.unfollow is not None + + def set_params(self, args): + if args.unfollow_followed_by_anyone is not None: + self.unfollow_followed_by_anyone = True + + if args.unfollow_non_followers is not None: + self.unfollow_non_followers = True + + def run(self, device_wrapper, storage, session_state, on_action, is_limit_reached, is_passed_filters=None): + from insomniac.action_runners.unfollow.action_unfollow import unfollow, get_unfollow_restriction + + unfollow_restriction = get_unfollow_restriction(self.unfollow_followed_by_anyone, self.unfollow_non_followers) + + self.action_status = ActionStatus(ActionState.PRE_RUN) + + @run_safely(device_wrapper=device_wrapper) + def job(): + self.action_status.set(ActionState.RUNNING) + unfollow(device=device_wrapper.get(), + on_action=on_action, + storage=storage, + unfollow_restriction=unfollow_restriction, + session_state=session_state, + is_limit_reached=is_limit_reached, + action_status=self.action_status) + print("Unfollowed " + str(session_state.totalUnfollowed) + " " + unfollow_restriction.value + ", finish.") + self.action_status.set(ActionState.DONE) + + while not self.action_status.get() == ActionState.DONE: + job() diff --git a/insomniac/action_unfollow.py b/insomniac/action_runners/unfollow/action_unfollow.py similarity index 77% rename from insomniac/action_unfollow.py rename to insomniac/action_runners/unfollow/action_unfollow.py index 13e8bc7..cd26b1e 100644 --- a/insomniac/action_unfollow.py +++ b/insomniac/action_runners/unfollow/action_unfollow.py @@ -32,7 +32,8 @@ def iteration_callback_pre_conditions(following_name, following_name_view): print("Skip @" + following_name + ". Following status: " + following_status.name + ".") return False - if unfollow_restriction == UnfollowRestriction.ANY: + if unfollow_restriction == UnfollowRestriction.ANY or \ + unfollow_restriction == UnfollowRestriction.ANY_NON_FOLLOWERS: following_status = storage.get_following_status(following_name) if following_status == FollowingStatus.UNFOLLOWED: print("Skip @" + following_name + ". Following status: " + following_status.name + ".") @@ -62,7 +63,8 @@ def iteration_callback(following_name, following_name_view): get_profile_reached_source_limit, action_status, "Get-Profile"): return False - check_if_is_follower = unfollow_restriction == UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS + check_if_is_follower = unfollow_restriction == UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS or \ + unfollow_restriction == UnfollowRestriction.ANY_NON_FOLLOWERS unfollowed = do_unfollow(device, following_name, session_state.my_username, check_if_is_follower, on_action) if unfollowed: @@ -76,6 +78,18 @@ def iteration_callback(following_name, following_name_view): @unique class UnfollowRestriction(Enum): - ANY = 0 - FOLLOWED_BY_SCRIPT = 1 - FOLLOWED_BY_SCRIPT_NON_FOLLOWERS = 2 + ANY = "profiles" + FOLLOWED_BY_SCRIPT = "followed-by-bot profiles" + FOLLOWED_BY_SCRIPT_NON_FOLLOWERS = "followed-by-bot non-followers-profiles" + ANY_NON_FOLLOWERS = "non-followers-profiles" + + +def get_unfollow_restriction(followed_by_anyone, unfollow_non_followers): + # followed_by_anyone -> unfollow_non_followers -> restriction + unfollow_restriction_matrix = { + True: {True: UnfollowRestriction.ANY_NON_FOLLOWERS, + False: UnfollowRestriction.ANY}, + False: {True: UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS, + False: UnfollowRestriction.FOLLOWED_BY_SCRIPT} + } + return unfollow_restriction_matrix[followed_by_anyone][unfollow_non_followers] diff --git a/insomniac/actions_impl.py b/insomniac/actions_impl.py index caf8e7a..72a7481 100644 --- a/insomniac/actions_impl.py +++ b/insomniac/actions_impl.py @@ -5,20 +5,19 @@ from insomniac.navigation import switch_to_english, search_for, LanguageChangedException from insomniac.scroll_end_detector import ScrollEndDetector from insomniac.sleeper import sleeper +from insomniac.softban_indicator import softban_indicator from insomniac.utils import * +from insomniac.views import ActionBarView -FOLLOWERS_BUTTON_ID_REGEX = 'com.instagram.android:id/row_profile_header_followers_container' \ - '|com.instagram.android:id/row_profile_header_container_followers' +FOLLOWERS_BUTTON_ID_REGEX = '{0}:id/row_profile_header_followers_container' \ + '|{1}:id/row_profile_header_container_followers' TEXTVIEW_OR_BUTTON_REGEX = 'android.widget.TextView|android.widget.Button' FOLLOW_REGEX = 'Follow|Follow Back' UNFOLLOW_REGEX = 'Following|Requested' SHOP_REGEX = 'Add Shop|View Shop' -FOLLOWING_BUTTON_ID_REGEX = 'com.instagram.android:id/row_profile_header_following_container' \ - '|com.instagram.android:id/row_profile_header_container_following' -USER_AVATAR_VIEW_ID = 'com.instagram.android:id/circular_image|^$' - -_action_bar_bottom = None -_tab_bar_top = None +FOLLOWING_BUTTON_ID_REGEX = '{0}:id/row_profile_header_following_container' \ + '|{1}:id/row_profile_header_container_following' +USER_AVATAR_VIEW_ID = '{0}:id/circular_image|^$' liked_count = 0 is_followed = False @@ -36,35 +35,11 @@ def __init__(self, do_like=False, do_follow=False, do_story_watch=False, self.stories_count = stories_count -def update_interaction_rect(device): - action_bar = device.find(resourceId='com.instagram.android:id/action_bar_container', - className='android.widget.FrameLayout') - if action_bar.exists(): - global _action_bar_bottom - _action_bar_bottom = action_bar.get_bounds()['bottom'] - - tab_bar = device.find(resourceId='com.instagram.android:id/tab_bar', - className='android.widget.LinearLayout') - if tab_bar.exists(): - global _tab_bar_top - _tab_bar_top = tab_bar.get_bounds()['top'] - - -def is_in_interaction_rect(view): - if _action_bar_bottom is None or _tab_bar_top is None: - print(COLOR_FAIL + "Interaction rect is not specified." + COLOR_ENDC) - return False - - view_top = view.get_bounds()['top'] - view_bottom = view.get_bounds()['bottom'] - return _action_bar_bottom <= view_top and view_bottom <= _tab_bar_top - - def scroll_to_bottom(device): print("Scroll to bottom") def is_end_reached(): - see_all_button = device.find(resourceId='com.instagram.android:id/see_all_button', + see_all_button = device.find(resourceId=f'{device.app_id}:id/see_all_button', className='android.widget.TextView') return see_all_button.exists() @@ -76,7 +51,7 @@ def is_end_reached(): print("Scroll back to the first follower") def is_at_least_one_follower(): - follower = device.find(resourceId='com.instagram.android:id/follow_list_container', + follower = device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout') return follower.exists() @@ -104,11 +79,11 @@ def open_user_followings(device, username, refresh=False, on_action=None): def iterate_over_followers(device, is_myself, iteration_callback, iteration_callback_pre_conditions, iterate_without_sleep=False): # Wait until list is rendered - device.find(resourceId='com.instagram.android:id/follow_list_container', + device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout').wait() def scrolled_to_top(): - row_search = device.find(resourceId='com.instagram.android:id/row_search_edit_text', + row_search = device.find(resourceId=f'{device.app_id}:id/row_search_edit_text', className='android.widget.EditText') return row_search.exists() @@ -124,7 +99,7 @@ def scrolled_to_top(): scroll_end_detector.notify_new_page() try: - for item in device.find(resourceId='com.instagram.android:id/follow_list_container', + for item in device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout'): user_info_view = item.child(index=1) user_name_view = user_info_view.child(index=0).child() @@ -152,7 +127,7 @@ def scrolled_to_top(): print(COLOR_OKGREEN + "Scrolled to top, finish." + COLOR_ENDC) return elif len(screen_iterated_followers) > 0: - load_more_button = device.find(resourceId='com.instagram.android:id/row_load_more_button') + load_more_button = device.find(resourceId=f'{device.app_id}:id/row_load_more_button') load_more_button_exists = load_more_button.exists(quick=True) if scroll_end_detector.is_the_end(): @@ -203,9 +178,9 @@ def iterate_over_likers(device, iteration_callback, iteration_callback_pre_condi screen_iterated_likers = [] try: - for item in device.find(resourceId='com.instagram.android:id/row_user_container_base', + for item in device.find(resourceId=f'{device.app_id}:id/row_user_container_base', className='android.widget.LinearLayout'): - user_name_view = item.child(resourceId='com.instagram.android:id/row_user_primary_name', + user_name_view = item.child(resourceId=f'{device.app_id}:id/row_user_primary_name', className='android.widget.TextView') if not user_name_view.exists(quick=True): print(COLOR_OKGREEN + "Next item not found: probably reached end of the screen." + COLOR_ENDC) @@ -220,7 +195,9 @@ def iterate_over_likers(device, iteration_callback, iteration_callback_pre_condi to_continue = iteration_callback(username, user_name_view) if not to_continue: print(COLOR_OKBLUE + "Stopping hashtag-likers iteration" + COLOR_ENDC) - return + print(f"Going back") + device.back() + return False except IndexError: print(COLOR_FAIL + "Cannot get next item: probably reached end of the screen." + COLOR_ENDC) @@ -236,6 +213,8 @@ def iterate_over_likers(device, iteration_callback, iteration_callback_pre_condi print(COLOR_OKGREEN + "Need to scroll now" + COLOR_ENDC) likes_list_view.scroll(DeviceFacade.Direction.BOTTOM) + return True + def interact_with_user(device, user_source, @@ -260,7 +239,7 @@ def interact_with_user(device, if interaction_strategy.do_story_watch: is_watched = _watch_stories(device, username, interaction_strategy.stories_count, on_action) - coordinator_layout = device.find(resourceId='com.instagram.android:id/coordinator_root_layout') + coordinator_layout = device.find(resourceId=f'{device.app_id}:id/coordinator_root_layout') if coordinator_layout.exists(): print("Scroll down to see more photos.") coordinator_layout.scroll(DeviceFacade.Direction.BOTTOM) @@ -325,7 +304,7 @@ def open_photo(): if to_like: print("Double click!") - photo_view = device.find(resourceId='com.instagram.android:id/layout_container_main', + photo_view = device.find(resourceId=f'{device.app_id}:id/layout_container_main', className='android.widget.FrameLayout') photo_view.double_click() sleeper.random_sleep() @@ -334,10 +313,10 @@ def open_photo(): try: # Click only button which is under the action bar and above the tab bar. # It fixes bugs with accidental back / home clicks. - for like_button in device.find(resourceId='com.instagram.android:id/row_feed_button_like', + for like_button in device.find(resourceId=f'{device.app_id}:id/row_feed_button_like', className='android.widget.ImageView', selected=False): - if is_in_interaction_rect(like_button): + if ActionBarView.is_in_interaction_rect(like_button): print("Double click didn't work, click on icon.") like_button.click() sleeper.random_sleep() @@ -345,7 +324,7 @@ def open_photo(): except DeviceFacade.JsonRpcError: print("Double click worked successfully.") - detect_block(device) + softban_indicator.detect_action_blocked_dialog(device) on_like() print("Back to profile") @@ -359,20 +338,20 @@ def _follow(device, username, follow_percentage): return False print("Following...") - coordinator_layout = device.find(resourceId='com.instagram.android:id/coordinator_root_layout') + coordinator_layout = device.find(resourceId=f'{device.app_id}:id/coordinator_root_layout') if coordinator_layout.exists(): coordinator_layout.scroll(DeviceFacade.Direction.TOP) sleeper.random_sleep() - profile_header_main_layout = device.find(resourceId="com.instagram.android:id/profile_header_fixed_list", + profile_header_main_layout = device.find(resourceId=f"{device.app_id}:id/profile_header_fixed_list", className='android.widget.LinearLayout') shop_button = profile_header_main_layout.child(className='android.widget.Button', clickable=True, textMatches=SHOP_REGEX) - if shop_button.exists(): + if shop_button.exists(quick=True): follow_button = profile_header_main_layout.child(className='android.widget.Button', clickable=True, textMatches=FOLLOW_REGEX) @@ -380,7 +359,7 @@ def _follow(device, username, follow_percentage): print(COLOR_FAIL + "Look like a shop profile without an option to follow, continue." + COLOR_ENDC) return False else: - profile_header_actions_layout = device.find(resourceId='com.instagram.android:id/profile_header_actions_top_row', + profile_header_actions_layout = device.find(resourceId=f'{device.app_id}:id/profile_header_actions_top_row', className='android.widget.LinearLayout') if not profile_header_actions_layout.exists(): print(COLOR_FAIL + "Cannot find profile actions." + COLOR_ENDC) @@ -405,25 +384,25 @@ def _follow(device, username, follow_percentage): return False follow_button.click() - detect_block(device) + softban_indicator.detect_action_blocked_dialog(device) print(COLOR_OKGREEN + "Followed @" + username + COLOR_ENDC) sleeper.random_sleep() return True def do_have_story(device): - return device.find(resourceId="com.instagram.android:id/reel_ring", + return device.find(resourceId=f"{device.app_id}:id/reel_ring", className="android.view.View").exists(quick=True) def is_already_followed(device): # Using main layout in order to support shop pages - profile_header_main_layout = device.find(resourceId="com.instagram.android:id/profile_header_fixed_list", + profile_header_main_layout = device.find(resourceId=f"{device.app_id}:id/profile_header_fixed_list", className='android.widget.LinearLayout') unfollow_button = profile_header_main_layout.child(classNameMatches=TEXTVIEW_OR_BUTTON_REGEX, clickable=True, textMatches=UNFOLLOW_REGEX) - return unfollow_button.exists() + return unfollow_button.exists(quick=True) def _watch_stories(device, username, stories_value, on_action): @@ -432,7 +411,7 @@ def _watch_stories(device, username, stories_value, on_action): if do_have_story(device): profile_picture = device.find( - resourceId="com.instagram.android:id/row_profile_header_imageview", + resourceId=f"{device.app_id}:id/row_profile_header_imageview", className="android.widget.ImageView" ) @@ -467,7 +446,7 @@ def _skip_story(device): def _is_story_opened(device): - reel_viewer = device.find(resourceId="com.instagram.android:id/reel_viewer_root", + reel_viewer = device.find(resourceId=f"{device.app_id}:id/reel_viewer_root", className="android.widget.FrameLayout") return reel_viewer.exists() @@ -475,19 +454,19 @@ def _is_story_opened(device): def _open_user(device, username, open_followers=False, open_followings=False, refresh=False, on_action=None): if refresh: print("Refreshing profile status...") - coordinator_layout = device.find(resourceId='com.instagram.android:id/coordinator_root_layout') + coordinator_layout = device.find(resourceId=f'{device.app_id}:id/coordinator_root_layout') if coordinator_layout.exists(): coordinator_layout.scroll(DeviceFacade.Direction.TOP) if username is None: if open_followers: print("Open your followers") - followers_button = device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX) + followers_button = device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX.format(device.app_id, device.app_id)) followers_button.click() if open_followings: print("Open your followings") - followings_button = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + followings_button = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX.format(device.app_id, device.app_id)) followings_button.click() else: if not search_for(device, username=username, on_action=on_action): @@ -495,14 +474,18 @@ def _open_user(device, username, open_followers=False, open_followings=False, re sleeper.random_sleep() + is_profile_empty = softban_indicator.detect_empty_profile(device) + if is_profile_empty: + return False + if open_followers: print("Open @" + username + " followers") - followers_button = device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX) + followers_button = device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX.format(device.app_id, device.app_id)) followers_button.click() if open_followings: print("Open @" + username + " followings") - followings_button = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + followings_button = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX.format(device.app_id, device.app_id)) followings_button.click() return True @@ -510,7 +493,7 @@ def _open_user(device, username, open_followers=False, open_followings=False, re def iterate_over_followings(device, iteration_callback, iteration_callback_pre_conditions): # Wait until list is rendered - device.find(resourceId='com.instagram.android:id/follow_list_container', + device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout').wait() while True: @@ -518,7 +501,7 @@ def iterate_over_followings(device, iteration_callback, iteration_callback_pre_c sleeper.random_sleep() screen_iterated_followings = 0 - for item in device.find(resourceId='com.instagram.android:id/follow_list_container', + for item in device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout'): user_info_view = item.child(index=1) user_name_view = user_info_view.child(index=0).child() @@ -536,7 +519,7 @@ def iterate_over_followings(device, iteration_callback, iteration_callback_pre_c if to_continue: sleeper.random_sleep() else: - print(COLOR_OKBLUE + "Stopping unfollowing" + COLOR_ENDC) + print(COLOR_OKBLUE + "Stopping iteration over followings" + COLOR_ENDC) return if screen_iterated_followings > 0: @@ -551,7 +534,7 @@ def iterate_over_followings(device, iteration_callback, iteration_callback_pre_c def sort_followings_by_date(device): print("Sort followings by date: from oldest to newest.") - sort_button = device.find(resourceId='com.instagram.android:id/sorting_entry_row_icon', + sort_button = device.find(resourceId=f'{device.app_id}:id/sorting_entry_row_icon', className='android.widget.ImageView') if not sort_button.exists(): print(COLOR_FAIL + "Cannot find button to sort followings. Continue without sorting." + COLOR_ENDC) @@ -559,7 +542,7 @@ def sort_followings_by_date(device): sort_button.click() sort_options_recycler_view = device.find( - resourceId='com.instagram.android:id/follow_list_sorting_options_recycler_view') + resourceId=f'{device.app_id}:id/follow_list_sorting_options_recycler_view') if not sort_options_recycler_view.exists(): print(COLOR_FAIL + "Cannot find options to sort followings. Continue without sorting." + COLOR_ENDC) return @@ -571,7 +554,7 @@ def do_unfollow(device, username, my_username, check_if_is_follower, on_action): """ :return: whether unfollow was successful """ - username_view = device.find(resourceId='com.instagram.android:id/follow_list_username', + username_view = device.find(resourceId=f'{device.app_id}:id/follow_list_username', className='android.widget.TextView', text=username) if not username_view.exists(): @@ -579,6 +562,13 @@ def do_unfollow(device, username, my_username, check_if_is_follower, on_action): return False username_view.click() on_action(GetProfileAction(user=username)) + sleeper.random_sleep() + if_profile_empty = softban_indicator.detect_empty_profile(device) + + if if_profile_empty: + print("Back to the followings list.") + device.back() + return False if check_if_is_follower and _check_is_follower(device, username, my_username): print("Skip @" + username + ". This user is following you.") @@ -596,7 +586,7 @@ def do_unfollow(device, username, my_username, check_if_is_follower, on_action): raise LanguageChangedException() unfollow_button.click() - confirm_unfollow_button = device.find(resourceId='com.instagram.android:id/follow_sheet_unfollow_row', + confirm_unfollow_button = device.find(resourceId=f'{device.app_id}:id/follow_sheet_unfollow_row', className='android.widget.TextView') if not confirm_unfollow_button.exists(): print(COLOR_FAIL + "Cannot confirm unfollow." + COLOR_ENDC) @@ -607,7 +597,7 @@ def do_unfollow(device, username, my_username, check_if_is_follower, on_action): sleeper.random_sleep() _close_confirm_dialog_if_shown(device) - detect_block(device) + softban_indicator.detect_action_blocked_dialog(device) print("Back to the followings list.") device.back() @@ -615,9 +605,9 @@ def do_unfollow(device, username, my_username, check_if_is_follower, on_action): def open_likers(device): - likes_view = device.find(resourceId='com.instagram.android:id/row_feed_textview_likes', + likes_view = device.find(resourceId=f'{device.app_id}:id/row_feed_textview_likes', className='android.widget.TextView') - if likes_view.exists(quick=True) and is_in_interaction_rect(likes_view): + if likes_view.exists(quick=True) and ActionBarView.is_in_interaction_rect(likes_view): print("Opening post likers") sleeper.random_sleep() likes_view.click() @@ -628,35 +618,44 @@ def open_likers(device): def _check_is_follower(device, username, my_username): print(COLOR_OKGREEN + "Check if @" + username + " is following you." + COLOR_ENDC) - following_container = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + following_container = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX.format(device.app_id, device.app_id)) following_container.click() sleeper.random_sleep() - my_username_view = device.find(resourceId='com.instagram.android:id/follow_list_username', - className='android.widget.TextView', - text=my_username) - result = my_username_view.exists() - print("Back to the profile.") - device.back() - return result + is_list_empty = softban_indicator.detect_empty_list(device) + + if is_list_empty: + # By default, the profile will be considered as following if the profile list didnt loaded + print("List seems to be empty, cant decide if you are followed by the profile or not (could be a soft-ban).") + print("Back to the profile.") + device.back() + return True + else: + my_username_view = device.find(resourceId=f'{device.app_id}:id/follow_list_username', + className='android.widget.TextView', + text=my_username) + result = my_username_view.exists() + print("Back to the profile.") + device.back() + return result def _close_confirm_dialog_if_shown(device): - dialog_root_view = device.find(resourceId='com.instagram.android:id/dialog_root_view', + dialog_root_view = device.find(resourceId=f'{device.app_id}:id/dialog_root_view', className='android.widget.FrameLayout') if not dialog_root_view.exists(): return # Avatar existence is the way to distinguish confirm dialog from block dialog - user_avatar_view = device.find(resourceIdMatches=USER_AVATAR_VIEW_ID, + user_avatar_view = device.find(resourceIdMatches=USER_AVATAR_VIEW_ID.format(device.app_id), className='android.widget.ImageView') if not user_avatar_view.exists(): return print(COLOR_OKGREEN + "Dialog shown, confirm unfollowing." + COLOR_ENDC) sleeper.random_sleep() - unfollow_button = dialog_root_view.child(resourceId='com.instagram.android:id/primary_button', + unfollow_button = dialog_root_view.child(resourceId=f'{device.app_id}:id/primary_button', className='android.widget.TextView') unfollow_button.click() @@ -664,7 +663,7 @@ def _close_confirm_dialog_if_shown(device): def _get_action_bar(device): tab_bar = device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/action_bar_container" + f"{device.app_id}:id/action_bar_container" ), className="android.widget.FrameLayout", ) diff --git a/insomniac/actions_providers.py b/insomniac/actions_providers.py new file mode 100644 index 0000000..e2e559e --- /dev/null +++ b/insomniac/actions_providers.py @@ -0,0 +1,9 @@ +from enum import Enum, unique + + +@unique +class Provider(Enum): + UNKNOWN = 0 + INTERACTION = 1 + SCRAPING = 2 + TARGETS_LIST = 3 diff --git a/insomniac/database_engine.py b/insomniac/database_engine.py new file mode 100644 index 0000000..68115f5 --- /dev/null +++ b/insomniac/database_engine.py @@ -0,0 +1,439 @@ +import sqlite3 + +from insomniac.actions_providers import Provider +from insomniac.utils import * + +DB_NAME = "interaction_data.db" +DB_VERSIONS = {'3.5.0': 1} + +SQL_SELECT_FROM_METADATA = "SELECT * from metadata" +SQL_SELECT_FROM_INTERACTED_USERS_BY_USERNAME = "SELECT * from interacted_users WHERE username = :username" +SQL_SELECT_TARGETS_FROM_INTERACTED_USERS = "SELECT * from interacted_users WHERE interactions_count = 0 " \ + "AND (provider = 'TARGETS_LIST' OR provider = 'SCRAPING')" +SQL_SELECT_FROM_FILTERED_USERS_BY_USERNAME = "SELECT * from filtered_users WHERE username = :username" +SQL_SELECT_FROM_SCRAPED_USERS_BY_USERNAME = "SELECT * from scraped_users WHERE username = :username" + +SQL_INSERT_INTO_METADATA = "INSERT INTO metadata DEFAULT VALUES" +SQL_INSERT_INTO_INTERACTED_USERS = "INSERT INTO interacted_users (username, last_interaction, " \ + "source, interaction_type, provider) VALUES (?, ?, ?, ?, ?)" +SQL_INSERT_INTO_FILTERED_USERS = "INSERT INTO filtered_users (username, filtered_at) VALUES (?, ?)" +SQL_INSERT_INTO_SCRAPED_USERS = "INSERT INTO scraped_users (username, last_interaction) VALUES (?, ?)" +SQL_INSERT_INTO_PROFILES = "INSERT INTO profiles (followers, following) VALUES (?, ?)" +SQL_INSERT_INTO_SESSIONS = """ + INSERT INTO sessions ( + app_version, + total_interactions, + successful_interactions, + total_followed, + total_likes, + total_unfollowed, + total_stories_watched, + total_get_profile, + total_scraped, + removed_mass_followers, + start_time, + finish_time, + args, + profile_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" + +SQL_DELETE_FROM_TARGETS_BY_USERNAME = "DELETE from targets WHERE username = :username" + +SQL_UPDATE_INTERACTED_USER = "UPDATE interacted_users set following_status = ?, last_interaction = ?, " \ + "interactions_count = ?, source = ?, interaction_type = ?, provider = ? where username = ?" +SQL_UPDATE_INTERACTED_USER_INTERACTIONS_COUNT = "UPDATE interacted_users set interactions_count = ? where username = ?" +SQL_UPDATE_FILTERED_USER = "UPDATE filtered_users set filtered_at = ? where username = ?" +SQL_UPDATE_SCRAPED_USER = "UPDATE scraped_users set scraping_status = ?, last_interaction = ? where username = ?" + +SQL_CREATE_METADATA_TABLE = f""" + CREATE TABLE IF NOT EXISTS `metadata` ( + `version` INTEGER PRIMARY KEY DEFAULT {max(DB_VERSIONS.values())});""" + +SQL_CREATE_INTERACTED_USERS_TABLE = """ + CREATE TABLE IF NOT EXISTS `interacted_users` ( + `username` TEXT PRIMARY KEY, + `following_status` TEXT CHECK(`following_status` IN ('NONE', 'FOLLOWED', 'UNFOLLOWED')) NOT NULL DEFAULT 'NONE', + `last_interaction` DATETIME NOT NULL, + `interactions_count` INTEGER NOT NULL DEFAULT 0, + `source` TEXT, + `interaction_type` TEXT, + `provider` TEXT CHECK(`provider` IN ('UNKNOWN', 'INTERACTION', 'SCRAPING', 'TARGETS_LIST')));""" + +SQL_CREATE_SCRAPED_USERS_TABLE = """ + CREATE TABLE IF NOT EXISTS `scraped_users` ( + `username` TEXT PRIMARY KEY, + `scraping_status` TEXT CHECK(`scraping_status` IN ('SCRAPED','NOT_SCRAPED')) NOT NULL DEFAULT 'NOT_SCRAPED', + `last_interaction` DATETIME NOT NULL);""" + +SQL_CREATE_FILTERED_USERS_TABLE = """ + CREATE TABLE IF NOT EXISTS `filtered_users` ( + `username` TEXT PRIMARY KEY, + `filtered_at` DATETIME NOT NULL);""" + +SQL_CREATE_PROFILE_TABLE = """ + CREATE TABLE IF NOT EXISTS `profiles` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `followers` INTEGER, + `following` INTEGER);""" + +SQL_CREATE_SESSIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS `sessions` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `app_version` TEXT NOT NULL, + `total_interactions` INTEGER NOT NULL, + `successful_interactions` INTEGER NOT NULL, + `total_followed` INTEGER NOT NULL, + `total_likes` INTEGER NOT NULL, + `total_unfollowed` INTEGER NOT NULL, + `total_stories_watched` INTEGER NOT NULL, + `total_get_profile` INTEGER NOT NULL, + `total_scraped` INTEGER NOT NULL, + `removed_mass_followers` TEXT NOT NULL, + `start_time` DATETIME NOT NULL, + `finish_time` DATETIME, + `args` TEXT NOT NULL, + `profile_id` INTEGER REFERENCES `profiles` (id));""" + + +def get_database(username): + address = os.path.join(username, DB_NAME) + if not check_database_exists(username): + create_database(address) + return address + + +def check_database_exists(username): + address = os.path.join(username, DB_NAME) + verify_database_directories(address) + return os.path.isfile(address) + + +def create_database(address): + connection = None + try: + connection = sqlite3.connect(address) + with connection: + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + create_tables( + cursor, + [ + "metadata", + "interacted_users", + "scraped_users", + "filtered_users", + "profiles", + "sessions", + "targets" + ] + ) + _update_database(cursor) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot create/open database at {address}: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def create_tables(cursor, tables): + if "metadata" in tables: + cursor.execute(SQL_CREATE_METADATA_TABLE) + + if "interacted_users" in tables: + cursor.execute(SQL_CREATE_INTERACTED_USERS_TABLE) + + if "scraped_users" in tables: + cursor.execute(SQL_CREATE_SCRAPED_USERS_TABLE) + + if "filtered_users" in tables: + cursor.execute(SQL_CREATE_FILTERED_USERS_TABLE) + + if "profiles" in tables: + cursor.execute(SQL_CREATE_PROFILE_TABLE) + + if "sessions" in tables: + cursor.execute(SQL_CREATE_SESSIONS_TABLE) + + +def verify_database_directories(address): + db_dir = os.path.dirname(address) + if not os.path.exists(db_dir): + os.makedirs(db_dir) + + +def get_interacted_user(address, username): + connection = None + interacted_user = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + interacted_user = _select_interacted_user_by_username(cursor, username) + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot get interacted user {username}: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + return dict(interacted_user) if interacted_user is not None else None + + +def update_interacted_users(address, + usernames, + last_interactions, + following_statuses, + sources, + interaction_types, + providers): + connection = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + for i, username in enumerate(usernames): + last_interaction = last_interactions[i] + following_status = following_statuses[i] + source = sources[i] + interaction_type = interaction_types[i] + provider = providers[i] + + interacted_user = _select_interacted_user_by_username(cursor, username) + if interacted_user is None: + cursor.execute(SQL_INSERT_INTO_INTERACTED_USERS, (username, last_interaction, source, interaction_type, provider.name)) + cursor.execute( + SQL_UPDATE_INTERACTED_USER, + ( + following_status.name, + last_interaction, + interacted_user["interactions_count"] + 1 if interacted_user is not None else 1, + source if source is not None else (interacted_user["source"] if interacted_user is not None else None), + interaction_type if interaction_type is not None else (interacted_user["interaction_type"] if interacted_user is not None else None), + provider.name if provider is not None else (interacted_user["provider"] if interacted_user is not None else Provider.UNKNOWN), + username + ) + ) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot update interacted users: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def get_filtered_user(address, username): + connection = None + filtered_user = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + filtered_user = _select_filtered_user_by_username(cursor, username) + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot get filtered user {username}: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + return dict(filtered_user) if filtered_user is not None else None + + +def update_filtered_users(address, usernames, filtered_at_list): + connection = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + for i, username in enumerate(usernames): + filtered_at = filtered_at_list[i] + + filtered_user = _select_filtered_user_by_username(cursor, username) + if filtered_user is None: + cursor.execute(SQL_INSERT_INTO_FILTERED_USERS, (username, filtered_at)) + cursor.execute(SQL_UPDATE_FILTERED_USER, (filtered_at, username)) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot update filtered users: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def get_scraped_user(address, username): + connection = None + scraped_user = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + scraped_user = _select_scraped_user_by_username(cursor, username) + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot get scraped user {username}: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + return dict(scraped_user) if scraped_user is not None else None + + +def update_scraped_users(address, usernames, last_interactions, scraping_statuses): + connection = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + for i, username in enumerate(usernames): + last_interaction = last_interactions[i] + scraping_status = scraping_statuses[i] + + scraped_user = _select_scraped_user_by_username(cursor, username) + if scraped_user is None: + cursor.execute(SQL_INSERT_INTO_SCRAPED_USERS, (username, last_interaction)) + cursor.execute(SQL_UPDATE_SCRAPED_USER, (scraping_status.name, last_interaction, username)) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot update scraped users: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def add_targets(address, usernames, provider, source=None, interaction_type=None): + connection = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + for username in usernames: + user = _select_interacted_user_by_username(cursor, username) + if user is None: + cursor.execute( + SQL_INSERT_INTO_INTERACTED_USERS, + ( + username, + datetime.now(), + source, + interaction_type, + provider.name + ) + ) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot add targets: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def get_target(address): + """ + Takes a user from interacted_user table which satisfies the following conditions: + 1. Has zero interactions_count + 2. Provider is either TARGETS_LIST or SCRAPING + Then increments interactions_count. + + :return: username or None if no such user found + """ + connection = None + target = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + cursor.execute(SQL_SELECT_TARGETS_FROM_INTERACTED_USERS) + target = cursor.fetchone() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot pop target: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + return target["username"] if target is not None else None + + +def add_sessions(address, session_states): + connection = None + try: + connection = sqlite3.connect(address) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + for session_state in session_states: + _add_session(cursor, session_state) + connection.commit() + except Exception as e: + print(COLOR_FAIL + f"[Database] Cannot add sessions: {e}" + COLOR_ENDC) + finally: + if connection: + # Close the opened connection + connection.close() + + +def _update_database(cursor): + current_version = _get_database_version(cursor) + latest_version = max(DB_VERSIONS.values()) + if current_version is None: + cursor.execute(SQL_INSERT_INTO_METADATA) + elif current_version < latest_version: + # TODO: here we can add migration logic between database versions + raise NotImplemented() + elif current_version > latest_version: + raise Exception(f"[Database] Current DB version (v{current_version}) is not supported. Please update.") + + +def _get_database_version(cursor): + cursor.execute(SQL_SELECT_FROM_METADATA) + metadata_row = cursor.fetchone() + if metadata_row is None: + return None + return dict(metadata_row)["version"] + + +def _select_interacted_user_by_username(cursor, username): + cursor.execute(SQL_SELECT_FROM_INTERACTED_USERS_BY_USERNAME, {"username": username}) + interacted_user = cursor.fetchone() + return dict(interacted_user) if interacted_user is not None else None + + +def _select_filtered_user_by_username(cursor, username): + cursor.execute(SQL_SELECT_FROM_FILTERED_USERS_BY_USERNAME, {"username": username}) + filtered_user = cursor.fetchone() + return filtered_user + + +def _select_scraped_user_by_username(cursor, username): + cursor.execute(SQL_SELECT_FROM_SCRAPED_USERS_BY_USERNAME, {"username": username}) + scraped_user = cursor.fetchone() + return scraped_user + + +def _add_session(cursor, session_state): + cursor.execute(SQL_INSERT_INTO_PROFILES, (session_state.my_followers_count, session_state.my_following_count)) + profile_id = cursor.lastrowid + cursor.execute( + SQL_INSERT_INTO_SESSIONS, + ( + session_state.app_version, + sum(session_state.totalInteractions.values()), + sum(session_state.successfulInteractions.values()), + sum(session_state.totalFollowed.values()), + session_state.totalLikes, + session_state.totalUnfollowed, + session_state.totalStoriesWatched, + session_state.totalGetProfile, + sum(session_state.totalScraped.values()), + str(session_state.removedMassFollowers), + session_state.startTime, + session_state.finishTime, + str(session_state.args), + profile_id + ) + ) diff --git a/insomniac/device.py b/insomniac/device.py index 43076bc..b01a942 100644 --- a/insomniac/device.py +++ b/insomniac/device.py @@ -5,20 +5,21 @@ class DeviceWrapper(object): device = None - def __init__(self, device_id, old_uiautomator): + def __init__(self, device_id, old_uiautomator, wait_for_device, app_id): self.device_id = device_id + self.app_id = app_id self.old_uiautomator = old_uiautomator - self.create() + self.create(wait_for_device) def get(self): return self.device - def create(self): - if not check_adb_connection(is_device_id_provided=(self.device_id is not None)): + def create(self, wait_for_device): + if not check_adb_connection(device_id=self.device_id, wait_for_device=wait_for_device): return None - device = create_device(self.old_uiautomator, self.device_id) + device = create_device(self.old_uiautomator, self.device_id, self.app_id) if device is None: return None diff --git a/insomniac/device_facade.py b/insomniac/device_facade.py index 5beee84..778036a 100644 --- a/insomniac/device_facade.py +++ b/insomniac/device_facade.py @@ -1,4 +1,6 @@ from enum import Enum, unique +from random import uniform +from re import search from insomniac.utils import * @@ -7,10 +9,10 @@ UI_TIMEOUT_SHORT = 1 -def create_device(is_old, device_id): +def create_device(is_old, device_id, app_id): print("Using uiautomator v" + ("1" if is_old else "2")) try: - return DeviceFacade(is_old, device_id) + return DeviceFacade(is_old, device_id, app_id) except ImportError as e: print(COLOR_FAIL + str(e) + COLOR_ENDC) return None @@ -21,8 +23,12 @@ class DeviceFacade: deviceV2 = None # uiautomator2 width = None height = None + device_id = None + app_id = None - def __init__(self, is_old, device_id): + def __init__(self, is_old, device_id, app_id): + self.device_id = device_id + self.app_id = app_id if is_old: try: import uiautomator @@ -89,6 +95,132 @@ def dump_hierarchy(self, path): with open(path, 'w', encoding="utf-8") as outfile: outfile.write(xml_dump) + def is_screen_on(self): + return self.get_info()["screenOn"] + + def press_power(self): + if self.deviceV1 is not None: + self.deviceV1.press.power() + else: + self.deviceV2.press("power") + + def is_screen_locked(self): + cmd = f"adb {'' if self.device_id is None else ('-s '+ self.device_id)} shell dumpsys window" + + cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") + data = cmd_res.stdout.strip() + flag = search("mDreamingLockscreen=(true|false)", data) + return True if flag.group(1) == "true" else False + + def is_alive(self): + if self.deviceV1 is not None: + return self.deviceV1.alive() + else: + return self.deviceV2._is_alive() + + def wake_up(self): + """ Make sure agent is alive or bring it back up before starting. """ + attempts = 0 + while not self.is_alive() and attempts < 5: + self.get_info() + attempts += 1 + + def unlock(self): + self.swipe(DeviceFacade.Direction.TOP, 0.8) + if self.is_screen_locked(): + self.swipe(DeviceFacade.Direction.RIGHT, 0.8) + + def screen_off(self): + if self.deviceV1 is not None: + self.deviceV1.screen.off() + else: + self.deviceV2.screen_off() + + def swipe(self, direction: "DeviceFacade.Direction", scale=0.5): + """Swipe finger in the `direction`. + Scale is the sliding distance. Default to 50% of the screen width + """ + + if self.deviceV1 is not None: + def _swipe(_from, _to): + self.deviceV1.swipe(_from[0], _from[1], _to[0], _to[1]) + + lx, ly = 0, 0 + rx, ry = self._get_screen_size() + + width, height = rx - lx, ry - ly + + h_offset = int(width * (1 - scale)) // 2 + v_offset = int(height * (1 - scale)) // 2 + + left = lx + h_offset, ly + height // 2 + up = lx + width // 2, ly + v_offset + right = rx - h_offset, ly + height // 2 + bottom = lx + width // 2, ry - v_offset + + if direction == DeviceFacade.Direction.TOP: + _swipe(bottom, up) + elif direction == DeviceFacade.Direction.RIGHT: + _swipe(left, right) + elif direction == DeviceFacade.Direction.LEFT: + _swipe(right, left) + elif direction == DeviceFacade.Direction.BOTTOM: + _swipe(up, bottom) + else: + swipe_dir = "" + if direction == DeviceFacade.Direction.TOP: + swipe_dir = "up" + elif direction == DeviceFacade.Direction.RIGHT: + swipe_dir = "right" + elif direction == DeviceFacade.Direction.LEFT: + swipe_dir = "left" + elif direction == DeviceFacade.Direction.BOTTOM: + swipe_dir = "down" + self.deviceV2.swipe_ext(swipe_dir, scale=scale) + + def swipe_points(self, sx, sy, ex, ey): + if self.deviceV1 is not None: + import uiautomator + try: + self.deviceV1.swipePoints([[sx, sy], [ex, ey]]) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + try: + self.deviceV2.swipe_points([[sx, sy], [ex, ey]], uniform(0.2, 0.6)) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + def get_info(self): + if self.deviceV1 is not None: + return self.deviceV1.info + else: + return self.deviceV2.info + + def is_keyboard_open(self): + cmd = f"adb {'' if self.device_id is None else ('-s '+ self.device_id)} shell dumpsys input_method" + + cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") + data = cmd_res.stdout.strip() + flag = search("mInputShown=(true|false)", data) + return True if flag.group(1) == "true" else False + + def close_keyboard(self): + print("Closing keyboard...") + if self.is_keyboard_open(): + print("Keyboard is open, closing it by pressing back") + self.back() + print("Verifying again that keyboard is closed") + if self.is_keyboard_open(): + print(COLOR_FAIL + "Keyboard is open and couldn't be closed for some reason" + COLOR_ENDC) + else: + print("The device keyboard is closed now.") + + return + + print("The device keyboard is already closed.") + def _get_screen_size(self): if self.width is not None and self.height is not None: return self.width, self.height @@ -166,7 +298,76 @@ def right(self, *args, **kwargs): raise DeviceFacade.JsonRpcError(e) return DeviceFacade.View(is_old=False, view=view, device=self.deviceV2) - def click(self): + def left(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator + try: + view = self.viewV1.left(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=True, view=view, device=self.deviceV1) + else: + import uiautomator2 + try: + view = self.viewV2.left(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=False, view=view, device=self.deviceV2) + + def up(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator + try: + view = self.viewV1.up(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=True, view=view, device=self.deviceV1) + else: + import uiautomator2 + try: + view = self.viewV2.up(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=False, view=view, device=self.deviceV2) + + def down(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator + try: + view = self.viewV1.down(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=True, view=view, device=self.deviceV1) + else: + import uiautomator2 + try: + view = self.viewV2.down(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(is_old=False, view=view, device=self.deviceV2) + + def click(self, mode=None): + mode = DeviceFacade.Place.WHOLE if mode is None else mode + if mode == DeviceFacade.Place.WHOLE: + x_offset = uniform(0.15, 0.85) + y_offset = uniform(0.15, 0.85) + + elif mode == DeviceFacade.Place.LEFT: + x_offset = uniform(0.15, 0.4) + y_offset = uniform(0.15, 0.85) + + elif mode == DeviceFacade.Place.CENTER: + x_offset = uniform(0.4, 0.6) + y_offset = uniform(0.15, 0.85) + + elif mode == DeviceFacade.Place.RIGHT: + x_offset = uniform(0.6, 0.85) + y_offset = uniform(0.15, 0.85) + + else: + x_offset = 0.5 + y_offset = 0.5 + if self.viewV1 is not None: import uiautomator try: @@ -176,15 +377,20 @@ def click(self): else: import uiautomator2 try: - self.viewV2.click(UI_TIMEOUT_LONG) + self.viewV2.click(UI_TIMEOUT_LONG, offset=(x_offset, y_offset)) except uiautomator2.JSONRPCError as e: raise DeviceFacade.JsonRpcError(e) - def double_click(self): + def double_click(self, padding=0.3): + """ + Double click randomly in the selected view using padding + padding: % of how far from the borders we want the double click to happen. + """ + if self.viewV1 is not None: self._double_click_v1() else: - self._double_click_v2() + self._double_click_v2(padding) def scroll(self, direction): if self.viewV1 is not None: @@ -269,17 +475,57 @@ def get_bounds(self): except uiautomator2.JSONRPCError as e: raise DeviceFacade.JsonRpcError(e) - def get_text(self): + def get_text(self, retry=True): + max_attempts = 1 if not retry else 3 + attempts = 0 + + if self.viewV1 is not None: + import uiautomator + while attempts < max_attempts: + attempts += 1 + try: + text = self.viewV1.text + if text is None: + if attempts < max_attempts: + print(COLOR_REPORT + "Could not get text. Waiting 2 seconds and trying again..." + COLOR_ENDC) + sleep(2) # wait 2 seconds and retry + continue + else: + return text + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + while attempts < max_attempts: + attempts += 1 + try: + text = self.viewV2.info['text'] + if text is None: + if attempts < max_attempts: + print(COLOR_REPORT + "Could not get text. " + "Waiting 2 seconds and trying again..." + COLOR_ENDC) + sleep(2) # wait 2 seconds and retry + continue + else: + return text + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + print(COLOR_FAIL + f"Attempted to get text {attempts} times. You may have a slow network or are " + f"experiencing another problem." + COLOR_ENDC) + return "" + + def get_selected(self) -> bool: if self.viewV1 is not None: import uiautomator try: - return self.viewV1.text + self.viewV1.info["selected"] except uiautomator.JsonRPCError as e: raise DeviceFacade.JsonRpcError(e) else: import uiautomator2 try: - return self.viewV2.info['text'] + return self.viewV2.info["selected"] except uiautomator2.JSONRPCError as e: raise DeviceFacade.JsonRpcError(e) @@ -310,13 +556,28 @@ def _double_click_v1(self): config['actionAcknowledgmentTimeout'] = 3000 self.deviceV1.server.jsonrpc.setConfigurator(config) - def _double_click_v2(self): + def _double_click_v2(self, padding): import uiautomator2 visible_bounds = self.get_bounds() - center_x = (visible_bounds['right'] - visible_bounds['left']) / 2 - center_y = (visible_bounds['bottom'] - visible_bounds['top']) / 2 + horizontal_len = visible_bounds["right"] - visible_bounds["left"] + vertical_len = visible_bounds["bottom"] - visible_bounds["top"] + horizintal_padding = int(padding * horizontal_len) + vertical_padding = int(padding * vertical_len) + random_x = int( + uniform( + visible_bounds["left"] + horizintal_padding, + visible_bounds["right"] - horizintal_padding, + ) + ) + random_y = int( + uniform( + visible_bounds["top"] + vertical_padding, + visible_bounds["bottom"] - vertical_padding, + ) + ) + time_between_clicks = uniform(0.050, 0.200) try: - self.deviceV2.double_click(center_x, center_y, duration=0) + self.deviceV2.double_click(random_x, random_y, duration=time_between_clicks) except uiautomator2.JSONRPCError as e: raise DeviceFacade.JsonRpcError(e) @@ -324,11 +585,17 @@ def _double_click_v2(self): class Direction(Enum): TOP = 0 BOTTOM = 1 + RIGHT = 2 + LEFT = 3 @unique class Place(Enum): # TODO: add more places RIGHT = 0 + WHOLE = 1 + CENTER = 2 + BOTTOM = 3 + LEFT = 4 class JsonRpcError(Exception): pass diff --git a/insomniac/extra_features/profile_info_fetcher.py b/insomniac/extra_features/profile_info_fetcher.py new file mode 100644 index 0000000..a0e69d3 --- /dev/null +++ b/insomniac/extra_features/profile_info_fetcher.py @@ -0,0 +1,3 @@ +from insomniac import activation_controller + +exec(activation_controller.get_extra_feature('profile_info_fetcher')) diff --git a/insomniac/limits.py b/insomniac/limits.py index 9b3a073..7b25108 100644 --- a/insomniac/limits.py +++ b/insomniac/limits.py @@ -1,7 +1,7 @@ from abc import ABC from enum import unique, Enum -from insomniac.actions_runners import ActionState +from insomniac.action_runners.actions_runners_manager import ActionState from insomniac.actions_types import LikeAction, InteractAction, FollowAction, UnfollowAction, StoryWatchAction, \ GetProfileAction from insomniac.utils import * @@ -132,9 +132,8 @@ class TotalInteractionsLimit(CoreLimit): LIMIT_TYPE = LimitType.SESSION LIMIT_ARGS = { "total_interactions_limit": { - "help": "number of total interactions per session, disabled by default. " - "It can be a number (e.g. 70) or a range (e.g. 60-80). " - "Only successful interactions count", + "help": "number of total interactions (successful & unsuccessful) per session, disabled by default. " + "It can be a number (e.g. 70) or a range (e.g. 60-80)", "metavar": '60-80' } } @@ -152,7 +151,41 @@ def is_reached_for_action(self, action, session_state): if not type(action) == InteractAction: return False - return sum(session_state.successfulInteractions.values()) >= self.total_interactions_limit + return sum(session_state.totalInteractions.values()) >= self.total_interactions_limit + + def reset(self): + pass + + def update_state(self, action): + pass + + +class TotalSuccessfulInteractionsLimit(CoreLimit): + LIMIT_ID = "total_successful_interactions_limit" + LIMIT_TYPE = LimitType.SESSION + LIMIT_ARGS = { + "total_successful_interactions_limit": { + "help": "number of total successful interactions per session, disabled by default. " + "It can be a number (e.g. 70) or a range (e.g. 60-80)", + "metavar": '60-80' + } + } + + total_successful_interactions_limit = None + + def set_limit(self, args): + if args.total_successful_interactions_limit is not None: + self.total_successful_interactions_limit = get_value(args.total_successful_interactions_limit, + "Total successful-interactions limit: {}", 1000) + + def is_reached_for_action(self, action, session_state): + if self.total_successful_interactions_limit is None: + return False + + if not type(action) == InteractAction: + return False + + return sum(session_state.successfulInteractions.values()) >= self.total_successful_interactions_limit def reset(self): pass @@ -305,10 +338,6 @@ class UnfollowingLimit(CoreLimit): def set_limit(self, args): if args.unfollow is not None: self.unfollow_limit = get_value(args.unfollow, "Unfollow: {}", 100) - elif args.unfollow_non_followers is not None: - self.unfollow_limit = get_value(args.unfollow_non_followers, "Unfollow non followers: {}", 100) - elif args.unfollow_any is not None: - self.unfollow_limit = get_value(args.unfollow_any, "Unfollow any: {}", 100) def is_reached_for_action(self, action, session_state): if self.unfollow_limit is None: diff --git a/insomniac/migration.py b/insomniac/migration.py new file mode 100644 index 0000000..e89a168 --- /dev/null +++ b/insomniac/migration.py @@ -0,0 +1,112 @@ +from insomniac.session_state import SessionState +from insomniac.sessions import FILENAME_SESSIONS, Sessions +from insomniac.storage import * +from insomniac.utils import * + + +def migrate_from_json_to_sql(my_username): + """ + Migration from JSON storage (v3.4.2) to SQL storage (v3.5.0). + """ + if check_database_exists(my_username): + return + + database = get_database(my_username) + + interacted_users_path = os.path.join(my_username, FILENAME_INTERACTED_USERS) + if os.path.exists(interacted_users_path): + try: + print(f"[Migration] Loading data from {FILENAME_INTERACTED_USERS}...") + with open(interacted_users_path, encoding="utf-8") as json_file: + interacted_users = json.load(json_file) + usernames = [] + last_interactions = [] + following_statuses = [] + sources = [] + interaction_types = [] + providers = [] + for username in interacted_users.keys(): + usernames.append(username) + user = interacted_users[username] + last_interactions.append(datetime.strptime(user[USER_LAST_INTERACTION], '%Y-%m-%d %H:%M:%S.%f')) + following_statuses.append(FollowingStatus[user[USER_FOLLOWING_STATUS].upper()]) + sources.append(None) + interaction_types.append(None) + providers.append(Provider.UNKNOWN) + + update_interacted_users(database, usernames, last_interactions, following_statuses, sources, interaction_types, providers) + print(COLOR_BOLD + f"[Migration] Done! You can now delete {FILENAME_INTERACTED_USERS}" + COLOR_ENDC) + except ValueError as e: + print(COLOR_FAIL + f"[Migration] Cannot load {FILENAME_INTERACTED_USERS}: {e}" + COLOR_ENDC) + + scrapped_users_path = os.path.join(my_username, FILENAME_SCRAPPED_USERS) + if os.path.exists(scrapped_users_path): + try: + print(f"[Migration] Loading data from {FILENAME_SCRAPPED_USERS}...") + with open(scrapped_users_path, encoding="utf-8") as json_file: + scrapped_users = json.load(json_file) + usernames = [] + last_interactions = [] + scraping_statuses = [] + for username in scrapped_users.keys(): + usernames.append(username) + user = scrapped_users[username] + last_interactions.append(datetime.strptime(user[USER_LAST_INTERACTION], '%Y-%m-%d %H:%M:%S.%f')) + scraping_statuses.append(ScrappingStatus[user[USER_SCRAPPING_STATUS].upper()]) + + update_scraped_users(database, usernames, last_interactions, scraping_statuses) + print(COLOR_BOLD + f"[Migration] Done! You can now delete {FILENAME_SCRAPPED_USERS}" + COLOR_ENDC) + except ValueError as e: + print(COLOR_FAIL + f"[Migration] Cannot load {FILENAME_SCRAPPED_USERS}: {e}" + COLOR_ENDC) + + filtered_users_path = os.path.join(my_username, FILENAME_FILTERED_USERS) + if os.path.exists(filtered_users_path): + try: + print(f"[Migration] Loading data from {FILENAME_FILTERED_USERS}...") + with open(filtered_users_path, encoding="utf-8") as json_file: + filtered_users = json.load(json_file) + usernames = [] + filtered_at_list = [] + for username in filtered_users.keys(): + usernames.append(username) + user = filtered_users[username] + filtered_at_list.append(datetime.strptime(user[USER_FILTERED_AT], '%Y-%m-%d %H:%M:%S.%f')) + + update_filtered_users(database, usernames, filtered_at_list) + print(COLOR_BOLD + f"[Migration] Done! You can now delete {FILENAME_FILTERED_USERS}" + COLOR_ENDC) + except ValueError as e: + print(COLOR_FAIL + f"[Migration] Cannot load {FILENAME_FILTERED_USERS}: {e}" + COLOR_ENDC) + + sessions_path = os.path.join(my_username, FILENAME_SESSIONS) + if os.path.exists(sessions_path): + try: + print(f"[Migration] Loading data from {FILENAME_SESSIONS}...") + sessions_persistent_list = Sessions() + with open(sessions_path, encoding="utf-8") as json_file: + sessions = json.load(json_file) + for session in sessions: + session_state = SessionState() + session_state.id = session["id"] + session_state.args = str(session["args"]) + session_state.app_version = session.get("app_version", "") + session_state.my_username = my_username + session_state.my_followers_count = session["profile"].get("followers", 0) + session_state.my_following_count = session["profile"].get("following", 0) + session_state.totalInteractions = {None: session["total_interactions"]} + session_state.successfulInteractions = {None: session["successful_interactions"]} + session_state.totalFollowed = {None: session["total_followed"]} + session_state.totalScraped = session.get("total_scraped", {}) + session_state.totalLikes = session["total_likes"] + session_state.totalGetProfile = session.get("total_get_profile", 0) + session_state.totalUnfollowed = session.get("total_unfollowed", 0) + session_state.totalStoriesWatched = session.get("total_stories_watched", 0) + session_state.removedMassFollowers = session["removed_mass_followers"] + session_state.startTime = datetime.strptime(session["start_time"], '%Y-%m-%d %H:%M:%S.%f') + if session.get("finish_time") != "None": + session_state.finishTime = datetime.strptime(session["finish_time"], '%Y-%m-%d %H:%M:%S.%f') + + sessions_persistent_list.append(session_state) + sessions_persistent_list.persist(my_username) + print(COLOR_BOLD + f"[Migration] Done! You can now delete {FILENAME_SESSIONS}" + COLOR_ENDC) + except ValueError as e: + print(COLOR_FAIL + f"[Migration] Cannot load {FILENAME_SESSIONS}: {e}" + COLOR_ENDC) diff --git a/insomniac/navigation.py b/insomniac/navigation.py index 5aa4008..51066e0 100644 --- a/insomniac/navigation.py +++ b/insomniac/navigation.py @@ -16,7 +16,8 @@ def navigate(device, tab): _navigate_to_search(device) return - tab_bar = device.find(resourceId='com.instagram.android:id/tab_bar', className='android.widget.LinearLayout') + device.close_keyboard() + tab_bar = device.find(resourceId=f'{device.app_id}:id/tab_bar', className='android.widget.LinearLayout') button = tab_bar.child(index=tab_index) # Two clicks to reset tab content @@ -26,14 +27,14 @@ def navigate(device, tab): def search_for(device, username=None, hashtag=None, on_action=None): navigate(device, Tabs.SEARCH) - search_edit_text = device.find(resourceId='com.instagram.android:id/action_bar_search_edit_text', + search_edit_text = device.find(resourceId=f'{device.app_id}:id/action_bar_search_edit_text', className='android.widget.EditText') search_edit_text.click() if username is not None: print("Open user @" + username) search_edit_text.set_text(username) - username_view = device.find(resourceId='com.instagram.android:id/row_search_user_username', + username_view = device.find(resourceId=f'{device.app_id}:id/row_search_user_username', className='android.widget.TextView', text=username) @@ -51,7 +52,7 @@ def search_for(device, username=None, hashtag=None, on_action=None): if hashtag is not None: print("Open hashtag #" + hashtag) - tab_layout = device.find(resourceId='com.instagram.android:id/fixed_tabbar_tabs_container', + tab_layout = device.find(resourceId=f'{device.app_id}:id/fixed_tabbar_tabs_container', className='android.widget.LinearLayout') if not tab_layout.exists(): print(COLOR_FAIL + "Cannot find tabs." + COLOR_ENDC) @@ -59,7 +60,7 @@ def search_for(device, username=None, hashtag=None, on_action=None): tab_layout.child(index=2).click() search_edit_text.set_text(hashtag) - hashtag_view = device.find(resourceId='com.instagram.android:id/row_hashtag_textview_tag_name', + hashtag_view = device.find(resourceId=f'{device.app_id}:id/row_hashtag_textview_tag_name', className='android.widget.TextView', text=f"#{hashtag}") @@ -79,7 +80,7 @@ def switch_to_english(device): navigate(device, Tabs.PROFILE) print("Changing language in settings") - action_bar = device.find(resourceId='com.instagram.android:id/action_bar', + action_bar = device.find(resourceId=f'{device.app_id}:id/action_bar', className='android.widget.LinearLayout') # We wanna pick last ImageView in the action bar options_view = None @@ -90,7 +91,7 @@ def switch_to_english(device): return options_view.click() - settings_button = device.find(resourceId='com.instagram.android:id/menu_settings_row', + settings_button = device.find(resourceId=f'{device.app_id}:id/menu_settings_row', className='android.widget.TextView') settings_button.click() @@ -113,7 +114,7 @@ def switch_to_english(device): continue language_item.click() - search_edit_text = device.find(resourceId='com.instagram.android:id/search', + search_edit_text = device.find(resourceId=f'{device.app_id}:id/search', className='android.widget.EditText') if not search_edit_text.exists(): print("Opened a wrong tab, going back") @@ -122,7 +123,7 @@ def switch_to_english(device): continue search_edit_text.set_text("english") - list_view = device.find(resourceId='com.instagram.android:id/language_locale_list', + list_view = device.find(resourceId=f'{device.app_id}:id/language_locale_list', className='android.widget.ListView') english_item = list_view.child(index=0) english_item.click() @@ -134,7 +135,9 @@ def _navigate_to_search(device): # Search tab is a special case, because on some accounts there is "Reels" tab instead. If so, we have to go to the # "Home" tab and press search in the action bar. - tab_bar = device.find(resourceId='com.instagram.android:id/tab_bar', className='android.widget.LinearLayout') + device.close_keyboard() + + tab_bar = device.find(resourceId=f'{device.app_id}:id/tab_bar', className='android.widget.LinearLayout') search_in_tab_bar = tab_bar.child(descriptionMatches=SEARCH_CONTENT_DESC_REGEX) if search_in_tab_bar.exists(): # Two clicks to reset tab content @@ -145,7 +148,7 @@ def _navigate_to_search(device): print("Didn't find search in the tab bar...") navigate(device, Tabs.HOME) print("Press search in the action bar") - action_bar = device.find(resourceId='com.instagram.android:id/action_bar', className='android.widget.LinearLayout') + action_bar = device.find(resourceId=f'{device.app_id}:id/action_bar', className='android.widget.LinearLayout') search_in_action_bar = action_bar.child(descriptionMatches=SEARCH_CONTENT_DESC_REGEX) if search_in_action_bar.exists(): search_in_action_bar.click() diff --git a/insomniac/persistent_list.py b/insomniac/persistent_list.py deleted file mode 100644 index 8b4c66c..0000000 --- a/insomniac/persistent_list.py +++ /dev/null @@ -1,45 +0,0 @@ -import json -import os - - -class PersistentList(list): - filename = None - encoder = None - - def __init__(self, filename, encoder): - self.filename = filename - self.encoder = encoder - super().__init__() - - def persist(self, directory): - if directory is None: - return - - if not os.path.exists(directory): - os.makedirs(directory) - - path = os.path.join(directory, self.filename + ".json") - - if os.path.exists(path): - with open(path) as json_file: - json_array = json.load(json_file) - os.remove(path) - else: - json_array = [] - - json_array += (self.encoder.default(self.encoder, item) for item in self) - - # Remove duplicates - json_object = {} - for item in json_array: - item_id = item.get("id") - if item_id is None: - raise Exception("Items in PersistentList must have id property!") - json_object[item_id] = item - json_array = list(json_object.values()) - - with open(path, 'w') as outfile: - json.dump(json_array, - outfile, - indent=4, - sort_keys=False) diff --git a/insomniac/safely_runner.py b/insomniac/safely_runner.py index ff053f6..df407f5 100644 --- a/insomniac/safely_runner.py +++ b/insomniac/safely_runner.py @@ -1,10 +1,8 @@ -import sys from http.client import HTTPException from socket import timeout from insomniac.device_facade import DeviceFacade from insomniac.navigation import navigate, Tabs, LanguageChangedException -from insomniac.report import print_full_report from insomniac.sleeper import sleeper from insomniac.utils import * @@ -12,37 +10,21 @@ def run_safely(device_wrapper): def actual_decorator(func): def wrapper(*args, **kwargs): - from insomniac.session import sessions - session_state = sessions[-1] try: func(*args, **kwargs) - except KeyboardInterrupt: - close_instagram(device_wrapper.device_id) - print_copyright() - print_timeless(COLOR_REPORT + "-------- FINISH: " + str(datetime.now().time()) + " --------" + - COLOR_ENDC) - print_full_report(sessions) - sessions.persist(directory=session_state.my_username) - sys.exit(0) except (DeviceFacade.JsonRpcError, IndexError, HTTPException, timeout) as ex: print(COLOR_FAIL + traceback.format_exc() + COLOR_ENDC) save_crash(device_wrapper.get(), ex) print("No idea what it was. Let's try again.") # Hack for the case when IGTV was accidentally opened - close_instagram(device_wrapper.device_id) + close_instagram(device_wrapper.device_id, device_wrapper.app_id) sleeper.random_sleep() - open_instagram(device_wrapper.device_id) + open_instagram(device_wrapper.device_id, device_wrapper.app_id) sleeper.random_sleep() navigate(device_wrapper.get(), Tabs.PROFILE) except LanguageChangedException: print_timeless("") print("Language was changed. We'll have to start from the beginning.") navigate(device_wrapper.get(), Tabs.PROFILE) - except Exception as e: - save_crash(device_wrapper.get(), e) - close_instagram(device_wrapper.device_id) - print_full_report(sessions) - sessions.persist(directory=session_state.my_username) - raise e return wrapper return actual_decorator diff --git a/insomniac/session.py b/insomniac/session.py index bd487c0..cd92f32 100644 --- a/insomniac/session.py +++ b/insomniac/session.py @@ -1,24 +1,25 @@ import random import sys -from time import sleep import colorama +import insomniac.__version__ from insomniac.__version__ import __debug_mode__ from insomniac.action_get_my_profile_info import get_my_profile_info -from insomniac.actions_runners import ActionRunnersManager +from insomniac.action_runners.actions_runners_manager import ActionRunnersManager from insomniac.device import DeviceWrapper from insomniac.limits import LimitsManager +from insomniac.migration import migrate_from_json_to_sql from insomniac.params import parse_arguments, refresh_args_by_conf_file -from insomniac.persistent_list import PersistentList from insomniac.report import print_full_report -from insomniac.session_state import SessionStateEncoder, SessionState +from insomniac.session_state import SessionState +from insomniac.sessions import Sessions from insomniac.sleeper import sleeper -from insomniac.storage import STORAGE_ARGS -from insomniac.storage import Storage +from insomniac.softban_indicator import ActionBlockedError +from insomniac.storage import STORAGE_ARGS, Storage from insomniac.utils import * -sessions = PersistentList("sessions", SessionStateEncoder) +sessions = Sessions() class InsomniacSession(object): @@ -32,6 +33,12 @@ class InsomniacSession(object): "help": 'device identifier. Should be used only when multiple devices are connected at once', "metavar": '2443de990e017ece' }, + "wait_for_device": { + 'help': 'keep waiting for ADB-device to be ready for connection (if no device-id is provided using ' + '--device flag, will wait for any available device)', + 'action': 'store_true', + "default": False + }, "no_speed_check": { 'help': 'skip internet speed check at start', 'action': 'store_true' @@ -40,6 +47,22 @@ class InsomniacSession(object): 'help': 'add this flag to use an old version of uiautomator. Use it only if you experience ' 'problems with the default version', 'action': 'store_true' + }, + "app_id": { + "help": 'apk package identifier. Should be used only if you are using cloned-app. ' + 'Using \'com.instagram.android\' by default', + "metavar": 'com.instagram.android', + "default": 'com.instagram.android' + }, + "dont_indicate_softban": { + "help": "by default, Insomniac tried to indicate if there is a softban on your acoount. set this flag in " + "order to ignore those soft-ban indicators", + 'action': 'store_true', + "default": False + }, + "debug": { + 'help': 'add this flag to insomniac in debug mode (more verbose logs)', + 'action': 'store_true' } } @@ -70,17 +93,23 @@ def set_session_args(self, args): if args.repeat is not None: self.repeat = get_value(args.repeat, "Sleep time (min) before repeat: {}", 180) + if args.debug is not None: + insomniac.__version__.__debug_mode__ = True + + if args.dont_indicate_softban: + insomniac.softban_indicator.should_indicate_softban = False + def parse_args_and_get_device_wrapper(self): ok, args = parse_arguments(self.get_session_args()) if not ok: return None, None, None - device_wrapper = DeviceWrapper(args.device, args.old) + device_wrapper = DeviceWrapper(args.device, args.old, args.wait_for_device, args.app_id) device = device_wrapper.get() if device is None: return None, None, None - app_version = get_instagram_version(args.device) + app_version = get_instagram_version(args.device, args.app_id) print("Instagram version: " + app_version) @@ -92,8 +121,11 @@ def start_session(self, args, device_wrapper, app_version): self.session_state.app_version = app_version self.sessions.append(self.session_state) + device_wrapper.get().wake_up() + print_timeless(COLOR_REPORT + "\n-------- START: " + str(self.session_state.startTime) + " --------" + COLOR_ENDC) - open_instagram(args.device) + + open_instagram(args.device, args.app_id) sleeper.random_sleep() self.session_state.my_username, \ self.session_state.my_followers_count, \ @@ -102,22 +134,23 @@ def start_session(self, args, device_wrapper, app_version): return self.session_state def end_session(self, device_wrapper): - close_instagram(device_wrapper.device_id) + close_instagram(device_wrapper.device_id, device_wrapper.app_id) print_copyright() self.session_state.finishTime = datetime.now() print_timeless(COLOR_REPORT + "-------- FINISH: " + str(self.session_state.finishTime) + " --------" + COLOR_ENDC) - def repeat_session(self, args): print_full_report(self.sessions) print_timeless("") - self.sessions.persist(directory=self.session_state.my_username) + self.sessions.persist(self.session_state.my_username) + + def repeat_session(self, args): print("Sleep for {} minutes".format(self.repeat)) try: sleep(60 * self.repeat) refresh_args_by_conf_file(args) except KeyboardInterrupt: print_full_report(self.sessions) - self.sessions.persist(directory=self.session_state.my_username) + self.sessions.persist(self.session_state.my_username) sys.exit(0) def on_action_callback(self, action): @@ -147,6 +180,7 @@ def run(self): try: self.start_session(args, device_wrapper, app_version) + migrate_from_json_to_sql(self.session_state.my_username) self.storage = Storage(self.session_state.my_username, args) action_runner.run(device_wrapper, @@ -154,6 +188,9 @@ def run(self): self.session_state, self.on_action_callback, self.limits_mgr.is_limit_reached_for_action) + except KeyboardInterrupt: + self.end_session(device_wrapper) + return except ActionBlockedError as ex: print_timeless("") print_timeless(COLOR_FAIL + str(ex) + COLOR_ENDC) @@ -171,6 +208,3 @@ def run(self): self.repeat_session(args) else: break - - print_full_report(self.sessions) - self.sessions.persist(directory=self.session_state.my_username) diff --git a/insomniac/session_state.py b/insomniac/session_state.py index f9bead8..7b1a5f9 100644 --- a/insomniac/session_state.py +++ b/insomniac/session_state.py @@ -84,28 +84,3 @@ def add_action(self, action): def is_finished(self): return self.finishTime is not None - - -class SessionStateEncoder(JSONEncoder): - - def default(self, session_state: SessionState): - return { - "id": session_state.id, - "app_version": session_state.app_version, - "total_interactions": sum(session_state.totalInteractions.values()), - "successful_interactions": sum(session_state.successfulInteractions.values()), - "total_followed": sum(session_state.totalFollowed.values()), - "total_likes": session_state.totalLikes, - "total_unfollowed": session_state.totalUnfollowed, - "total_stories_watched": session_state.totalStoriesWatched, - "total_get_profile": session_state.totalGetProfile, - "total_scraped": session_state.totalScraped, - "removed_mass_followers": session_state.removedMassFollowers, - "start_time": str(session_state.startTime), - "finish_time": str(session_state.finishTime), - "args": session_state.args, - "profile": { - "followers": str(session_state.my_followers_count), - "following": str(session_state.my_following_count) - } - } diff --git a/insomniac/sessions.py b/insomniac/sessions.py new file mode 100644 index 0000000..8bc665b --- /dev/null +++ b/insomniac/sessions.py @@ -0,0 +1,14 @@ +from insomniac.database_engine import get_database, add_sessions + +FILENAME_SESSIONS = "sessions.json" # deprecated + + +class Sessions(list): + + def persist(self, username): + """ + Save the sessions list to the database and then clear this list. + """ + database = get_database(username) + add_sessions(database, self) + self.clear() diff --git a/insomniac/sleeper.py b/insomniac/sleeper.py index 3965d9e..5250b4a 100644 --- a/insomniac/sleeper.py +++ b/insomniac/sleeper.py @@ -1,5 +1,4 @@ from random import uniform -from time import sleep import speedtest diff --git a/insomniac/softban_indicator.py b/insomniac/softban_indicator.py new file mode 100644 index 0000000..70f1ef4 --- /dev/null +++ b/insomniac/softban_indicator.py @@ -0,0 +1,88 @@ +from enum import unique, Enum + +from insomniac.utils import * +from insomniac.views import ProfileView, FollowersFollowingListView, InstagramView + +EMPTY_LIST_TRESHOLD = 5 +EMPTY_PROFILE_TRESHOLD = 5 +ACTION_BLOCKED_DIALOG_TRESHOLD = 1 + + +should_indicate_softban = True + + +def check_softban_feature_flag(func): + def wrap(*args, **kwargs): + is_indication_exists = False + if should_indicate_softban: + is_indication_exists = func(*args, **kwargs) + return is_indication_exists + return wrap + + +class ActionBlockedError(Exception): + pass + + +@unique +class IndicationType(Enum): + EMPTY_LISTS = 'empty-lists' + EMPTY_PROFILES = 'empty-profiles' + ACTION_BLOCKED_DIALOGS = 'action-block-dialogs' + + +class SoftBanIndicator: + def __init__(self): + self.indications = { + IndicationType.EMPTY_LISTS: {"curr": 0, "treshold": EMPTY_LIST_TRESHOLD}, + IndicationType.EMPTY_PROFILES: {"curr": 0, "treshold": EMPTY_PROFILE_TRESHOLD}, + IndicationType.ACTION_BLOCKED_DIALOGS: {"curr": 0, "treshold": ACTION_BLOCKED_DIALOG_TRESHOLD}, + } + + def indicate_block(self): + for indicator, stats in self.indications.items(): + if stats['curr'] >= stats['treshold']: + block_indication_message = f"Instagram-block indicated after finding {stats['curr']} {indicator.value}. " \ + f"Seems that action is blocked. Consider reinstalling Instagram app and " \ + f"be more careful with limits!" + raise ActionBlockedError(block_indication_message) + + @check_softban_feature_flag + def detect_empty_list(self, device): + list_view = FollowersFollowingListView(device) + is_list_empty_from_profiles = list_view.is_list_empty() + if is_list_empty_from_profiles: + print(COLOR_FAIL + "List of followers seems to be empty. " + "Counting that as a soft-ban indicator!." + COLOR_ENDC) + self.indications[IndicationType.EMPTY_LISTS]["curr"] += 1 + self.indicate_block() + + return is_list_empty_from_profiles + + @check_softban_feature_flag + def detect_empty_profile(self, device): + profile_view = ProfileView(device) + followers_count = profile_view.get_followers_count(should_parse=False) + is_profile_empty = followers_count is None + if is_profile_empty: + print(COLOR_FAIL + "A profile-page seems to be empty. " + "Counting that as a soft-ban indicator!." + COLOR_ENDC) + self.indications[IndicationType.EMPTY_PROFILES]["curr"] += 1 + self.indicate_block() + + return is_profile_empty + + @check_softban_feature_flag + def detect_action_blocked_dialog(self, device): + curr_view = InstagramView(device) + is_blocked = curr_view.is_block_dialog_present() + if is_blocked: + print(COLOR_FAIL + "Probably block dialog is shown. " + "Counting that as a soft-ban indicator!." + COLOR_ENDC) + self.indications[IndicationType.ACTION_BLOCKED_DIALOGS]["curr"] += 1 + self.indicate_block() + + return is_blocked + + +softban_indicator = SoftBanIndicator() diff --git a/insomniac/storage.py b/insomniac/storage.py index 053c019..61469cc 100644 --- a/insomniac/storage.py +++ b/insomniac/storage.py @@ -1,12 +1,14 @@ from datetime import timedelta from enum import Enum, unique +from insomniac.database_engine import * from insomniac.utils import * -FILENAME_INTERACTED_USERS = "interacted_users.json" -FILENAME_SCRAPPED_USERS = "scrapped_users.json" -FILENAME_FILTERED_USERS = "filtered_users.json" +FILENAME_INTERACTED_USERS = "interacted_users.json" # deprecated +FILENAME_SCRAPPED_USERS = "scrapped_users.json" # deprecated +FILENAME_FILTERED_USERS = "filtered_users.json" # deprecated USER_LAST_INTERACTION = "last_interaction" +USER_INTERACTIONS_COUNT = "interactions_count" USER_FILTERED_AT = "filtered_at" USER_FOLLOWING_STATUS = "following_status" USER_SCRAPPING_STATUS = "scrapping_status" @@ -14,6 +16,7 @@ FILENAME_WHITELIST = "whitelist.txt" FILENAME_BLACKLIST = "blacklist.txt" FILENAME_TARGETS = "targets.txt" +FILENAME_LOADED_TARGETS = "targets_loaded.txt" FILENAME_FOLLOWERS = "followers.txt" @@ -27,17 +30,16 @@ class Storage: + database = None + scrapping_database = None reinteract_after = None - interacted_users_path = None - interacted_users = {} - scrapped_users = {} - filtered_users = {} whitelist = [] blacklist = [] - targets = [] account_followers = {} def __init__(self, my_username, args): + self.database = get_database(my_username) + if args.reinteract_after is not None: self.reinteract_after = get_value(args.reinteract_after, "Re-interact after {} hours", 168) @@ -50,21 +52,7 @@ def __init__(self, my_username, args): if not os.path.exists(my_username): os.makedirs(my_username) - self.interacted_users_path = os.path.join(my_username, FILENAME_INTERACTED_USERS) - if os.path.exists(self.interacted_users_path): - with open(self.interacted_users_path, encoding="utf-8") as json_file: - self.interacted_users = json.load(json_file) - - self.scrapped_users_path = os.path.join(my_username, FILENAME_SCRAPPED_USERS) - if os.path.exists(self.scrapped_users_path): - with open(self.scrapped_users_path, encoding="utf-8") as json_file: - self.scrapped_users = json.load(json_file) - - self.filtered_users_path = os.path.join(my_username, FILENAME_FILTERED_USERS) - if os.path.exists(self.filtered_users_path): - with open(self.filtered_users_path, encoding="utf-8") as json_file: - self.filtered_users = json.load(json_file) - + # Whitelist and Blacklist whitelist_path = os.path.join(my_username, FILENAME_WHITELIST) if os.path.exists(whitelist_path): with open(whitelist_path, encoding="utf-8") as file: @@ -73,94 +61,103 @@ def __init__(self, my_username, args): if os.path.exists(blacklist_path): with open(blacklist_path, encoding="utf-8") as file: self.blacklist = [line.rstrip() for line in file] - self.targets_path = os.path.join(my_username, FILENAME_TARGETS) - if os.path.exists(self.targets_path): - with open(self.targets_path, encoding="utf-8") as file: - self.targets = [line.rstrip() for line in file] + # Read targets from targets.txt + targets_path = os.path.join(my_username, FILENAME_TARGETS) + if os.path.exists(targets_path): + with open(targets_path, 'r+', encoding="utf-8") as file_targets: + targets = [line.rstrip() for line in file_targets] + # Add targets to the database + add_targets(self.database, targets, Provider.TARGETS_LIST) + targets_loaded_path = os.path.join(my_username, FILENAME_LOADED_TARGETS) + # Add targets to targets_loaded.txt + with open(targets_loaded_path, 'a+', encoding="utf-8") as file_loaded_targets: + for target in targets: + file_loaded_targets.write(f"{target}\n") + # Clear targets.txt + file_targets.truncate(0) + + # Scraping if scrape_for_account is not None: - if not os.path.isdir(scrape_for_account): - os.makedirs(scrape_for_account) - self.targets_path = os.path.join(scrape_for_account, FILENAME_TARGETS) - if os.path.exists(self.targets_path): - with open(self.targets_path, encoding="utf-8") as file: - self.targets = [line.rstrip() for line in file] + self.scrapping_database = get_database(scrape_for_account) + # TODO: implement 'dump-followers' feature or remove these lines self.followers_path = os.path.join(scrape_for_account, FILENAME_FOLLOWERS) if os.path.exists(self.followers_path): with open(self.followers_path, encoding="utf-8") as json_file: self.account_followers = json.load(json_file) def check_user_was_interacted(self, username): + user = get_interacted_user(self.database, username) if self.reinteract_after is None: - return not self.interacted_users.get(username) is None + return user is not None and user[USER_INTERACTIONS_COUNT] > 0 return self.check_user_was_interacted_recently(username, hours=self.reinteract_after) def check_user_was_interacted_recently(self, username, hours=72): - user = self.interacted_users.get(username) - if user is None: + user = get_interacted_user(self.database, username) + if user is None or user[USER_INTERACTIONS_COUNT] == 0: return False - last_interaction = datetime.strptime(user[USER_LAST_INTERACTION], '%Y-%m-%d %H:%M:%S.%f') + last_interaction = datetime.strptime(user[USER_LAST_INTERACTION], '%Y-%m-%d %H:%M:%S') return datetime.now() - last_interaction <= timedelta(hours=hours) def check_user_was_scrapped(self, username): - return not self.scrapped_users.get(username) is None + user = get_scraped_user(self.database, username) + return user is not None def check_user_was_filtered(self, username): - return not self.filtered_users.get(username) is None + user = get_filtered_user(self.database, username) + return user is not None def get_following_status(self, username): - user = self.interacted_users.get(username) - return user is None and FollowingStatus.NONE or FollowingStatus[user[USER_FOLLOWING_STATUS].upper()] - - def add_interacted_user(self, username, followed=False, unfollowed=False): - user = self.interacted_users.get(username, {}) - user[USER_LAST_INTERACTION] = str(datetime.now()) - + user = get_interacted_user(self.database, username) + return FollowingStatus.NONE if user is None else FollowingStatus[user[USER_FOLLOWING_STATUS].upper()] + + def add_interacted_user(self, + username, + last_interaction=datetime.now(), + followed=False, + unfollowed=False, + source=None, + interaction_type=None, + provider=Provider.UNKNOWN): + following_status = FollowingStatus.NONE if followed: - user[USER_FOLLOWING_STATUS] = FollowingStatus.FOLLOWED.name.lower() - elif unfollowed: - user[USER_FOLLOWING_STATUS] = FollowingStatus.UNFOLLOWED.name.lower() - else: - user[USER_FOLLOWING_STATUS] = FollowingStatus.NONE.name.lower() - - self.interacted_users[username] = user - self._update_file() - - def add_scrapped_user(self, username, success=False): - user = self.scrapped_users.get(username, {}) - user[USER_LAST_INTERACTION] = str(datetime.now()) - - if success: - user[USER_SCRAPPING_STATUS] = ScrappingStatus.SCRAPED.name.lower() - else: - user[USER_SCRAPPING_STATUS] = ScrappingStatus.NOT_SCRAPED.name.lower() - - self.scrapped_users[username] = user - self._update_scrapped_file() - - def add_filtered_user(self, username): - user = {USER_FILTERED_AT: str(datetime.now())} - - self.filtered_users[username] = user - self._update_filters_file() - - def add_target_user(self, username): - if username in self.targets: - return - - if self.targets_path is not None: - with open(self.targets_path, 'a', encoding="utf-8") as outfile: - outfile.write(username + '\n') - - def read_targets(self): - if os.path.exists(self.targets_path): - with open(self.targets_path, encoding="utf-8") as file: - self.targets = [line.rstrip() for line in file] + following_status = FollowingStatus.FOLLOWED + if unfollowed: + following_status = FollowingStatus.UNFOLLOWED + update_interacted_users(self.database, + (username,), + (last_interaction,), + (following_status,), + (source,), + (interaction_type,), + (provider,)) + + def add_scrapped_user(self, username, last_interaction=datetime.now(), success=False): + scraping_status = ScrappingStatus.SCRAPED if success else ScrappingStatus.NOT_SCRAPED + update_scraped_users(self.database, (username,), (last_interaction,), (scraping_status,)) + + def add_filtered_user(self, username, filtered_at=datetime.now()): + update_filtered_users(self.database, (username,), (filtered_at,)) + + def add_target(self, username, source, interaction_type): + """ + Add a target to the scrapping_database (it's a database of original account for which we are scrapping). + """ + add_targets(self.scrapping_database, (username,), Provider.SCRAPING, source, interaction_type) + + def get_target(self): + """ + Read and remove a target from the targets table. + + :return: a target or None if table is empty. + """ + return get_target(self.database) def save_followers_for_today(self, followers_list, override=False): + # TODO: implement 'dump-followers' feature or remove this function curr_day = str(datetime.date(datetime.now())) if curr_day in self.account_followers: if not override: @@ -178,31 +175,6 @@ def is_user_in_whitelist(self, username): def is_user_in_blacklist(self, username): return username in self.blacklist - def _get_last_day_interactions_count(self): - count = 0 - users_list = list(self.interacted_users.values()) - for user in users_list: - last_interaction = datetime.strptime(user[USER_LAST_INTERACTION], '%Y-%m-%d %H:%M:%S.%f') - is_last_day = datetime.now() - last_interaction <= timedelta(days=1) - if is_last_day: - count += 1 - return count - - def _update_file(self): - if self.interacted_users_path is not None: - with open(self.interacted_users_path, 'w', encoding="utf-8") as outfile: - json.dump(self.interacted_users, outfile, indent=4, sort_keys=False) - - def _update_scrapped_file(self): - if self.scrapped_users_path is not None: - with open(self.scrapped_users_path, 'w', encoding="utf-8") as outfile: - json.dump(self.scrapped_users, outfile, indent=4, sort_keys=False) - - def _update_filters_file(self): - if self.filtered_users_path is not None: - with open(self.filtered_users_path, 'w', encoding="utf-8") as outfile: - json.dump(self.filtered_users, outfile, indent=4, sort_keys=False) - @unique class FollowingStatus(Enum): diff --git a/insomniac/utils.py b/insomniac/utils.py index e1b15fa..f0a6b1e 100644 --- a/insomniac/utils.py +++ b/insomniac/utils.py @@ -1,15 +1,21 @@ import json import os +import sys +from os import path import re import shutil import ssl +import subprocess +import traceback import urllib.request from datetime import datetime from random import randint +from subprocess import PIPE +from time import sleep from urllib.error import URLError -import traceback +from urllib.parse import urlparse -from insomniac.__version__ import __version__ +from insomniac.__version__ import __version__, __debug_mode__ COLOR_HEADER = '\033[95m' COLOR_OKBLUE = '\033[94m' @@ -31,9 +37,9 @@ def print_version(): print_timeless("") -def get_instagram_version(device_id): +def get_instagram_version(device_id, app_id): stream = os.popen("adb" + ("" if device_id is None else " -s " + device_id) + - " shell dumpsys package com.instagram.android") + f" shell dumpsys package {app_id}") output = stream.read() version_match = re.findall('versionName=(\\S+)', output) if len(version_match) == 1: @@ -44,11 +50,39 @@ def get_instagram_version(device_id): return version -def check_adb_connection(is_device_id_provided): - stream = os.popen('adb devices') - output = stream.read() - devices_count = len(re.findall('device\n', output)) - stream.close() +def check_adb_connection(device_id, wait_for_device): + is_device_id_provided = device_id is not None + + while True: + print_timeless("Looking for ADB devices...") + stream = os.popen('adb devices') + output = stream.read() + devices_count = len(re.findall('device\n', output)) + stream.close() + + if not wait_for_device: + break + + if devices_count == 0: + print_timeless(COLOR_HEADER + "Couldn't find any ADB-device available, sleeping a bit and trying again..." + COLOR_ENDC) + sleep(10) + continue + + if not is_device_id_provided: + break + + found_device = False + for line in output.split('\n'): + if device_id in line and 'device' in line: + found_device = True + break + + if found_device: + break + + print_timeless(COLOR_HEADER + "Couldn't find ADB-device " + device_id + " available, sleeping a bit and trying again..." + COLOR_ENDC) + sleep(10) + continue is_ok = True message = "That's ok." @@ -64,72 +98,90 @@ def check_adb_connection(is_device_id_provided): return is_ok -def open_instagram(device_id): +def open_instagram(device_id, app_id): print("Open Instagram app") - os.popen("adb" + ("" if device_id is None else " -s " + device_id) + - " shell am start -n com.instagram.android/com.instagram.mainactivity.MainActivity").close() + cmd = ("adb" + ("" if device_id is None else " -s " + device_id) + + f" shell am start -n {app_id}/com.instagram.mainactivity.MainActivity") + + cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") + err = cmd_res.stderr.strip() + if err: + print(COLOR_FAIL + err + COLOR_ENDC) + + +def open_instagram_with_url(device_id, url): + print("Open Instagram app with url: {}".format(url)) + cmd = ("adb" + ("" if device_id is None else " -s " + device_id) + + " shell am start -a android.intent.action.VIEW -d {}".format(url)) + cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") + err = cmd_res.stderr.strip() + + if err: + print(COLOR_FAIL + err + COLOR_ENDC) + return False + + return True -def close_instagram(device_id): +def close_instagram(device_id, app_id): print("Close Instagram app") os.popen("adb" + ("" if device_id is None else " -s " + device_id) + - " shell am force-stop com.instagram.android").close() + f" shell am force-stop {app_id}").close() def save_crash(device, ex=None): global print_log - directory_name = "Crash-" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") - try: - os.makedirs(os.path.join("crashes", directory_name), exist_ok=False) - except OSError: - print(COLOR_FAIL + "Directory " + directory_name + " already exists." + COLOR_ENDC) - return - - screenshot_format = ".png" if device.is_old() else ".jpg" - try: - device.screenshot(os.path.join("crashes", directory_name, "screenshot" + screenshot_format)) - except RuntimeError: - print(COLOR_FAIL + "Cannot save screenshot." + COLOR_ENDC) + device.wake_up() - view_hierarchy_format = ".xml" try: - device.dump_hierarchy(os.path.join("crashes", directory_name, "view_hierarchy" + view_hierarchy_format)) - except RuntimeError: - print(COLOR_FAIL + "Cannot save view hierarchy." + COLOR_ENDC) + directory_name = "Crash-" + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + try: + os.makedirs(os.path.join("crashes", directory_name), exist_ok=False) + except OSError: + print(COLOR_FAIL + "Directory " + directory_name + " already exists." + COLOR_ENDC) + return - with open(os.path.join("crashes", directory_name, "logs.txt"), 'w', encoding="utf-8") as outfile: - outfile.write(print_log) + screenshot_format = ".png" if device.is_old() else ".jpg" + try: + device.screenshot(os.path.join("crashes", directory_name, "screenshot" + screenshot_format)) + except RuntimeError: + print(COLOR_FAIL + "Cannot save screenshot." + COLOR_ENDC) - if ex: - outfile.write("\n") - outfile.write(''.join(traceback.format_exception(etype=type(ex), value=ex, tb=ex.__traceback__))) + view_hierarchy_format = ".xml" + try: + device.dump_hierarchy(os.path.join("crashes", directory_name, "view_hierarchy" + view_hierarchy_format)) + except RuntimeError: + print(COLOR_FAIL + "Cannot save view hierarchy." + COLOR_ENDC) - shutil.make_archive(os.path.join("crashes", directory_name), 'zip', os.path.join("crashes", directory_name)) - shutil.rmtree(os.path.join("crashes", directory_name)) + with open(os.path.join("crashes", directory_name, "logs.txt"), 'w', encoding="utf-8") as outfile: + outfile.write(print_log) - print(COLOR_OKGREEN + "Crash saved as \"crashes/" + directory_name + ".zip\"." + COLOR_ENDC) - print(COLOR_OKGREEN + "Please attach this file if you gonna report the crash at" + COLOR_ENDC) - print(COLOR_OKGREEN + "https://github.com/alexal1/Insomniac/issues\n" + COLOR_ENDC) + if ex: + outfile.write("\n") + outfile.write(''.join(traceback.format_exception(etype=type(ex), value=ex, tb=ex.__traceback__))) + shutil.make_archive(os.path.join("crashes", directory_name), 'zip', os.path.join("crashes", directory_name)) + shutil.rmtree(os.path.join("crashes", directory_name)) -def detect_block(device): - block_dialog = device.find(resourceId='com.instagram.android:id/dialog_root_view', - className='android.widget.FrameLayout') - is_blocked = block_dialog.exists() - if is_blocked: - print(COLOR_FAIL + "Probably block dialog is shown." + COLOR_ENDC) - raise ActionBlockedError("Seems that action is blocked. Consider reinstalling Instagram app and be more careful" - " with limits!") + print(COLOR_OKGREEN + "Crash saved as \"crashes/" + directory_name + ".zip\"." + COLOR_ENDC) + print(COLOR_OKGREEN + "Please attach this file if you gonna report the crash at" + COLOR_ENDC) + print(COLOR_OKGREEN + "https://github.com/alexal1/Insomniac/issues\n" + COLOR_ENDC) + except Exception as e: + print(COLOR_FAIL + f"Could not save crash after an error. Crash-save-error: {str(e)}" + COLOR_ENDC) + print(COLOR_FAIL + traceback.format_exc() + COLOR_ENDC) def print_copyright(): - print_timeless("\nIf you like this script, please " + COLOR_BOLD + "give us a star" + COLOR_ENDC + ":") + print_timeless("\nIf you like this bot, please " + COLOR_BOLD + "give us a star" + COLOR_ENDC + ":") print_timeless(COLOR_BOLD + "https://github.com/alexal1/Insomniac\n" + COLOR_ENDC) -def _print_with_time_decorator(standard_print, print_time): +def _print_with_time_decorator(standard_print, print_time, debug): def wrapper(*args, **kwargs): + if debug and not __debug_mode__: + return + global print_log if print_time: time = datetime.now().strftime("%m/%d %H:%M:%S") @@ -225,10 +277,43 @@ def get_count_of_nums_in_str(string): return count -print_log = "" -print_timeless = _print_with_time_decorator(print, False) -print = _print_with_time_decorator(print, True) +def validate_url(x): + try: + result = urlparse(x) + return all([result.scheme, result.netloc, result.path]) + except Exception as e: + print(COLOR_FAIL + f"Error validating URL {x}. Error: {e}" + COLOR_ENDC) + return False -class ActionBlockedError(Exception): - pass +def _get_log_file_name(): + logs_directory_name = "logs" + os.makedirs(os.path.join(logs_directory_name), exist_ok=True) + curr_time = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + log_name = "insomniac_log-" + curr_time + ".log" + log_path = os.path.join(logs_directory_name, log_name) + return log_path + + +class Logger(object): + def __init__(self): + self.terminal = sys.stdout + self.log = open(_get_log_file_name(), "a", encoding="utf-8") + + def write(self, message): + self.terminal.write(message) + self.log.write(message) + self.log.flush() + + def flush(self): + pass + + def fileno(self): + return self.terminal.fileno() + + +sys.stdout = Logger() +print_log = "" +print_timeless = _print_with_time_decorator(print, False, False) +print_debug = _print_with_time_decorator(print, True, True) +print = _print_with_time_decorator(print, True, False) diff --git a/insomniac/views.py b/insomniac/views.py new file mode 100644 index 0000000..8f3015f --- /dev/null +++ b/insomniac/views.py @@ -0,0 +1,1148 @@ +import datetime +from enum import Enum + +from insomniac.actions_types import GetProfileAction +from insomniac.device_facade import DeviceFacade +from insomniac.scroll_end_detector import ScrollEndDetector +from insomniac.sleeper import sleeper +from insomniac.utils import * + + +def case_insensitive_re(str_list): + if isinstance(str_list, str): + strings = str_list + else: + strings = "|".join(str_list) + re_str = f"(?i)({strings})" + return re_str + + +class TabBarTabs(Enum): + HOME = 0 + SEARCH = 1 + REELS = 2 + ORDERS = 3 + ACTIVITY = 4 + PROFILE = 5 + + +class SearchTabs(Enum): + TOP = 0 + ACCOUNTS = 1 + TAGS = 2 + PLACES = 3 + + +class ProfileTabs(Enum): + POSTS = 0 + IGTV = 1 + REELS = 2 + EFFECTS = 3 + PHOTOS_OF_YOU = 4 + + +class InstagramView: + def __init__(self, device: DeviceFacade): + self.device = device + + def is_block_dialog_present(self): + block_dialog = self.device.find(resourceId=f'{self.device.app_id}:id/dialog_root_view', + className='android.widget.FrameLayout') + return block_dialog.exists() + + +class TabBarView(InstagramView): + HOME_CONTENT_DESC = "Home" + SEARCH_CONTENT_DESC = "[Ss]earch and [Ee]xplore" + REELS_CONTENT_DESC = "Reels" + ORDERS_CONTENT_DESC = "Orders" + ACTIVITY_CONTENT_DESC = "Activity" + PROFILE_CONTENT_DESC = "Profile" + + def _get_tab_bar(self): + self.device.close_keyboard() + + tab_bar = self.device.find( + resourceIdMatches=case_insensitive_re(f"{self.device.app_id}:id/tab_bar"), + className="android.widget.LinearLayout", + ) + return tab_bar + + def navigate_to_home(self): + self._navigate_to(TabBarTabs.HOME) + return HomeView(self.device) + + def navigate_to_search(self): + self._navigate_to(TabBarTabs.SEARCH) + return SearchView(self.device) + + def navigate_to_reels(self): + self._navigate_to(TabBarTabs.REELS) + + def navigate_to_orders(self): + self._navigate_to(TabBarTabs.ORDERS) + + def navigate_to_activity(self): + self._navigate_to(TabBarTabs.ACTIVITY) + + def navigate_to_profile(self): + self._navigate_to(TabBarTabs.PROFILE) + return ProfileView(self.device, is_own_profile=True) + + def _navigate_to(self, tab: TabBarTabs): + tab_name = tab.name + print_debug(f"Navigate to {tab_name}") + button = None + tab_bar_view = self._get_tab_bar() + if tab == TabBarTabs.HOME: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.HOME_CONTENT_DESC) + ) + elif tab == TabBarTabs.SEARCH: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.SEARCH_CONTENT_DESC) + ) + if not button.exists(): + # Some accounts display the search btn only in Home -> action bar + print_debug("Didn't find search in the tab bar...") + home_view = self.navigate_to_home() + home_view.navigate_to_search() + return + elif tab == TabBarTabs.REELS: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.REELS_CONTENT_DESC) + ) + elif tab == TabBarTabs.ORDERS: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.ORDERS_CONTENT_DESC) + ) + elif tab == TabBarTabs.ACTIVITY: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.ACTIVITY_CONTENT_DESC) + ) + elif tab == TabBarTabs.PROFILE: + button = tab_bar_view.child( + descriptionMatches=case_insensitive_re(TabBarView.PROFILE_CONTENT_DESC) + ) + + if button.exists(): + # Two clicks to reset tab content + button.click() + button.click() + + return + + print(COLOR_FAIL + f"Didn't find tab {tab_name} in the tab bar... " + f"Maybe English language is not set!?" + COLOR_ENDC) + + raise LanguageNotEnglishException() + + +class ActionBarView(InstagramView): + action_bar_bottom = None + tab_bar_top = None + + def __init__(self, device: DeviceFacade): + super().__init__(device) + self.action_bar = self._get_action_bar() + + def _get_action_bar(self): + tab_bar = self.device.find( + resourceIdMatches=case_insensitive_re(f"{self.device.app_id}:id/action_bar_container"), + className="android.widget.FrameLayout") + return tab_bar + + @staticmethod + def update_interaction_rect(device): + action_bar = device.find(resourceId=f'{device.app_id}:id/action_bar_container', + className='android.widget.FrameLayout') + if action_bar.exists(): + ActionBarView.action_bar_bottom = action_bar.get_bounds()['bottom'] + + device.close_keyboard() + + tab_bar = device.find(resourceId=f'{device.app_id}:id/tab_bar', + className='android.widget.LinearLayout') + if tab_bar.exists(): + ActionBarView.tab_bar_top = tab_bar.get_bounds()['top'] + + @staticmethod + def is_in_interaction_rect(view): + if ActionBarView.action_bar_bottom is None or ActionBarView.tab_bar_top is None: + print(COLOR_FAIL + "Interaction rect is not specified." + COLOR_ENDC) + return False + + view_top = view.get_bounds()['top'] + view_bottom = view.get_bounds()['bottom'] + return ActionBarView.action_bar_bottom <= view_top and view_bottom <= ActionBarView.tab_bar_top + + +class HomeView(ActionBarView): + def __init__(self, device: DeviceFacade): + super().__init__(device) + + def navigate_to_search(self): + print_debug("Navigate to Search") + search_btn = self.action_bar.child( + descriptionMatches=case_insensitive_re(TabBarView.SEARCH_CONTENT_DESC) + ) + search_btn.click() + + return SearchView(self.device) + + +class HashTagView(InstagramView): + def _get_recycler_view(self): + CLASSNAME = "(androidx.recyclerview.widget.RecyclerView|android.view.View)" + + return self.device.find(classNameMatches=CLASSNAME) + + def _get_first_image_view(self, recycler): + return recycler.child( + className="android.widget.ImageView", + resourceIdMatches=f"{self.device.app_id}:id/image_button", + ) + + def _get_recent_tab(self): + return self.device.find( + className="android.widget.TextView", + text="Recent", + ) + + +class SearchView(InstagramView): + def _get_search_edit_text(self): + return self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/action_bar_search_edit_text" + ), + className="android.widget.EditText", + ) + + def _get_username_row(self, username): + return self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/row_search_user_username" + ), + className="android.widget.TextView", + text=username, + ) + + def _get_hashtag_row(self, hashtag): + return self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/row_hashtag_textview_tag_name" + ), + className="android.widget.TextView", + text=f"#{hashtag}", + ) + + def _get_tab_text_view(self, tab: SearchTabs): + tab_layout = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/fixed_tabbar_tabs_container" + ), + className="android.widget.LinearLayout", + ) + + tab_text_view = tab_layout.child( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/tab_button_name_text" + ), + className="android.widget.TextView", + textMatches=case_insensitive_re(tab.name), + ) + return tab_text_view + + def _search_tab_with_text_placeholder(self, tab: SearchTabs): + tab_layout = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/fixed_tabbar_tabs_container" + ), + className="android.widget.LinearLayout", + ) + search_edit_text = self._get_search_edit_text() + + fixed_text = "Search {}".format(tab.name if tab.name != "TAGS" else "hashtags") + print_debug( + "Going to check if the search bar have as placeholder: {}".format( + fixed_text + ) + ) + + for item in tab_layout.child( + resourceId=f"{self.device.app_id}:id/tab_button_fallback_icon", + className="android.widget.ImageView", + ): + item.click() + # random_sleep() + + # Little trick for force-update the ui and placeholder text + search_edit_text.click() + self.device.back() + + if self.device.find( + className="android.widget.TextView", + textMatches=case_insensitive_re(fixed_text), + ).exists(): + return item + return None + + def navigate_to_username(self, username, on_action): + print_debug("Navigate to profile @" + username) + search_edit_text = self._get_search_edit_text() + search_edit_text.click() + + search_edit_text.set_text(username) + username_view = self._get_username_row(username) + + if not username_view.exists(): + print(COLOR_FAIL + "Cannot find user @" + username + ", abort." + COLOR_ENDC) + return None + + username_view.click() + on_action(GetProfileAction(user=username)) + + return ProfileView(self.device, is_own_profile=False) + + def navigate_to_hashtag(self, hashtag): + print(f"Navigate to hashtag {hashtag}") + search_edit_text = self._get_search_edit_text() + search_edit_text.click() + + sleeper.random_sleep() + hashtag_tab = self._get_tab_text_view(SearchTabs.TAGS) + if not hashtag_tab.exists(): + print_debug( + "Cannot find tab: Tags. Going to attempt to search for placeholder in all tabs" + ) + hashtag_tab = self._search_tab_with_text_placeholder(SearchTabs.TAGS) + if hashtag_tab is None: + print(COLOR_FAIL + "Cannot find tab: Tags." + COLOR_ENDC) + save_crash(self.device) + return None + + hashtag_tab.click() + sleeper.random_sleep() + DeviceFacade.back(self.device) + sleeper.random_sleep() + # check if that hashtag already exists in the recent search list -> act as human + hashtag_view_recent = self._get_hashtag_row(hashtag[1:]) + + if hashtag_view_recent.exists(): + hashtag_view_recent.click() + sleeper.random_sleep() + return HashTagView(self.device) + + print(f"{hashtag} is not in recent searching history..") + search_edit_text.set_text(hashtag) + hashtag_view = self._get_hashtag_row(hashtag[1:]) + + if not hashtag_view.exists(): + print(COLOR_FAIL + f"Cannot find hashtag {hashtag}, abort." + COLOR_ENDC) + save_crash(self.device) + return None + + hashtag_view.click() + sleeper.random_sleep() + + return HashTagView(self.device) + + +class PostsViewList(InstagramView): + def swipe_to_fit_posts(self, first_post): + """calculate the right swipe amount necessary to swipe to next post in hashtag post view""" + POST_CONTAINER = f"{self.device.app_id}:id/zoomable_view_container|{self.device.app_id}:id/carousel_media_group" + displayWidth = self.device.get_info()["displayWidth"] + if first_post: + zoomable_view_container = self.device.find( + resourceIdMatches=POST_CONTAINER + ).get_bounds()["bottom"] + + print("Scrolled down to see more posts.") + self.device.swipe_points( + displayWidth / 2, + zoomable_view_container - 1, + displayWidth / 2, + zoomable_view_container * 2 / 3, + ) + else: + + gap_view = self.device.find( + resourceIdMatches=f"{self.device.app_id}:id/gap_view" + ).get_bounds()["top"] + + self.device.swipe_points(displayWidth / 2, gap_view, displayWidth / 2, 10) + zoomable_view_container = self.device.find(resourceIdMatches=(POST_CONTAINER)) + + zoomable_view_container = self.device.find( + resourceIdMatches=POST_CONTAINER + ).get_bounds()["bottom"] + + self.device.swipe_points( + displayWidth / 2, + zoomable_view_container - 1, + displayWidth / 2, + zoomable_view_container * 2 / 3, + ) + return + + def check_if_last_post(self, last_description): + """check if that post has been just interacted""" + post_description = self.device.find( + resourceId=f"{self.device.app_id}:id/row_feed_comment_textview_layout" + ) + if post_description.exists(True): + new_description = post_description.get_text().upper() + if new_description == last_description: + print("This is the last post for this hashtag") + return True, new_description + else: + return False, new_description + + +class LanguageView(InstagramView): + def setLanguage(self, language: str): + print_debug(f"Set language to {language}") + search_edit_text = self.device.find( + resourceId=f"{self.device.app_id}:id/search", + className="android.widget.EditText", + ) + search_edit_text.set_text(language) + + list_view = self.device.find( + resourceId=f"{self.device.app_id}:id/language_locale_list", + className="android.widget.ListView", + ) + first_item = list_view.child(index=0) + first_item.click() + + +class AccountView(InstagramView): + def navigateToLanguage(self): + print_debug("Navigate to Language") + button = self.device.find( + textMatches=case_insensitive_re("Language"), + resourceId=f"{self.device.app_id}:id/row_simple_text_textview", + className="android.widget.TextView", + ) + button.click() + + return LanguageView(self.device) + + +class SettingsView(InstagramView): + def navigateToAccount(self): + print_debug("Navigate to Account") + button = self.device.find( + textMatches=case_insensitive_re("Account"), + resourceId=f"{self.device.app_id}:id/row_simple_text_textview", + className="android.widget.TextView", + ) + button.click() + return AccountView(self.device) + + +class OptionsView(InstagramView): + def navigateToSettings(self): + print_debug("Navigate to Settings") + button = self.device.find( + textMatches=case_insensitive_re("Settings"), + resourceId=f"{self.device.app_id}:id/menu_settings_row", + className="android.widget.TextView", + ) + button.click() + return SettingsView(self.device) + + +class OpenedPostView(InstagramView): + def _getPostLikeButton(self, scroll_to_find=True): + """Find the like button right bellow a post. + Note: sometimes the like button from the post above or bellow are + dumped as well, so we need handle that situation. + + scroll_to_find: if the like button is not found, scroll a bit down + to try to find it. Default: True + """ + BTN_LIKE_RES_ID = f"{self.device.app_id}:id/row_feed_button_like" + MEDIA_GROUP_RE = case_insensitive_re( + [ + f"{self.device.app_id}:id/media_group", + f"{self.device.app_id}:id/carousel_media_group", + ] + ) + post_view_area = self.device.find( + resourceIdMatches=case_insensitive_re("android:id/list") + ) + if not post_view_area.exists(): + print_debug("Cannot find post recycler view area") + return None + + post_media_view = self.device.find( + resourceIdMatches=MEDIA_GROUP_RE, + className="android.widget.FrameLayout", + ) + + if not post_media_view.exists(): + print_debug("Cannot find post media view area") + return None + + like_btn_view = post_media_view.down( + resourceIdMatches=case_insensitive_re(OpenedPostView.BTN_LIKE_RES_ID) + ) + + if like_btn_view.exists(): + # threshold of 30% of the display height + threshold = int((0.3) * self.device.get_info()["displayHeight"]) + like_btn_top_bound = like_btn_view.get_bounds()["top"] + is_like_btn_in_the_bottom = like_btn_top_bound > threshold + + if not is_like_btn_in_the_bottom: + print_debug( + f"Like button is to high ({like_btn_top_bound} px). Threshold is {threshold} px" + ) + + post_view_area_bottom_bound = post_view_area.get_bounds()["bottom"] + is_like_btn_visible = like_btn_top_bound <= post_view_area_bottom_bound + if not is_like_btn_visible: + print_debug( + f"Like btn out of current clickable area. Like btn top ({like_btn_top_bound}) recycler_view bottom ({post_view_area_bottom_bound})" + ) + else: + print_debug("Like button not found bellow the post.") + + if ( + not like_btn_view.exists() + or not is_like_btn_in_the_bottom + or not is_like_btn_visible + ): + if scroll_to_find: + print_debug("Try to scroll tiny bit down...") + # Remember: to scroll down we need to swipe up :) + self.device.swipe(DeviceFacade.Direction.TOP, scale=0.1) + like_btn_view = post_media_view.down( + resourceIdMatches=case_insensitive_re( + OpenedPostView.BTN_LIKE_RES_ID + ) + ) + + if not scroll_to_find or not like_btn_view.exists(): + print(COLOR_FAIL + "Could not find like button bellow the post" + COLOR_ENDC) + return None + + return like_btn_view + + def _isPostLiked(self): + + like_btn_view = self._getPostLikeButton() + if not like_btn_view: + return False + + return like_btn_view.get_selected() + + def likePost(self, click_btn_like=False): + MEDIA_GROUP_RE = case_insensitive_re( + [ + f"{self.device.app_id}:id/media_group", + f"{self.device.app_id}:id/carousel_media_group", + ] + ) + post_media_view = self.device.find( + resourceIdMatches=MEDIA_GROUP_RE, className="android.widget.FrameLayout" + ) + + if click_btn_like: + like_btn_view = self._getPostLikeButton() + if not like_btn_view: + return False + like_btn_view.click() + else: + + if post_media_view.exists(): + post_media_view.double_click() + else: + print(COLOR_FAIL + "Could not find post area to double click" + COLOR_ENDC) + return False + + sleeper.random_sleep() + + return self._isPostLiked() + + def open_likers(self): + while True: + likes_view = self.device.find( + resourceId=f"{self.device.app_id}:id/row_feed_textview_likes", + className="android.widget.TextView", + ) + if likes_view.exists(True): + if likes_view.get_text()[-6:].upper() == "OTHERS": + print("Opening post likers") + sleeper.random_sleep() + likes_view.click(likes_view.Location.RIGHT) + return True + else: + print("This post has only 1 liker, skip") + return False + else: + return False + + def _getListViewLikers(self): + return self.device.find( + resourceId="android:id/list", className="android.widget.ListView" + ) + + def _getUserCountainer(self): + return self.device.find( + resourceId=f"{self.device.app_id}:id/row_user_container_base", + className="android.widget.LinearLayout", + ) + + def _getUserName(self, countainer): + return countainer.child( + resourceId=f"{self.device.app_id}:id/row_user_primary_name", + className="android.widget.TextView", + ) + + +class PostsGridView(InstagramView): + def scrollDown(self): + coordinator_layout = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/coordinator_root_layout" + ) + ) + if coordinator_layout.exists(): + coordinator_layout.scroll(DeviceFacade.Direction.BOTTOM) + return True + + return False + + def navigateToPost(self, row, col): + post_list_view = self.device.find( + resourceIdMatches=case_insensitive_re("android:id/list") + ) + OFFSET = 1 # row with post starts from index 1 + row_view = post_list_view.child(index=row + OFFSET) + if not row_view.exists(): + return None + post_view = row_view.child(index=col) + if not post_view.exists(): + return None + post_view.click() + + return OpenedPostView(self.device) + + +class ProfileView(ActionBarView): + def __init__(self, device: DeviceFacade, is_own_profile=False): + super().__init__(device) + self.is_own_profile = is_own_profile + + def refresh(self): + re_case_insensitive = case_insensitive_re( + [ + f"{self.device.app_id}:id/coordinator_root_layout", + ] + ) + coordinator_layout = self.device.find(resourceIdMatches=re_case_insensitive) + if coordinator_layout.exists(): + coordinator_layout.scroll(DeviceFacade.Direction.TOP) + + def navigate_to_options(self): + print_debug("Navigate to Options") + button = self.action_bar.child( + descriptionMatches=case_insensitive_re("Options") + ) + button.click() + + return OptionsView(self.device) + + def _get_action_bar_title_btn(self): + re_case_insensitive = case_insensitive_re( + [ + f"{self.device.app_id}:id/title_view", + f"{self.device.app_id}:id/action_bar_title", + f"{self.device.app_id}:id/action_bar_large_title", + f"{self.device.app_id}:id/action_bar_textview_title", + ] + ) + return self.action_bar.child( + resourceIdMatches=re_case_insensitive, className="android.widget.TextView" + ) + + def get_username(self, error=True): + title_view = self._get_action_bar_title_btn() + if title_view.exists(): + return title_view.get_text() + if error: + print(COLOR_FAIL + "Cannot get username" + COLOR_ENDC) + return None + + def _parse_counter(self, text): + multiplier = 1 + text = text.replace(",", "") + is_dot_in_text = False + if '.' in text: + text = text.replace(".", "") + is_dot_in_text = True + if "K" in text: + text = text.replace("K", "") + multiplier = 1000 + + if is_dot_in_text: + multiplier = 100 + + if "M" in text: + text = text.replace("M", "") + multiplier = 1000000 + + if is_dot_in_text: + multiplier = 100000 + try: + count = int(float(text) * multiplier) + except ValueError: + print(COLOR_FAIL + f"Cannot parse {text}. Probably wrong language ?!" + COLOR_ENDC) + raise LanguageNotEnglishException() + return count + + def _get_followers_text_view(self): + followers_text_view = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/row_profile_header_textview_followers_count" + ), + className="android.widget.TextView", + ) + return followers_text_view + + def get_followers_count(self, should_parse=True): + followers = None + followers_text_view = self._get_followers_text_view() + if followers_text_view.exists(): + followers_text = followers_text_view.get_text() + if followers_text: + if should_parse: + followers = self._parse_counter(followers_text) + else: + followers = followers_text + else: + print(COLOR_FAIL + "Cannot get followers count text" + COLOR_ENDC) + else: + print(COLOR_FAIL + "Cannot find followers count view" + COLOR_ENDC) + + return followers + + def _get_following_text_view(self): + following_text_view = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/row_profile_header_textview_following_count" + ), + className="android.widget.TextView", + ) + return following_text_view + + def get_following_count(self): + following = None + following_text_view = self._get_following_text_view() + if following_text_view.exists(): + following_text = following_text_view.get_text() + if following_text: + following = self._parse_counter(following_text) + else: + print(COLOR_FAIL + "Cannot get following count text" + COLOR_ENDC) + else: + print(COLOR_FAIL + "Cannot find following count view" + COLOR_ENDC) + + return following + + def get_posts_count(self): + post_count_view = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/row_profile_header_textview_post_count" + ), + className="android.widget.TextView", + ) + if post_count_view.exists(): + count = post_count_view.get_text() + if count != None: + return self._parse_counter(count) + else: + print(COLOR_FAIL + "Cannot get posts count text" + COLOR_ENDC) + return 0 + else: + print(COLOR_FAIL + "Cannot get posts count text" + COLOR_ENDC) + return 0 + + def count_photo_in_view(self): + """return rows filled and the number of post in the last row""" + RECYCLER_VIEW = "androidx.recyclerview.widget.RecyclerView" + grid_post = self.device.find( + className=RECYCLER_VIEW, resourceIdMatches="android:id/list" + ) + if grid_post.exists(): # max 4 rows supported + for i in range(2, 5): + lin_layout = grid_post.child( + index=i, className="android.widget.LinearLayout" + ) + if i == 4 or not lin_layout.exists(True): + last_index = i - 1 + last_lin_layout = grid_post.child(index=last_index) + for n in range(1, 4): + if n == 3 or not last_lin_layout.child(index=n).exists(True): + if n == 3: + return last_index, 0 + else: + return last_index - 1, n + else: + return 0, 0 + + def get_profile_info(self): + + username = self.get_username() + followers = self.get_followers_count() + following = self.get_following_count() + + return username, followers, following + + def get_profile_biography(self): + biography = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/profile_header_bio_text" + ), + className="android.widget.TextView", + ) + if biography.exists(): + biography_text = biography.get_text() + # If the biography is very long, blabla text and end with "...more" click the bottom of the text and get the new text + is_long_bio = re.compile( + r"{0}$".format("… more"), flags=re.IGNORECASE + ).search(biography_text) + if is_long_bio is not None: + biography.click(biography.Location.BOTTOM) + return biography.get_text() + return biography_text + return "" + + def get_full_name(self): + full_name_view = self.device.find( + resourceIdMatches=case_insensitive_re( + f"{self.device.app_id}:id/profile_header_full_name" + ), + className="android.widget.TextView", + ) + if full_name_view.exists(): + fullname_text = full_name_view.get_text() + if fullname_text is not None: + return fullname_text + return "" + + def is_private_account(self): + private_profile_view = self.device.find( + resourceIdMatches=case_insensitive_re( + [ + f"{self.device.app_id}:id/private_profile_empty_state", + f"{self.device.app_id}:id/row_profile_header_empty_profile_notice_title", + ] + ) + ) + return private_profile_view.exists(quick=True) + + def is_story_available(self): + return self.device.find( + resourceId=f"{self.device.app_id}:id/reel_ring", + className="android.view.View", + ).exists(quick=True) + + def profile_image(self): + return self.device.find( + resourceId=f"{self.device.app_id}:id/row_profile_header_imageview", + className="android.widget.ImageView", + ) + + def navigate_to_followers(self): + print_debug("Navigate to Followers") + FOLLOWERS_BUTTON_ID_REGEX = case_insensitive_re( + [ + f"{self.device.app_id}:id/row_profile_header_followers_container", + f"{self.device.app_id}:id/row_profile_header_container_followers", + ] + ) + followers_button = self.device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX) + followers_button.click() + + return FollowersFollowingListView(self.device) + + def navigate_to_following(self): + print_debug("Navigate to Followers") + FOLLOWING_BUTTON_ID_REGEX = case_insensitive_re( + [ + f"{self.device.app_id}:id/row_profile_header_following_container", + f"{self.device.app_id}:id/row_profile_header_container_following", + ] + ) + following_button = self.device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + following_button.click() + + return FollowersFollowingListView(self.device) + + def swipe_to_fit_posts(self): + """calculate the right swipe amount necessary to see 12 photos""" + displayWidth = self.device.get_info()["displayWidth"] + element_to_swipe_over = self.device.find( + resourceIdMatches=f"{self.device.app_id}:id/profile_tabs_container" + ).get_bounds()["top"] + bar_countainer = self.device.find( + resourceIdMatches=f"{self.device.app_id}:id/action_bar_container" + ).get_bounds()["bottom"] + + print("Scrolled down to see more posts.") + self.device.swipe_points( + displayWidth / 2, element_to_swipe_over, displayWidth / 2, bar_countainer + ) + return + + def navigate_to_posts_tab(self): + self._navigate_to_tab(ProfileTabs.POSTS) + return PostsGridView(self.device) + + def navigate_to_igtv_tab(self): + self._navigate_to_tab(ProfileTabs.IGTV) + raise Exception("Not implemented") + + def navigate_to_reels_tab(self): + self._navigate_to_tab(ProfileTabs.REELS) + raise Exception("Not implemented") + + def navigate_to_effects_tab(self): + self._navigate_to_tab(ProfileTabs.EFFECTS) + raise Exception("Not implemented") + + def navigate_to_photos_of_you_tab(self): + self._navigate_to_tab(ProfileTabs.PHOTOS_OF_YOU) + raise Exception("Not implemented") + + def _navigate_to_tab(self, tab: ProfileTabs): + TABS_RES_ID = f"{self.device.app_id}:id/profile_tab_layout" + TABS_CLASS_NAME = "android.widget.HorizontalScrollView" + tabs_view = self.device.find( + resourceIdMatches=case_insensitive_re(TABS_RES_ID), + className=TABS_CLASS_NAME, + ) + + TAB_RES_ID = f"{self.device.app_id}:id/profile_tab_icon_view" + TAB_CLASS_NAME = "android.widget.ImageView" + description = "" + if tab == ProfileTabs.POSTS: + description = "Grid View" + elif tab == ProfileTabs.IGTV: + description = "IGTV" + elif tab == ProfileTabs.REELS: + description = "Reels" + elif tab == ProfileTabs.EFFECTS: + description = "Effects" + elif tab == ProfileTabs.PHOTOS_OF_YOU: + description = "Photos of You" + + button = tabs_view.child( + descriptionMatches=case_insensitive_re(description), + resourceIdMatches=case_insensitive_re(TAB_RES_ID), + className=TAB_CLASS_NAME, + ) + + attempts = 0 + while not button.exists(): + attempts += 1 + self.device.swipe(DeviceFacade.Direction.TOP, scale=0.1) + if attempts > 2: + print(COLOR_FAIL + f"Cannot navigate to tab '{description}'" + COLOR_ENDC) + save_crash(self.device) + return + + button.click() + + def _get_recycler_view(self): + CLASSNAME = "(androidx.recyclerview.widget.RecyclerView|android.view.View)" + + return self.device.find(classNameMatches=CLASSNAME) + + +class FollowersFollowingListView(InstagramView): + def scroll_to_bottom(self): + print("Scroll to the bottom of the list") + + def is_end_reached(): + see_all_button = self.device.find(resourceId=f'{self.device.app_id}:id/see_all_button', + className='android.widget.TextView') + return see_all_button.exists() + + list_view = self.device.find(resourceId='android:id/list', + className='android.widget.ListView') + while not is_end_reached(): + list_view.swipe(DeviceFacade.Direction.BOTTOM) + + def scroll_to_top(self): + print("Scroll to top of the list") + + def is_at_least_one_follower(): + follower = self.device.find(resourceId=f'{self.device.app_id}:id/follow_list_container', + className='android.widget.LinearLayout') + return follower.exists() + + list_view = self.device.find(resourceId='android:id/list', + className='android.widget.ListView') + + while not is_at_least_one_follower(): + list_view.scroll(DeviceFacade.Direction.TOP) + + def is_list_empty(self): + # Looking for any profile in the list, just to make sure its loaded with profiles + any_username_view = self.device.find(resourceId=f'{self.device.app_id}:id/follow_list_username', + className='android.widget.TextView') + is_list_empty_from_profiles = not any_username_view.exists() + return is_list_empty_from_profiles + + def iterate_over_followers(self, is_myself, iteration_callback, + iteration_callback_pre_conditions, iterate_without_sleep=False): + FOLLOW_LIST_CONTAINER_ID_REGEX = case_insensitive_re([f"{self.device.app_id}:id/follow_list_container"]) + ROW_SEARCH_ID_REGEX = case_insensitive_re([f"{self.device.app_id}:id/row_search_edit_text"]) + LOAD_MORE_BUTTON_ID_REGEX = case_insensitive_re([f"{self.device.app_id}:id/row_load_more_button"]) + + # Wait until list is rendered + self.device.find(resourceIdMatches=FOLLOW_LIST_CONTAINER_ID_REGEX, + className='android.widget.LinearLayout').wait() + + def scrolled_to_top(): + row_search = self.device.find(resourceIdMatches=ROW_SEARCH_ID_REGEX, + className='android.widget.EditText') + return row_search.exists() + + prev_screen_iterated_followers = [] + scroll_end_detector = ScrollEndDetector() + while True: + print("Iterate over visible followers") + if not iterate_without_sleep: + sleeper.random_sleep() + + screen_iterated_followers = [] + screen_skipped_followers_count = 0 + scroll_end_detector.notify_new_page() + + try: + for item in self.device.find(resourceIdMatches=FOLLOW_LIST_CONTAINER_ID_REGEX, + className='android.widget.LinearLayout'): + user_info_view = item.child(index=1) + user_name_view = user_info_view.child(index=0).child() + if not user_name_view.exists(quick=True): + print(COLOR_OKGREEN + "Next item not found: probably reached end of the screen." + COLOR_ENDC) + break + + try: + username = user_name_view.get_text() + except DeviceFacade.JsonRpcError: + print(COLOR_OKGREEN + "Next item was found, but its text is half-cutted / invisible, " + "moving on to next row." + COLOR_ENDC) + continue + + screen_iterated_followers.append(username) + scroll_end_detector.notify_username_iterated(username) + + if not iteration_callback_pre_conditions(username, user_name_view): + screen_skipped_followers_count += 1 + continue + + to_continue = iteration_callback(username, user_name_view) + if not to_continue: + print(COLOR_OKBLUE + "Stopping followers iteration" + COLOR_ENDC) + return + + except IndexError: + print(COLOR_FAIL + "Cannot get next item: probably reached end of the screen." + COLOR_ENDC) + + if is_myself and scrolled_to_top(): + print(COLOR_OKGREEN + "Scrolled to top, finish." + COLOR_ENDC) + return + elif len(screen_iterated_followers) > 0: + load_more_button = self.device.find(resourceIdMatches=LOAD_MORE_BUTTON_ID_REGEX) + load_more_button_exists = load_more_button.exists(quick=True) + + if scroll_end_detector.is_the_end(): + return + + need_swipe = screen_skipped_followers_count == len(screen_iterated_followers) + list_view = self.device.find(resourceId='android:id/list', + className='android.widget.ListView') + if not list_view.exists(): + print(COLOR_FAIL + "Cannot find the list of followers. Trying to press back again." + COLOR_ENDC) + self.device.back() + list_view = self.device.find(resourceId='android:id/list', + className='android.widget.ListView') + + if is_myself: + print(COLOR_OKGREEN + "Need to scroll now" + COLOR_ENDC) + list_view.scroll(DeviceFacade.Direction.TOP) + else: + pressed_retry = False + if load_more_button_exists: + retry_button = load_more_button.child(className='android.widget.ImageView') + if retry_button.exists(): + print("Press \"Load\" button") + retry_button.click() + sleeper.random_sleep() + pressed_retry = True + + if need_swipe and not pressed_retry: + print(COLOR_OKGREEN + "All followers skipped, let's do a swipe" + COLOR_ENDC) + list_view.swipe(DeviceFacade.Direction.BOTTOM) + else: + print(COLOR_OKGREEN + "Need to scroll now" + COLOR_ENDC) + list_view.scroll(DeviceFacade.Direction.BOTTOM) + + prev_screen_iterated_followers.clear() + prev_screen_iterated_followers += screen_iterated_followers + else: + print(COLOR_OKGREEN + "No followers were iterated, finish." + COLOR_ENDC) + return + + +class CurrentStoryView(InstagramView): + def getStoryFrame(self): + return self.device.find( + resourceId=f"{self.device.app_id}:id/reel_viewer_image_view", + className="android.widget.FrameLayout", + ) + + def getUsername(self): + reel_viewer_title = self.device.find( + resourceId=f"{self.device.app_id}:id/reel_viewer_title", + className="android.widget.TextView", + ) + return "" if not reel_viewer_title.exists() else reel_viewer_title.get_text() + + def getTimestamp(self): + reel_viewer_timestamp = self.device.find( + resourceId=f"{self.device.app_id}:id/reel_viewer_timestamp", + className="android.widget.TextView", + ) + if reel_viewer_timestamp.exists(): + timestamp = reel_viewer_timestamp.get_text().strip() + value = int(re.sub("[^0-9]", "", timestamp)) + if timestamp[-1] == "s": + return datetime.timestamp( + datetime.datetime.now() - datetime.timedelta(seconds=value) + ) + elif timestamp[-1] == "m": + return datetime.timestamp( + datetime.datetime.now() - datetime.timedelta(minutes=value) + ) + elif timestamp[-1] == "h": + return datetime.timestamp( + datetime.datetime.now() - datetime.timedelta(hours=value) + ) + else: + return datetime.timestamp( + datetime.datetime.now() - datetime.timedelta(days=value) + ) + return None + + +class LanguageNotEnglishException(Exception): + pass