diff --git a/config-examples-extra/all-params.json b/config-examples-extra/all-params.json index 54eec2b..6c691f8 100644 --- a/config-examples-extra/all-params.json +++ b/config-examples-extra/all-params.json @@ -59,6 +59,12 @@ "value": "2.json", "description" : "if you want to run multiple insomniac sessions one-by-one but with different parameters, for example - different action (interact and then unfollow), or same config but with different username, or any other variation of parameters you can think of, you can combine this parameter with the \"repeat\"-parameter, and after the sleep of the \"repeat\"-parameter, a new config file (referenced by this parameter) will be loaded. By default using the same config that been loaded in the first Insominac session. You must use \"repeat\"-parameter in order for that parameter take action!" }, + { + "parameter-name": "send_stats", + "enabled": true, + "value": "True", + "description" : "add this flag to send your anonymous statistics to the Telegram bot @your_insomniac_bot. This is useful when insomniac runs infinitely and you want to be able to track progress remotely" + }, { "parameter-name": "username", "enabled": false, diff --git a/docs/README.md b/docs/README.md index 13aa60d..dca16ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -156,6 +156,9 @@ default uses the same config file as been loaded for the first session. Note that you must use "--repeat" with this argument! +#### --send-stats +add this flag to send your anonymous statistics to the Telegram bot @your_insomniac_bot. This is useful when insomniac runs infinitely and you want to be able to track progress remotely + ### Advanced Options for savvy users. diff --git a/insomniac/__version__.py b/insomniac/__version__.py index 9ce1903..cdf9fde 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.7.28' +__version__ = '3.8.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 d4de8e5..6052d0e 100644 --- a/insomniac/action_get_my_profile_info.py +++ b/insomniac/action_get_my_profile_info.py @@ -1,5 +1,7 @@ +from insomniac.hardban_indicator import HardBanError from insomniac.navigation import switch_to_english from insomniac.sleeper import sleeper +from insomniac.softban_indicator import ActionBlockedError from insomniac.utils import * from insomniac.views import TabBarView, ActionBarView, UserSwitchFailedException @@ -21,7 +23,7 @@ def get_my_profile_info(device, username): profile_view.refresh() sleeper.random_sleep() username, followers, following = profile_view.get_profile_info(swipe_up_if_needed=True) - except UserSwitchFailedException as e: + except (UserSwitchFailedException, HardBanError, ActionBlockedError) as e: raise e except Exception as e: print(COLOR_FAIL + describe_exception(e) + COLOR_ENDC) diff --git a/insomniac/actions_impl.py b/insomniac/actions_impl.py index 6f20315..ab4de81 100644 --- a/insomniac/actions_impl.py +++ b/insomniac/actions_impl.py @@ -661,20 +661,31 @@ def iterate_over_my_followers(device, iteration_callback, iteration_callback_pre _iterate_over_my_followers_or_followings(device, iteration_callback, iteration_callback_pre_conditions, - is_followers=True) + is_followers=True, + is_swipes_allowed=True) + + +def iterate_over_my_followers_no_swipes(device, iteration_callback, iteration_callback_pre_conditions): + _iterate_over_my_followers_or_followings(device, + iteration_callback, + iteration_callback_pre_conditions, + is_followers=True, + is_swipes_allowed=False) def iterate_over_my_followings(device, iteration_callback, iteration_callback_pre_conditions): _iterate_over_my_followers_or_followings(device, iteration_callback, iteration_callback_pre_conditions, - is_followers=False) + is_followers=False, + is_swipes_allowed=True) def _iterate_over_my_followers_or_followings(device, iteration_callback, iteration_callback_pre_conditions, - is_followers): + is_followers, + is_swipes_allowed): entities_name = "followers" if is_followers else "followings" # Wait until list is rendered @@ -685,6 +696,7 @@ def _iterate_over_my_followers_or_followings(device, print(f"Iterate over visible {entities_name}") sleeper.random_sleep() screen_iterated_followings = 0 + screen_skipped_followings = 0 for item in device.find(resourceId=f'{device.app_id}:id/follow_list_container', className='android.widget.LinearLayout'): @@ -702,6 +714,7 @@ def _iterate_over_my_followers_or_followings(device, screen_iterated_followings += 1 if not iteration_callback_pre_conditions(username, user_name_view, follow_status_button_view): + screen_skipped_followings += 1 continue to_continue = iteration_callback(username, user_name_view, follow_status_button_view) @@ -711,10 +724,15 @@ def _iterate_over_my_followers_or_followings(device, print(COLOR_OKBLUE + f"Stopping iteration over {entities_name}" + COLOR_ENDC) return - if screen_iterated_followings > 0: + list_view = device.find(resourceId='android:id/list', + className='android.widget.ListView') + + if screen_skipped_followings == screen_iterated_followings > 0 and is_swipes_allowed: + print(COLOR_OKGREEN + "All followings skipped, let's do a swipe" + COLOR_ENDC) + list_view.swipe(DeviceFacade.Direction.BOTTOM) + sleeper.random_sleep(multiplier=2.0) + elif screen_iterated_followings > 0: print(COLOR_OKGREEN + "Need to scroll now" + COLOR_ENDC) - list_view = device.find(resourceId='android:id/list', - className='android.widget.ListView') list_view.scroll(DeviceFacade.Direction.BOTTOM) else: print(COLOR_OKGREEN + f"No {entities_name} were iterated, finish." + COLOR_ENDC) @@ -809,20 +827,11 @@ def do_unfollow(device, my_username, username, storage, check_if_is_follower, us unfollow_confirmed = dialog_view.click_unfollow() if unfollow_confirmed: - try: - # If the account is private, another popup is shown - confirm_button = device.find(classNameMatches=TEXTVIEW_OR_BUTTON_REGEX, - clickable=True, - text='Unfollow') - # If it exists, click unfollow - if confirm_button.exists(): - print("Private account, confirming unfollow...") - confirm_button.click() - # Either way, sleep - except: - pass - finally: - sleeper.random_sleep() + sleeper.random_sleep() + if dialog_view.is_visible(): + print("Confirming unfollow again...") + if dialog_view.click_unfollow(): + sleeper.random_sleep() else: softban_indicator.detect_action_blocked_dialog(device) diff --git a/insomniac/assets/ADBKeyboard.apk b/insomniac/assets/ADBKeyboard.apk index e486f27..6076465 100644 Binary files a/insomniac/assets/ADBKeyboard.apk and b/insomniac/assets/ADBKeyboard.apk differ diff --git a/insomniac/db_models.py b/insomniac/db_models.py index 11c4424..b4d4275 100644 --- a/insomniac/db_models.py +++ b/insomniac/db_models.py @@ -1,18 +1,27 @@ import uuid from collections import defaultdict -from typing import Optional +from typing import List from peewee import * from playhouse.migrate import SqliteMigrator, migrate -from insomniac.utils import * from insomniac.globals import executable_name +from insomniac.utils import * DATABASE_NAME = f'{executable_name}.db' DATABASE_VERSION = 4 db = SqliteDatabase(DATABASE_NAME, autoconnect=False) +# Uncomment the line below to use PostgreSQL database instead. You may want to use it if you have multiple engine +# instances running at the same time and trying to write to the same database. SQLite is not good for this, +# while PostgreSQL has client-server architecture and easily handles concurrent operations. +# +# Note that you'll have to install PostgreSQL (it's not included in Python distribution like SQLite), create scheme +# and then replace "your_database_name", "your_username" and "your_password" parameters according to your setup. +# +# db = PostgresqlDatabase('your_database_name', user='your_username', password='your_password', host='localhost', port=5432, autoconnect=False) + class InsomniacModel(Model): class Meta: @@ -25,7 +34,7 @@ class InstagramProfile(InsomniacModel): class Meta: db_table = 'instagram_profiles' - def start_session(self, app_id, app_version, args, profile_status, followers_count, following_count) -> uuid.UUID: + def start_session(self, app_id, app_version, args, profile_status, followers_count, following_count, start_time=None) -> uuid.UUID: """ Create InstagramProfileInfo record Create SessionInfo record @@ -35,21 +44,23 @@ def start_session(self, app_id, app_version, args, profile_status, followers_cou profile_info = InstagramProfileInfo.create(profile=self, status=profile_status, followers=followers_count, - following=following_count) + following=following_count, + timestamp=(start_time or datetime.now())) session = SessionInfo.create(app_id=app_id, app_version=app_version, + start=(start_time or datetime.now()), args=args, profile_info=profile_info) return session.id - def end_session(self, session_id): + def end_session(self, session_id, end_time=None): """ Add end-timestamp to the SessionInfo record """ with db.connection_context(): session_info = SessionInfo.get(SessionInfo.id == session_id) - session_info.end = datetime.now() + session_info.end = end_time or datetime.now() session_info.save() def add_session(self, app_id, app_version, args, profile_status, followers_count, following_count, start, end): @@ -79,17 +90,17 @@ def update_profile_info(self, profile_status, followers_count, following_count): followers=followers_count, following=following_count) - def get_latsest_profile_info(self) -> Optional['InstagramProfileInfo']: + def get_last_profile_infos(self, count) -> List['InstagramProfileInfo']: with db.connection_context(): query = InstagramProfileInfo.select() \ .where(InstagramProfileInfo.profile == self) \ - .group_by(InstagramProfileInfo.profile) \ - .having(InstagramProfileInfo.timestamp == fn.MAX(InstagramProfileInfo.timestamp)) - - for obj in query: - return obj + .order_by(InstagramProfileInfo.timestamp.desc()) \ + .limit(count) + return list(query) - return None + def get_latsest_profile_info(self) -> Optional['InstagramProfileInfo']: + last_profile_infos = self.get_last_profile_infos(1) + return last_profile_infos[0] if last_profile_infos is not None else None def log_get_profile_action(self, session_id, phase, username, task_id='', execution_id='', timestamp=None): """ @@ -888,7 +899,7 @@ def get_actions_count_within_hours_for_profiles(action_types=None, hours=None, def get_actions_count_for_profiles(action_types=None, timestamp_from=None, timestamp_to=None, - profiles=None, task_ids=None, session_phases=None) -> dict: + profiles=None, task_ids=None, session_phases=None, execution_ids_to_exclude=None) -> dict: """ Returns the amount of actions by 'action_type', within the last 'hours', that been done by 'profiles' """ @@ -900,6 +911,7 @@ def get_actions_count_for_profiles(action_types=None, timestamp_from=None, times query = InsomniacAction.select(InsomniacAction.actor_profile, InsomniacAction.type, fn.COUNT(InsomniacAction.id).alias('ct'))\ .where((InsomniacAction.actor_profile.in_(profiles) if profiles else True) & (InsomniacAction.task_id.in_(task_ids) if task_ids else True) & + (InsomniacAction.execution_id.not_in(execution_ids_to_exclude) if execution_ids_to_exclude else True) & (InsomniacAction.phase.in_(session_phases_values) if session_phases_values else True) & (InsomniacAction.type.in_(action_types_names) if action_types_names else True) & (InsomniacAction.timestamp >= timestamp_from if timestamp_from else True) & diff --git a/insomniac/device_facade.py b/insomniac/device_facade.py index 123d4dd..7b1c714 100644 --- a/insomniac/device_facade.py +++ b/insomniac/device_facade.py @@ -209,7 +209,10 @@ def is_alive(self): if self.deviceV1 is not None: return self.deviceV1.server.alive else: - return self.deviceV2._is_alive() + try: + return self.deviceV2.server.alive + except AttributeError: + return self.deviceV2._is_alive() def wake_up(self): """ Make sure agent is alive or bring it back up before starting. """ diff --git a/insomniac/extra_features/report_sender.py b/insomniac/extra_features/report_sender.py new file mode 100644 index 0000000..ad97f8c --- /dev/null +++ b/insomniac/extra_features/report_sender.py @@ -0,0 +1,3 @@ +from insomniac import activation_controller + +exec(activation_controller.get_extra_feature('report_sender')) diff --git a/insomniac/globals.py b/insomniac/globals.py index e81f3be..3d89c54 100644 --- a/insomniac/globals.py +++ b/insomniac/globals.py @@ -1,17 +1,15 @@ # These constants can be set by the external UI-layer process, don't change them manually +from typing import Callable + is_ui_process = False execution_id = '' task_id = '' executable_name = 'insomniac' do_location_permission_dialog_checks = True # no need in these checks if location permission is denied beforehand - -def callback(profile_name): - pass - - -hardban_detected_callback = callback -softban_detected_callback = callback +hardban_detected_callback: Callable[[str], None] = lambda profile_name: None # call when hard ban detected +softban_detected_callback: Callable[[str], None] = lambda profile_name: None # call when soft ban detected +is_session_allowed_callback: Callable[[str, object], bool] = lambda: True # call to check whether UI app allows this session def is_insomniac(): diff --git a/insomniac/navigation.py b/insomniac/navigation.py index 26976c7..ad2cfbd 100644 --- a/insomniac/navigation.py +++ b/insomniac/navigation.py @@ -1,5 +1,5 @@ from insomniac.utils import * -from insomniac.views import TabBarView, ProfileView, TabBarTabs, LanguageNotEnglishException, DialogView +from insomniac.views import TabBarView, ProfileView, TabBarTabs, LanguageNotEnglishException, DialogView, OpenedPostView SEARCH_CONTENT_DESC_REGEX = '[Ss]earch and [Ee]xplore' @@ -40,11 +40,60 @@ def switch_to_english(device): .switch_to_english() +def open_instagram_with_network_check(device) -> bool: + """ + :return: true if IG app was opened, false if it was already opened + """ + print("Open Instagram app with network check") + device_id = device.device_id + app_id = device.app_id + + # Try via starter + cmd = ("adb" + ("" if device_id is None else " -s " + device_id) + + f" shell am start -a com.alexal1.starter.CHECK_CONNECTION_AND_LAUNCH_APP --es \"package\" \"{app_id}\"") + cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") + err = cmd_res.stderr.strip() + if err: + # Fallback to standard way + print(COLOR_FAIL + "Didn't work :(" + COLOR_ENDC) + return open_instagram(device_id, app_id) + + # Wait until Instagram is actually opened + max_attempts = 10 + attempt = 0 + while True: + sleep(5) + attempt += 1 + resumed_activity_output = execute_command("adb" + ("" if device_id is None else " -s " + device_id) + + f" shell dumpsys activity | grep 'mResumedActivity'") + if app_id in resumed_activity_output: + break + + if attempt < max_attempts: + print(COLOR_OKGREEN + "Instagram is not yet opened, waiting..." + COLOR_ENDC) + sleep(10) + else: + return open_instagram_with_network_check(device) + return True + + def close_instagram_and_system_dialogs(device): close_instagram(device.device_id, device.app_id) # If the app crashed there will be a system dialog DialogView(device).close_not_responding_dialog_if_visible() +def is_user_exists(device, username): + return TabBarView(device).navigate_to_search().find_username(username) + + +def is_post_exists(device, post_link): + if not open_instagram_with_url(device.device_id, device.app_id, post_link): + return False + is_post_opened = OpenedPostView(device).is_visible() + device.back() + return is_post_opened + + class LanguageChangedException(Exception): pass diff --git a/insomniac/network.py b/insomniac/network.py index 670858b..d721531 100644 --- a/insomniac/network.py +++ b/insomniac/network.py @@ -24,13 +24,14 @@ ] -def post(url, data, user_agent=INSOMNIAC_USER_AGENT): +def post(url, data, user_agent=INSOMNIAC_USER_AGENT, initial_timeout=INITIAL_TIMEOUT): """ Perform HTTP POST request. :param url: URL to request for :param data: data to send as the request body :param user_agent: optional custom user-agent + :param initial_timeout: http timeout, increases exponentially on each socket.timeout error :return: tuple of: response code, body (if response has one), and fail reason which is None if code is 200 """ @@ -44,30 +45,32 @@ def post(url, data, user_agent=INSOMNIAC_USER_AGENT): 'Content-Length': len(json_data_as_bytes) } - return _request(url=url, data=json_data_as_bytes, headers=headers) + return _request(url=url, data=json_data_as_bytes, headers=headers, initial_timeout=initial_timeout) -def get(url, user_agent=INSOMNIAC_USER_AGENT): +def get(url, user_agent=INSOMNIAC_USER_AGENT, initial_timeout=INITIAL_TIMEOUT): """ Perform HTTP GET request. :param url: URL to request for :param user_agent: optional custom user-agent + :param initial_timeout: http timeout, increases exponentially on each socket.timeout error :return: tuple of: response code, body (if response has one), and fail reason which is None if code is 200 """ headers = { 'User-Agent': user_agent } - return _request(url=url, data=None, headers=headers) + return _request(url=url, data=None, headers=headers, initial_timeout=initial_timeout) -def _request(url, data, headers): +def _request(url, data, headers, initial_timeout): """ Perform HTTP GET request. :param url: URL to request for - :param user_agent: optional custom user-agent + :param headers: http headers + :param initial_timeout: http timeout, increases exponentially on each socket.timeout error :return: tuple of: response code, body (if response has one), and fail reason which is None if code is 200 """ @@ -76,7 +79,7 @@ def _request(url, data, headers): attempt = 0 while True: attempt += 1 - timeout = INITIAL_TIMEOUT ** attempt + timeout = initial_timeout ** attempt try: with urllib.request.urlopen(request, timeout=timeout, context=ssl.SSLContext()) as response: code = response.code diff --git a/insomniac/params.py b/insomniac/params.py index b477d01..10ca3d2 100644 --- a/insomniac/params.py +++ b/insomniac/params.py @@ -15,7 +15,7 @@ def parse_arguments(all_args_dict, starter_conf_file_path): - parser = argparse.ArgumentParser( + parser = CustomArgumentParser( description='Instagram bot for automated Instagram interaction using Android device via ADB', add_help=False ) @@ -151,3 +151,14 @@ def _load_params(config_file): with open(config_file, encoding="utf-8") as json_file: return json.load(json_file) + + +class CustomArgumentParser(argparse.ArgumentParser): + + def print_help(self, file=None): + new_optional_arguments = f"Please visit {COLOR_BOLD}https://insomniac-bot.com/docs/{COLOR_ENDC} " \ + f"to see descriptions of all arguments.\n" + if file is None: + file = sys.stdout + message = re.sub('optional arguments:.*', new_optional_arguments, self.format_help(), flags=re.S) + file.write(message) diff --git a/insomniac/report.py b/insomniac/report.py index 73188fe..7ebd97f 100644 --- a/insomniac/report.py +++ b/insomniac/report.py @@ -7,6 +7,10 @@ def print_full_report(sessions): completed_sessions = [session for session in sessions if session.is_finished()] print_timeless(COLOR_REPORT + "Completed sessions: " + str(len(completed_sessions)) + COLOR_ENDC) + last_session = sessions[-1] + last_duration = last_session.finishTime or datetime.now() - last_session.startTime + print_timeless(COLOR_REPORT + "Last duration: " + str(last_duration) + COLOR_ENDC) + duration = timedelta(0) for session in sessions: finish_time = session.finishTime or datetime.now() diff --git a/insomniac/safely_runner.py b/insomniac/safely_runner.py index 6f8b4b5..2810cfc 100644 --- a/insomniac/safely_runner.py +++ b/insomniac/safely_runner.py @@ -7,7 +7,8 @@ from insomniac import __version__ from insomniac.device_facade import DeviceFacade from insomniac.globals import is_insomniac -from insomniac.navigation import LanguageChangedException, close_instagram_and_system_dialogs +from insomniac.navigation import LanguageChangedException, close_instagram_and_system_dialogs, \ + open_instagram_with_network_check from insomniac.sleeper import sleeper from insomniac.utils import * @@ -32,7 +33,7 @@ def wrapper(*args, **kwargs): print("No idea what it was. Let's try again.") # Hack for the case when IGTV was accidentally opened close_instagram_and_system_dialogs(device_wrapper.get()) - open_instagram(device_wrapper.device_id, device_wrapper.app_id) + open_instagram_with_network_check(device_wrapper.get()) except LanguageChangedException: print_timeless("") print("Language was changed. We'll have to start from the beginning.") @@ -62,7 +63,7 @@ def wrapper(*args, **kwargs): sleeper.random_sleep(multiplier=2.0) close_instagram_and_system_dialogs(device_wrapper.get()) airplane_mode_on_off(device_wrapper) - open_instagram(device_wrapper.device_id, device_wrapper.app_id) + open_instagram_with_network_check(device_wrapper.get()) sleeper.random_sleep() return wrapper return actual_decorator diff --git a/insomniac/session.py b/insomniac/session.py index e34bb0d..8c6a3b1 100644 --- a/insomniac/session.py +++ b/insomniac/session.py @@ -11,7 +11,7 @@ from insomniac.hardban_indicator import HardBanError, hardban_indicator from insomniac.limits import LimitsManager from insomniac.migration import migrate_from_json_to_sql, migrate_from_sql_to_peewee -from insomniac.navigation import close_instagram_and_system_dialogs +from insomniac.navigation import close_instagram_and_system_dialogs, open_instagram_with_network_check from insomniac.params import parse_arguments, refresh_args_by_conf_file, load_app_id from insomniac.report import print_full_report from insomniac.session_state import InsomniacSessionState @@ -258,9 +258,7 @@ def prepare_session_state(self, args, device_wrapper, app_version, save_profile_ if __version__.__debug_mode__: device.start_screen_record() - if open_instagram(device_wrapper.device_id, device_wrapper.app_id): - # IG was just opened, check that we are not hard banned - hardban_indicator.detect_webview(device) + open_instagram_with_network_check(device) if save_profile_info: self.session_state.my_username, \ self.session_state.my_followers_count, \ diff --git a/insomniac/storage.py b/insomniac/storage.py index 8638a1f..e9d8938 100644 --- a/insomniac/storage.py +++ b/insomniac/storage.py @@ -241,15 +241,15 @@ def get_sessions_count_within_hours(self, hours): return self.profile.get_session_count_within_hours(hours) def log_softban(self): - InsomniacStorage._update_profile_status(self.profile, ProfileStatus.SOFT_BAN) + InsomniacStorage.update_profile_status(self.profile, ProfileStatus.SOFT_BAN) @staticmethod def log_hardban(username): profile = get_ig_profile_by_profile_name(username) - InsomniacStorage._update_profile_status(profile, ProfileStatus.HARD_BAN) + InsomniacStorage.update_profile_status(profile, ProfileStatus.HARD_BAN) @staticmethod - def _update_profile_status(profile, status): + def update_profile_status(profile, status): followers_count = 0 following_count = 0 latest_profile_info = profile.get_latsest_profile_info() diff --git a/insomniac/tests/db_tests.py b/insomniac/tests/db_tests.py index 01aeab8..a5c246a 100644 --- a/insomniac/tests/db_tests.py +++ b/insomniac/tests/db_tests.py @@ -3,14 +3,19 @@ from peewee import SqliteDatabase from insomniac import db_models -from insomniac.actions_types import SourceType -from insomniac.db_models import get_ig_profile_by_profile_name, MODELS +from insomniac.actions_types import SourceType, LikeAction, FollowAction +from insomniac.db_models import get_ig_profile_by_profile_name, MODELS, get_actions_count_for_profiles, \ + get_session_count_from_to, get_ig_profiles_by_profiles_names, get_actions_count_within_hours_for_profiles from insomniac.storage import ProfileStatus, SessionPhase from insomniac.utils import * TEST_DATABASE_FILE = 'test.db' test_db = SqliteDatabase(TEST_DATABASE_FILE, autoconnect=False) +# Uncomment to test on PostgreSQL: +# from peewee import PostgresqlDatabase +# test_db = PostgresqlDatabase('your_database_name', user='your_username', password='your_password', host='localhost', port=5432, autoconnect=False) + class DatabaseTests(unittest.TestCase): @@ -22,6 +27,21 @@ def setUp(self): test_db.create_tables(MODELS) test_db.close() + def test_validate_profile_info(self): + my_account = "my_account" + start_time1 = (datetime.now() - timedelta(hours=1)).replace(microsecond=0) + start_time2 = datetime.now().replace(microsecond=0) + + def job_interact(profile, session_id): + pass # just empty job to make a "profile info" record + self._run_inside_session(my_account, job_interact, start_time=start_time1) + + def job_interact(profile, session_id): + print(COLOR_BOLD + f"Validate last profile info's timestamp " + COLOR_ENDC) + profile_info = profile.get_latsest_profile_info() + assert profile_info.timestamp == start_time2 + self._run_inside_session(my_account, job_interact, start_time=start_time2) + def test_is_interacted(self): my_account1 = "my_account1" my_account2 = "my_account2" @@ -279,16 +299,90 @@ def job_real(profile, _): assert username == username8 self._run_inside_session(real_account_username, job_real) + def test_statistics(self): + my_account1 = "my_account1" + my_account2 = "my_account2" + username1 = "username1" + username2 = "username2" + profiles = list(get_ig_profiles_by_profiles_names([my_account1, my_account2]).values()) + + def job_interact(profile, session_id): + profile.log_like_action(session_id, SessionPhase.TASK_LOGIC.value, username1, SourceType.BLOGGER.name, "some_blogger") + profile.log_like_action(session_id, SessionPhase.TASK_LOGIC.value, username2, SourceType.BLOGGER.name, "some_blogger", timestamp=datetime.now()-timedelta(hours=2)) + profile.log_follow_action(session_id, SessionPhase.TASK_LOGIC.value, username2, None, None) + self._run_inside_session(my_account1, job_interact) + + def job_interact(profile, session_id): + profile.log_like_action(session_id, SessionPhase.TASK_LOGIC.value, username1, SourceType.BLOGGER.name, "some_blogger") + self._run_inside_session(my_account2, job_interact) + + with test_db.connection_context(): + stats = get_actions_count_for_profiles(profiles=profiles) + stats_1_hour = get_actions_count_within_hours_for_profiles(profiles=profiles, hours=1) + stats_24_hours = get_actions_count_within_hours_for_profiles(profiles=profiles, hours=24) + sessions = get_session_count_from_to(profiles=profiles) + + assert stats[my_account1][LikeAction.__name__] == 2 + assert stats[my_account1][FollowAction.__name__] == 1 + + assert stats_1_hour[my_account1][LikeAction.__name__] == 1 + assert stats_1_hour[my_account1][FollowAction.__name__] == 1 + + assert stats_24_hours[my_account1][LikeAction.__name__] == 2 + assert stats_24_hours[my_account1][FollowAction.__name__] == 1 + + assert sum(sessions.values()) == 2 + + def test_session_time(self): + my_account1 = "my_account1" + my_account2 = "my_account2" + username = "username" + + def job_interact(profile, session_id): + profile.log_get_profile_action(session_id, SessionPhase.TASK_LOGIC.value, username) + profile.log_like_action(session_id, SessionPhase.TASK_LOGIC.value, username, SourceType.BLOGGER.name, "some_blogger") + + # Account 1: interact yesterday (10 hour) + start_time = datetime.now() - timedelta(hours=34) + end_time = datetime.now() - timedelta(hours=24) + self._run_inside_session(my_account1, job_interact, start_time, end_time) + + # Account 1: interact today (1 hour) + start_time = datetime.now() - timedelta(hours=3) + end_time = datetime.now() - timedelta(hours=2) + self._run_inside_session(my_account1, job_interact, start_time, end_time) + + # Account 1: interact today (1 hour) + start_time = datetime.now() - timedelta(hours=2) + end_time = datetime.now() - timedelta(hours=1) + self._run_inside_session(my_account1, job_interact, start_time, end_time) + + # Account 2: interact today (10 hours) + start_time = datetime.now() - timedelta(hours=11) + end_time = datetime.now() - timedelta(hours=1) + self._run_inside_session(my_account2, job_interact, start_time, end_time) + + def job_get_session_time(profile, session_id): + print(COLOR_BOLD + f"Check session time for last 24 hours" + COLOR_ENDC) + assert profile.get_session_time_in_seconds_within_minutes(60 * 24) == 3 * 60 * 60 + start_time = datetime.now() - timedelta(hours=1) + self._run_inside_session(my_account1, job_get_session_time, start_time) + def tearDown(self): print("Deleting test database") - os.remove(TEST_DATABASE_FILE) - - def _run_inside_session(self, username, action): + with test_db.connection_context(): + test_db.drop_tables(MODELS) + try: + os.remove(TEST_DATABASE_FILE) + except FileNotFoundError: + pass + + def _run_inside_session(self, username, action, start_time=None, end_time=None): print(f"Starting session for {username}") profile = get_ig_profile_by_profile_name(username) - session_id = profile.start_session(None, "", "", ProfileStatus.VALID.value, 2200, 500) + session_id = profile.start_session(None, "", "", ProfileStatus.VALID.value, 2200, 500, start_time) print(f"session_id = {session_id}") action(profile, session_id) print(f"Ending session for {username}") - profile.end_session(session_id) + profile.end_session(session_id, end_time) diff --git a/insomniac/typewriter.py b/insomniac/typewriter.py index 36fc854..e289d7c 100644 --- a/insomniac/typewriter.py +++ b/insomniac/typewriter.py @@ -4,8 +4,10 @@ # Typewriter uses Android application (apk file) built from this repo: https://github.com/alexal1/InsomniacAutomator # It provides IME (Input Method Editor) that replaces virtual keyboard with it's own one, which listens to specific # broadcast messages and simulates key presses. +ADB_KEYBOARD_PKG = "com.alexal1.adbkeyboard" ADB_KEYBOARD_IME = "com.alexal1.adbkeyboard/.AdbIME" ADB_KEYBOARD_APK = "ADBKeyboard.apk" +ADB_KEYBOARD_VERSION = versiontuple("3.0.1") DELAY_MEAN = 200 DELAY_DEVIATION = 100 IME_MESSAGE_B64 = "ADB_INPUT_B64" @@ -24,7 +26,22 @@ def __init__(self, device_id): self.device_id = device_id def set_adb_keyboard(self): - if not self._is_adb_ime_existing(): + need_to_install_apk = False + if self._is_adb_ime_existing(): + # Check version and update if needed + stream = os.popen("adb" + ("" if self.device_id is None else " -s " + self.device_id) + + f" shell dumpsys package {ADB_KEYBOARD_PKG} | grep 'versionName'") + output = stream.read() + version_match = re.findall('versionName=(\\S+)', output) + stream.close() + if len(version_match) == 1 and versiontuple(version_match[0]) >= ADB_KEYBOARD_VERSION: + print_debug("ADB Keyboard version is good") + else: + need_to_install_apk = True + else: + need_to_install_apk = True + + if need_to_install_apk: print("Installing ADB Keyboard to enable typewriting...") apk_path = os.path.join(os.path.dirname(os.path.abspath(insomniac.__file__)), "assets", ADB_KEYBOARD_APK) os.popen("adb" + ("" if self.device_id is None else " -s " + self.device_id) diff --git a/insomniac/utils.py b/insomniac/utils.py index a5af710..7397923 100644 --- a/insomniac/utils.py +++ b/insomniac/utils.py @@ -36,6 +36,7 @@ ENGINE_LOGS_DIR_NAME = 'logs' UI_LOGS_DIR_NAME = 'ui-logs' +INSTAGRAM_MAIN_ACTIVITY = "{0}/com.instagram.mainactivity.MainActivity" APP_REOPEN_WARNING = "Warning: Activity not started, intent has been delivered to currently running top-most instance." @@ -127,7 +128,7 @@ def open_instagram(device_id, app_id) -> bool: """ print("Open Instagram app") cmd = ("adb" + ("" if device_id is None else " -s " + device_id) + - f" shell am start -n {app_id}/com.instagram.mainactivity.MainActivity") + f" shell am start -n {INSTAGRAM_MAIN_ACTIVITY.format(app_id)}") cmd_res = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True, encoding="utf8") err = cmd_res.stderr.strip() @@ -180,7 +181,6 @@ def execute_command(cmd) -> Optional[str]: err = err.strip() if len(err) > 0: print(COLOR_FAIL + err.strip() + COLOR_ENDC) - return None if out is not None: return out.strip() return None @@ -451,6 +451,8 @@ def _init_log(self): self.is_log_initiated = True def write(self, message): + if not insomniac_globals.is_insomniac(): + message = re.sub("(?i)insomniac", "nomix", message) self._init_log() self.terminal.write(message) self.terminal.flush() diff --git a/insomniac/views.py b/insomniac/views.py index d6ec972..daa2a33 100644 --- a/insomniac/views.py +++ b/insomniac/views.py @@ -1,11 +1,11 @@ import datetime from enum import Enum, unique -from typing import Optional from insomniac.actions_types import GetProfileAction from insomniac.counters_parser import parse from insomniac.device_facade import DeviceFacade from insomniac.globals import do_location_permission_dialog_checks +from insomniac.hardban_indicator import hardban_indicator from insomniac.scroll_end_detector import ScrollEndDetector from insomniac.sleeper import sleeper from insomniac.utils import * @@ -146,8 +146,11 @@ def navigate_to(self, tab: TabBarTabs): button = None tab_bar_view = self._get_tab_bar() - # There may be no TabBarView if Instagram was opened via a deeplink. Then we have to clear the backstack. - self._clear_backstack() + if not self.is_visible(): + # There may be no TabBarView if Instagram was opened via a deeplink. Then we have to clear the backstack. + is_backstack_cleared = self._clear_backstack() + if not is_backstack_cleared: + raise RuntimeError("Unexpected app state: cannot clear back stack") if tab == TabBarTabs.HOME: button = tab_bar_view.child( @@ -195,7 +198,8 @@ def navigate_to(self, tab: TabBarTabs): seconds_left = timer.get_seconds_left() if seconds_left > 0: print(COLOR_OKGREEN + f"Opening {tab_name}, {seconds_left} seconds left..." + COLOR_ENDC) - sleep(2) + # Maybe we are banned? + hardban_indicator.detect_webview(self.device) print(COLOR_FAIL + f"Didn't find tab {tab_name} in the tab bar... " f"Maybe English language is not set!?" + COLOR_ENDC) @@ -203,14 +207,22 @@ def navigate_to(self, tab: TabBarTabs): raise LanguageNotEnglishException() def _clear_backstack(self): + attempt = 0 + max_attempts = 10 is_message_printed = False while not self.is_visible(): if not is_message_printed: print(COLOR_OKGREEN + "Clearing the back stack..." + COLOR_ENDC) is_message_printed = True + if attempt > 0 and attempt % 2 == 0: + hardban_indicator.detect_webview(self.device) + if attempt >= max_attempts: + return False self.press_back_arrow() # On fresh apps there may be a location request window after a backpress DialogView(self.device).close_location_access_dialog_if_visible() + attempt += 1 + return True def _is_correct_tab_opened(self, tab: TabBarTabs) -> bool: if tab == TabBarTabs.HOME: @@ -432,6 +444,30 @@ def _get_tab_view(self, tab: SearchTabs): save_crash(self.device) return None + def find_username(self, username) -> bool: + search_edit_text = self._get_search_edit_text() + search_edit_text.click() + self._handle_permission_request() + + username_view_recent = self._get_username_row(username) + if username_view_recent.exists(quick=True): + return True + print(f"@{username} is not in recent searching history...") + + search_edit_text.set_text(username) + search_text = self.device.find(resourceId=self.SEARCH_TEXT_ID.format(self.device.app_id), + className=self.SEARCH_TEXT_CLASSNAME) + search_text.click(ignore_if_missing=True) + + accounts_tab = self._get_tab_view(SearchTabs.ACCOUNTS) + if accounts_tab is not None: + accounts_tab.click() + else: + return False + + username_view = self._get_username_row(username) + return username_view.exists() + def navigate_to_username(self, username, on_action): print_debug(f"Navigate to profile @{username}") @@ -544,6 +580,8 @@ def _handle_permission_request(self): class PostsViewList(InstagramView): + LOAD_MORE_ROW_ID_REGEX = '{0}:id/row_load_more_button' + RETRY_BUTTON_CLASS_NAME = 'android.widget.ImageView' def is_visible(self): # We suppose that at least one post must be visible @@ -560,6 +598,14 @@ def open_likers(self): return False def scroll_down(self): + # Check if retry button is shown + load_more_row = self.device.find(resourceId=self.LOAD_MORE_ROW_ID_REGEX.format(self.device.app_id)) + if load_more_row.exists(quick=True): + print("Press \"Load\" button") + retry_button = load_more_row.child(className=self.RETRY_BUTTON_CLASS_NAME) + retry_button.click() + sleeper.random_sleep() + recycler_view = self.device.find(resourceId='android:id/list', className='androidx.recyclerview.widget.RecyclerView') recycler_view.scroll(DeviceFacade.Direction.BOTTOM) @@ -640,6 +686,7 @@ def switch_to_business_account(self): continue_button = self.device.find(textMatches=case_insensitive_re("Continue")) continue_button.click() radio_button.click() + sleeper.random_sleep(multiplier=2.0) done_button = self.device.find(textMatches=case_insensitive_re("Done")) done_button.click() sleeper.random_sleep(multiplier=2.0) @@ -1497,7 +1544,7 @@ def close_not_responding_dialog_if_visible(self): save_crash(self.device) def _click_close_app(self) -> bool: - close_app_button = self.device.find(resourceId=self.CLOSE_APP_ID.format(self.device.app_id), + close_app_button = self.device.find(resourceId=self.CLOSE_APP_ID, className=self.CLOSE_APP_CLASS_NAME, textMatches=self.CLOSE_APP_TEXT_REGEX) if close_app_button.exists():