diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index f04c3d34..304ca5cf 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Enhancement Request -about: Suggest an enhancement to the Kubernetes project +about: Suggest an enhancement to the GramAddict project labels: kind/feature --- diff --git a/.gitignore b/.gitignore index 6c55e4d9..2dbadc7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ /venv* +/.venv +/build +/dist +/gramaddict.egg-info /.idea /.vscode +*/*.yml interacted_users.* sessions.json *.pyc @@ -12,5 +17,5 @@ whitelist.txt Pipfile Pipfile.lock *.pdf -.venv -*.log* \ No newline at end of file +*.log* +!config-examples/* \ No newline at end of file diff --git a/DEPLOYMENT.MD b/DEPLOYMENT.MD new file mode 100644 index 00000000..279021c3 --- /dev/null +++ b/DEPLOYMENT.MD @@ -0,0 +1,7 @@ +# Process for Deploying Releases +- Make sure you are in the `master` branch and do a `git pull` prior to continuing +- Ensure version number in both `__version__.py` and `setup-beta.py` are both set to the new version number, without any `b` designation. e.g. 1.2.0, 1.2.1 +- Ensure you have `twine` installed +- Remove any existing distribution data: `rm -rf dist/ gramaddict.egg-info/` +- Run the command: `python3 setup.py sdist` +- Run the command: `twine upload dist/*` \ No newline at end of file diff --git a/GramAddict/__init__.py b/GramAddict/__init__.py index dac3ab05..278c62ec 100644 --- a/GramAddict/__init__.py +++ b/GramAddict/__init__.py @@ -1,12 +1,14 @@ -import argparse import logging -import sys from datetime import datetime +from sys import exit from time import sleep from colorama import Fore, Style -from GramAddict.core.device_facade import DeviceFacade, create_device +from GramAddict.core.config import Config +from GramAddict.core.device_facade import create_device +from GramAddict.core.filter import load_config as load_filter +from GramAddict.core.interaction import load_config as load_interaction from GramAddict.core.log import ( configure_logger, update_log_file_name, @@ -14,7 +16,6 @@ ) from GramAddict.core.navigation import switch_to_english from GramAddict.core.persistent_list import PersistentList -from GramAddict.core.plugin_loader import PluginLoader from GramAddict.core.report import print_full_report from GramAddict.core.session_state import SessionState, SessionStateEncoder from GramAddict.core.storage import Storage @@ -23,16 +24,25 @@ close_instagram, get_instagram_version, get_value, + load_config as load_utils, open_instagram, random_sleep, save_crash, update_available, ) -from GramAddict.core.views import TabBarView +from GramAddict.core.views import ( + AccountView, + ProfileView, + TabBarView, + load_config as load_views, +) from GramAddict.version import __version__ +# Pre-Load Config +configs = Config(first_run=True) + # Logging initialization -configure_logger() +configure_logger(configs.debug, configs.username) logger = logging.getLogger(__name__) if update_available(): logger.warn( @@ -42,123 +52,40 @@ f"GramAddict {__version__}", extra={"color": f"{Style.BRIGHT}{Fore.MAGENTA}"} ) - # Global Variables -device_id = None -plugins = PluginLoader("GramAddict.plugins").plugins sessions = PersistentList("sessions", SessionStateEncoder) -parser = argparse.ArgumentParser(description="GramAddict Instagram Bot") - - -def load_plugins(): - actions = {} - - for plugin in plugins: - if plugin.arguments: - for arg in plugin.arguments: - try: - action = arg.get("action", None) - if action: - parser.add_argument( - arg["arg"], help=arg["help"], action=arg.get("action", None) - ) - else: - parser.add_argument( - arg["arg"], - nargs=arg["nargs"], - help=arg["help"], - metavar=arg["metavar"], - default=arg["default"], - ) - if arg.get("operation", False): - actions[arg["arg"]] = plugin - except Exception as e: - logger.error( - f"Error while importing arguments of plugin {plugin.__class__.__name__}. Error: Missing key from arguments dictionary - {e}" - ) - return actions - - -def get_args(): - logger.debug(f"Arguments used: {' '.join(sys.argv[1:])}") - if not len(sys.argv) > 1: - parser.print_help() - return False - - args, unknown_args = parser.parse_known_args() - - if unknown_args: - logger.error( - "Unknown arguments: " + ", ".join(str(arg) for arg in unknown_args) - ) - parser.print_help() - return False - return args +# Load Config +configs.load_plugins() +configs.parse_args() def run(): - global device_id - loaded = load_plugins() - args = get_args() - enabled = [] - if not args: + # Some plugins need config values without being passed + # through. Because we do a weird config/argparse hybrid, + # we need to load the configs in a weird way + load_filter(configs) + load_interaction(configs) + load_utils(configs) + load_views(configs) + + if not configs.args or not check_adb_connection(): return - dargs = vars(args) - for item in sys.argv[1:]: - if item in loaded: - if item != "--interact" and item != "--hashtag-likers": - enabled.append(item) - - for k in loaded: - if dargs[k.replace("-", "_")[2:]] != None: - if k == "--interact": - logger.warn( - 'Using legacy argument "--interact". Please switch to new arguments as this will be deprecated in the near future.' - ) - for source in args.interact: - if "@" in source: - enabled.append("--blogger-followers") - if type(args.blogger_followers) != list: - args.blogger_followers = [source] - else: - args.blogger_followers.append(source) - else: - enabled.append("--hashtag-likers-top") - if type(args.hashtag_likers_top) != list: - args.hashtag_likers_top = [source] - else: - args.hashtag_likers_top.append(source) - elif k == "--hashtag-likers": - logger.warn( - 'Using legacy argument "--hashtag-likers". Please switch to new arguments as this will be deprecated in the near future.' - ) - for source in args.hashtag_likers: - enabled.append("--hashtag-likers-top") - if type(args.hashtag_likers_top) != list: - args.hashtag_likers_top = [source] - else: - args.hashtag_likers_top.append(source) - - enabled = list(dict.fromkeys(enabled)) - - if len(enabled) < 1: - logger.error("You have to specify one of the actions: " + ", ".join(loaded)) + if len(configs.enabled) < 1: + logger.error( + "You have to specify one of the actions: " + ", ".join(configs.actions) + ) return - device_id = args.device - if not check_adb_connection(is_device_id_provided=(device_id is not None)): - return - logger.info("Instagram version: " + get_instagram_version(device_id)) - device = create_device(device_id) + logger.info("Instagram version: " + get_instagram_version()) + device = create_device(configs.device_id, configs.args.uia_version) if device is None: return while True: - session_state = SessionState() - session_state.args = args.__dict__ + session_state = SessionState(configs) sessions.append(session_state) device.wake_up() @@ -168,23 +95,32 @@ def run(): extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, ) - if not DeviceFacade(device_id).get_info()["screenOn"]: - DeviceFacade(device_id).press_power() - if DeviceFacade(device_id).is_screen_locked(): - DeviceFacade(device_id).unlock() - if DeviceFacade(device_id).is_screen_locked(): + if not device.get_info()["screenOn"]: + device.press_power() + if device.is_screen_locked(): + device.unlock() + if device.is_screen_locked(): logger.error( "Can't unlock your screen. There may be a passcode on it. If you would like your screen to be turned on and unlocked automatically, please remove the passcode." ) - sys.exit() + exit(0) logger.info("Device screen on and unlocked.") - open_instagram(device_id) + open_instagram() try: profileView = TabBarView(device).navigateToProfile() random_sleep() + if configs.args.username is not None: + success = AccountView(device).changeToUsername(configs.args.username) + if not success: + logger.error( + f"Not able to change to {configs.args.username}, abort!" + ) + device.back() + break + ( session_state.my_username, session_state.my_followers_count, @@ -204,9 +140,9 @@ def run(): ) = profileView.getProfileInfo() if ( - session_state.my_username == None - or session_state.my_followers_count == None - or session_state.my_following_count == None + session_state.my_username is None + or session_state.my_followers_count is None + or session_state.my_following_count is None ): logger.critical( "Could not get one of the following from your profile: username, # of followers, # of followings. This is typically due to a soft ban. Review the crash screenshot to see if this is the case." @@ -231,24 +167,27 @@ def run(): logger.info(report_string, extra={"color": f"{Style.BRIGHT}"}) storage = Storage(session_state.my_username) - for plugin in enabled: + for plugin in configs.enabled: if not session_state.check_limit( - args, limit_type=session_state.Limit.ALL, output=False + configs.args, limit_type=session_state.Limit.ALL, output=False ): - loaded[plugin].run( - device, device_id, args, enabled, storage, sessions, plugin - ) + logger.info(f"Current job: {plugin}", extra={"color": f"{Fore.BLUE}"}) + if ProfileView(device).getUsername() != session_state.my_username: + logger.debug("Not in your main profile.") + TabBarView(device).navigateToProfile() + configs.actions[plugin].run(device, configs, storage, sessions, plugin) + else: logger.info( "Successful or Total Interactions limit reached. Ending session." ) break - close_instagram(device_id) + close_instagram() session_state.finishTime = datetime.now() - if args.screen_sleep: - DeviceFacade(device_id).screen_off() + if configs.args.screen_sleep: + device.screen_off() logger.info("Screen turned off for sleeping time") logger.info( @@ -256,15 +195,15 @@ def run(): extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, ) - if args.repeat: + if configs.args.repeat: print_full_report(sessions) - repeat = get_value(args.repeat, "Sleep for {} minutes", 180) + repeat = get_value(configs.args.repeat, "Sleep for {} minutes", 180) try: sleep(60 * repeat) except KeyboardInterrupt: print_full_report(sessions) sessions.persist(directory=session_state.my_username) - sys.exit(0) + exit(0) else: break diff --git a/GramAddict/core/__init__.py b/GramAddict/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/GramAddict/core/config.py b/GramAddict/core/config.py new file mode 100644 index 00000000..6885652d --- /dev/null +++ b/GramAddict/core/config.py @@ -0,0 +1,142 @@ +import configargparse +import logging +import sys +import yaml + +from GramAddict.core.plugin_loader import PluginLoader + +logger = logging.getLogger(__name__) + + +class Config: + def __init__(self, first_run=False): + self.args = sys.argv + self.config = None + self.config_list = None + self.debug = False + self.device_id = None + self.first_run = first_run + self.username = False + + # Pre-Load Variables Needed for Script Init + if "--config" in self.args: + try: + file_name = self.args[self.args.index("--config") + 1] + with open(file_name) as fin: + # preserve order of yaml + self.config_list = [line.strip() for line in fin] + fin.seek(0) + # pre-load config for debug and username + self.config = yaml.safe_load(fin) + except IndexError: + print("Please provide a filename with your --config argument.") + exit(0) + + self.username = self.config.get("username", False) + self.debug = self.config.get("debug", False) + + if "--debug": + self.debug = True + if "--username" in self.args: + try: + self.username = self.args[self.args.index("--username") + 1] + except IndexError: + print("Please provide a username with your --username argument.") + exit(0) + + # Configure ArgParse + self.parser = configargparse.ArgumentParser( + description="GramAddict Instagram Bot" + ) + self.parser.add( + "-c", + "--config", + required=False, + is_config_file=True, + help="config file path", + ) + + # on first run, we must wait to proceed with loading + if not self.first_run: + self.load_plugins() + self.parse_args() + + def load_plugins(self): + self.plugins = PluginLoader("GramAddict.plugins", self.first_run).plugins + self.actions = {} + for plugin in self.plugins: + if plugin.arguments: + for arg in plugin.arguments: + try: + action = arg.get("action", None) + if action: + self.parser.add_argument( + arg["arg"], + help=arg["help"], + action=arg.get("action", None), + ) + else: + self.parser.add_argument( + arg["arg"], + nargs=arg["nargs"], + help=arg["help"], + metavar=arg["metavar"], + default=arg["default"], + ) + if arg.get("operation", False): + self.actions[arg["arg"][2:]] = plugin + except Exception as e: + logger.error( + f"Error while importing arguments of plugin {plugin.__class__.__name__}. Error: Missing key from arguments dictionary - {e}" + ) + + def parse_args(self): + def _is_legacy_arg(arg): + if arg == "interact" or arg == "hashtag-likers": + if self.first_run: + logger.warn( + f"You are using a legacy argument {arg} that is no longer supported. It will not be used. Please refer to https://docs.gramaddict.org/#/configuration?id=arguments." + ) + return True + return False + + self.enabled = [] + if self.first_run: + logger.debug(f"Arguments used: {' '.join(sys.argv[1:])}") + if self.config: + logger.debug(f"Config used: {self.config}") + if not len(sys.argv) > 1: + self.parser.print_help() + exit(0) + + self.args, self.unknown_args = self.parser.parse_known_args() + + if self.unknown_args and self.first_run: + logger.error( + "Unknown arguments: " + ", ".join(str(arg) for arg in self.unknown_args) + ) + self.parser.print_help() + exit(0) + + self.device_id = self.args.device + + # We need to maintain the order of plugins as defined + # in config or sys.argv + if self.config_list: + for item in self.config_list: + item = item.split(":")[0] + if ( + item in self.actions + and getattr(self.args, item.replace("-", "_")) != None + and not _is_legacy_arg(item) + ): + self.enabled.append(item) + else: + for item in sys.argv: + nitem = item[2:] + if ( + nitem in self.actions + and getattr(self.args, nitem.replace("-", "_")) != None + and not _is_legacy_arg(nitem) + ): + self.enabled.append(nitem) diff --git a/GramAddict/core/decorators.py b/GramAddict/core/decorators.py index a1ac637b..a8bd8689 100644 --- a/GramAddict/core/decorators.py +++ b/GramAddict/core/decorators.py @@ -6,7 +6,7 @@ from http.client import HTTPException from socket import timeout -from uiautomator2.exceptions import UiObjectNotFoundError +from uiautomator2.exceptions import UiObjectNotFoundError as UiObjectNotFoundErrorv2 from GramAddict.core.device_facade import DeviceFacade from GramAddict.core.report import print_full_report @@ -28,28 +28,56 @@ def wrapper(*args, **kwargs): try: func(*args, **kwargs) except KeyboardInterrupt: - close_instagram(device_id) - logger.info( - f"-------- FINISH: {datetime.now().time()} --------", - extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, - ) - print_full_report(sessions) - sessions.persist(directory=session_state.my_username) - sys.exit(0) + try: + # Catch Ctrl-C and ask if user wants to pause execution + logger.info( + "CTRL-C detected . . .", + extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, + ) + logger.info( + f"-------- PAUSED: {datetime.now().time()} --------", + extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, + ) + logger.info( + "NOTE: This is a rudimentary pause. It will restart the action, while retaining session data.", + extra={"color": Style.BRIGHT}, + ) + logger.info( + "Press RETURN to resume or CTRL-C again to Quit: ", + extra={"color": Style.BRIGHT}, + ) + + input("") + + logger.info( + f"-------- RESUMING: {datetime.now().time()} --------", + extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, + ) + TabBarView(device).navigateToProfile() + except KeyboardInterrupt: + close_instagram() + logger.info( + f"-------- FINISH: {datetime.now().time()} --------", + extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}, + ) + print_full_report(sessions) + sessions.persist(directory=session_state.my_username) + sys.exit(0) + except ( DeviceFacade.JsonRpcError, IndexError, HTTPException, timeout, - UiObjectNotFoundError, + UiObjectNotFoundErrorv2, ): logger.error(traceback.format_exc()) save_crash(device) logger.info("No idea what it was. Let's try again.") # Hack for the case when IGTV was accidentally opened - close_instagram(device_id) + close_instagram() random_sleep() - open_instagram(device_id) + open_instagram() TabBarView(device).navigateToProfile() except LanguageNotEnglishException: logger.info( @@ -59,7 +87,7 @@ def wrapper(*args, **kwargs): except Exception as e: logger.error(traceback.format_exc()) save_crash(device) - close_instagram(device_id) + close_instagram() print_full_report(sessions) sessions.persist(directory=session_state.my_username) raise e diff --git a/GramAddict/core/device_facade.py b/GramAddict/core/device_facade.py index 540f26ec..2b444106 100644 --- a/GramAddict/core/device_facade.py +++ b/GramAddict/core/device_facade.py @@ -4,9 +4,7 @@ from random import uniform from re import search from time import sleep - -import uiautomator2 -from uiautomator2 import Device +from GramAddict.core.utils import random_sleep logger = logging.getLogger(__name__) @@ -15,53 +13,92 @@ UI_TIMEOUT_SHORT = 1 -def create_device(device_id): - logger.debug("Using uiautomator v2") +def create_device(device_id, version=2): + logger.info(f"Using uiautomator v{version}") try: - return DeviceFacade(device_id) + return DeviceFacade(int(version), device_id) except ImportError as e: logger.error(str(e)) return None class DeviceFacade: + deviceV1 = None # uiautomator deviceV2 = None # uiautomator2 - def __init__(self, device_id): + def __init__(self, version, device_id): self.device_id = device_id + if version == 1: + try: + import uiautomator - try: - self.deviceV2 = ( - uiautomator2.connect() - if device_id is None - else uiautomator2.connect(device_id) - ) - except ImportError: - raise ImportError("Please install uiautomator2: pip3 install uiautomator2") + self.deviceV1 = ( + uiautomator.device + if device_id is None + else uiautomator.Device(device_id) + ) + except ImportError: + raise ImportError( + "Please install uiautomator: pip3 install uiautomator" + ) + else: + try: + import uiautomator2 + + self.deviceV2 = ( + uiautomator2.connect() + if device_id is None + else uiautomator2.connect(device_id) + ) + except ImportError: + raise ImportError( + "Please install uiautomator2: pip3 install uiautomator2" + ) def find(self, *args, **kwargs): + if self.deviceV1 is not None: + import uiautomator + + try: + view = self.deviceV1(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, view=view, device=self.deviceV1) + else: + import uiautomator2 - try: - view = self.deviceV2(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.deviceV2(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=2, view=view, device=self.deviceV2) def back(self): - self.deviceV2.press("back") + if self.deviceV1 is not None: + self.deviceV1.press.back() + else: + self.deviceV2.press("back") def screenshot(self, path): - self.deviceV2.screenshot(path) + if self.deviceV1 is not None: + self.deviceV1.screenshot(path) + else: + self.deviceV2.screenshot(path) def dump_hierarchy(self, path): - xml_dump = "" - xml_dump = self.deviceV2.dump_hierarchy() + if self.deviceV1 is not None: + xml_dump = self.deviceV1.dump() + else: + xml_dump = self.deviceV2.dump_hierarchy() with open(path, "w", encoding="utf-8") as outfile: outfile.write(xml_dump) def press_power(self): - self.deviceV2.press("power") + if self.deviceV1 is not None: + self.deviceV1.press.power() + else: + self.deviceV2.press("power") def is_screen_locked(self): status = popen( @@ -72,22 +109,125 @@ def is_screen_locked(self): return True if flag.group(1) == "true" else False def is_alive(self): + # v2 only - for atx_agent 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 + # v2 only - for atx_agent + if self.deviceV2 is not None: + 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) + random_sleep(1, 1) if self.is_screen_locked(): self.swipe(DeviceFacade.Direction.RIGHT, 0.8) def screen_off(self): - self.deviceV2.screen_off() + if self.deviceV1 is not None: + self.deviceV1.screen.off() + else: + self.deviceV2.screen_off() + + def get_orientation(self): + """ + Rotaion of the phone + 0: normal + 1: home key on the right + 2: home key on the top + 3: home key on the left + """ + if self.deviceV1 is not None: + import uiautomator, re + + try: + # code based on _get_orientation() of uiautomator2 + _DISPLAY_RE = re.compile( + r".*DisplayViewport{valid=true, .*orientation=(?P\d+), .*deviceWidth=(?P\d+), deviceHeight=(?P\d+).*" + ) + self.shell("dumpsys display") + for line in self.shell(["dumpsys", "display"]).output.splitlines(): + m = _DISPLAY_RE.search(line, 0) + if not m: + continue + # w = int(m.group('width')) + # h = int(m.group('height')) + o = int(m.group("orientation")) + # w, h = min(w, h), max(w, h) + return o + return self.get_info()["displayRotation"] + except uiautomator.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + return self.deviceV2._get_orientation() + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + def window_size(self): + """ return (width, height) """ + if self.deviceV1 is not None: + import uiautomator + + try: + # code extracted from uiautomator2 window_size() + info = self.get_info() + w, h = info["displayWidth"], info["displayHeight"] + rotation = self.get_orientation() + if (w > h) != (rotation % 2 == 1): + w, h = h, w + return w, h + except uiautomator.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + self.deviceV2.window_size() + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + def _swipe_ext_v1(self, direction: str, scale=0.5): + """ + Args: + direction (str): one of "left", "right", "up", "bottom" or Direction.LEFT + scale (float): percent of swipe, range (0, 1.0] + Raises: + ValueError + """ + + def _swipe(_from, _to): + self.deviceV1.swipe(_from[0], _from[1], _to[0], _to[1], steps=55) + + lx, ly = 0, 0 + rx, ry = self.window_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 == "left": + _swipe(right, left) + elif direction == "right": + _swipe(left, right) + elif direction == "up": + _swipe(bottom, up) + elif direction == "down": + _swipe(up, bottom) + else: + raise ValueError("Unknown direction:", direction) def swipe(self, direction: "DeviceFacade.Direction", scale=0.5): """Swipe finger in the `direction`. @@ -104,126 +244,244 @@ def swipe(self, direction: "DeviceFacade.Direction", scale=0.5): swipe_dir = "down" logger.debug(f"Swipe {swipe_dir}, scale={scale}") - self.deviceV2.swipe_ext(swipe_dir, scale=scale) - def swipe_points(self, sx, sy, ex, ey): - try: - self.deviceV2.swipe_points([[sx, sy], [ex, ey]], uniform(0.2, 0.6)) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + if self.deviceV1 is not None: + import uiautomator + + try: + self._swipe_ext_v1(swipe_dir, scale=scale) + except uiautomator.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + self.deviceV2.swipe_ext(swipe_dir, scale=scale) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + def swipe_points(self, sx, sy, ex, ey, random_x=True, random_y=True): + if random_x: + sx = sx * uniform(0.60, 1.40) + ex = sx * uniform(0.85, 1.15) + if random_y: + ey = ey * uniform(0.98, 1.02) + if self.deviceV1 is not None: + import uiautomator + + try: + self.deviceV1.swipe(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.4, 0.6)) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def get_info(self): # {'currentPackageName': 'net.oneplus.launcher', 'displayHeight': 1920, 'displayRotation': 0, 'displaySizeDpX': 411, # 'displaySizeDpY': 731, 'displayWidth': 1080, 'productName': 'OnePlus5', ' # screenOn': True, 'sdkInt': 27, 'naturalOrientation': True} - return self.deviceV2.info + if self.deviceV1 is not None: + return self.deviceV1.info + else: + return self.deviceV2.info class View: - deviceV2: Device = None # uiautomator2 + deviceV1 = None # uiautomator + deviceV2 = None # uiautomator2 + viewV1 = None # uiautomator viewV2 = None # uiautomator2 - def __init__(self, view, device): - self.viewV2 = view - self.deviceV2 = device + def __init__(self, version, view, device): + if version == 1: + self.viewV1 = view + self.deviceV1 = device + else: + self.viewV2 = view + self.deviceV2 = device def __iter__(self): children = [] + if self.viewV1 is not None: + import uiautomator - try: - for item in self.viewV2: - children.append(DeviceFacade.View(view=item, device=self.deviceV2)) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + for item in self.viewV1: + children.append( + DeviceFacade.View( + version=1, view=item, device=self.deviceV1 + ) + ) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + for item in self.viewV2: + children.append( + DeviceFacade.View( + version=2, view=item, device=self.deviceV2 + ) + ) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) return iter(children) def child(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator - try: - view = self.viewV2.child(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.viewV1.child(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, view=view, device=self.deviceV1) + else: + import uiautomator2 + + try: + view = self.viewV2.child(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=2, view=view, device=self.deviceV2) def left(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator - try: - view = self.viewV2.left(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.viewV1.left(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, 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(version=2, view=view, device=self.deviceV2) def right(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator - try: - view = self.viewV2.right(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.viewV1.right(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, view=view, device=self.deviceV1) + else: + import uiautomator2 + + try: + view = self.viewV2.right(*args, **kwargs) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=2, view=view, device=self.deviceV2) def up(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator - try: - view = self.viewV2.up(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.viewV1.up(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, 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(version=2, view=view, device=self.deviceV2) def down(self, *args, **kwargs): + if self.viewV1 is not None: + import uiautomator - try: - view = self.viewV2.down(*args, **kwargs) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) - return DeviceFacade.View(view=view, device=self.deviceV2) + try: + view = self.viewV1.down(*args, **kwargs) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return DeviceFacade.View(version=1, 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(version=2, view=view, device=self.deviceV2) def click(self, mode=None): - mode = self.Location.WHOLE if mode == None else mode - x_abs = -1 - y_abs = -1 - if mode == self.Location.WHOLE: - x_offset = uniform(0.15, 0.85) - y_offset = uniform(0.15, 0.85) + if self.viewV1 is not None: + import uiautomator - elif mode == self.Location.LEFT: - x_offset = uniform(0.15, 0.4) - y_offset = uniform(0.15, 0.85) + try: + self.viewV1.click.wait() + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 - elif mode == self.Location.CENTER: - x_offset = uniform(0.4, 0.6) - y_offset = uniform(0.15, 0.85) + mode = self.Location.WHOLE if mode is None else mode + x_abs = -1 + y_abs = -1 + if mode == self.Location.WHOLE: + x_offset = uniform(0.15, 0.85) + y_offset = uniform(0.15, 0.85) - elif mode == self.Location.RIGHT: - x_offset = uniform(0.6, 0.85) - y_offset = uniform(0.15, 0.85) + elif mode == self.Location.LEFT: + x_offset = uniform(0.15, 0.4) + y_offset = uniform(0.15, 0.85) - else: - x_offset = 0.5 - y_offset = 0.5 + elif mode == self.Location.CENTER: + x_offset = uniform(0.4, 0.6) + y_offset = uniform(0.15, 0.85) - try: - visible_bounds = self.get_bounds() - x_abs = int( - visible_bounds["left"] - + (visible_bounds["right"] - visible_bounds["left"]) * x_offset - ) - y_abs = int( - visible_bounds["top"] - + (visible_bounds["bottom"] - visible_bounds["top"]) * y_offset - ) - logger.debug(f"Single click ({x_abs}, {y_abs})") - self.viewV2.click(UI_TIMEOUT_LONG, offset=(x_offset, y_offset)) + elif mode == self.Location.RIGHT: + x_offset = uniform(0.6, 0.85) + y_offset = uniform(0.15, 0.85) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + else: + x_offset = 0.5 + y_offset = 0.5 - def double_click(self, padding=0.3): + try: + visible_bounds = self.get_bounds() + x_abs = int( + visible_bounds["left"] + + (visible_bounds["right"] - visible_bounds["left"]) * x_offset + ) + y_abs = int( + visible_bounds["top"] + + (visible_bounds["bottom"] - visible_bounds["top"]) * y_offset + ) + logger.debug(f"Single click ({x_abs}, {y_abs})") + self.viewV2.click(UI_TIMEOUT_LONG, offset=(x_offset, y_offset)) + + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + + def double_click(self, padding=0.3, obj_over=0): """Double click randomly in the selected view using padding padding: % of how far from the borders we want the double click to happen. """ visible_bounds = self.get_bounds() horizontal_len = visible_bounds["right"] - visible_bounds["left"] - vertical_len = visible_bounds["bottom"] - visible_bounds["top"] + vertical_len = visible_bounds["bottom"] - max( + visible_bounds["top"], obj_over + ) horizintal_padding = int(padding * horizontal_len) vertical_padding = int(padding * vertical_len) random_x = int( @@ -238,101 +496,225 @@ def double_click(self, padding=0.3): visible_bounds["bottom"] - vertical_padding, ) ) - time_between_clicks = uniform(0.050, 0.200) - try: - logger.debug( - f"Double click in x={random_x}; y={random_y} with t={int(time_between_clicks*1000)}ms" + logger.debug( + f"Available surface for double click ({visible_bounds['left']}-{visible_bounds['right']},{visible_bounds['top']}-{visible_bounds['bottom']})" + ) + if self.viewV1 is not None: + import uiautomator + + config = self.deviceV1.server.jsonrpc.getConfigurator() + config["actionAcknowledgmentTimeout"] = 40 + self.deviceV1.server.jsonrpc.setConfigurator(config) + try: + self.viewV1.click() + self.viewV1.click() + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + config["actionAcknowledgmentTimeout"] = 3000 + self.deviceV1.server.jsonrpc.setConfigurator(config) + else: + import uiautomator2 + + visible_bounds = self.get_bounds() + 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, + ) ) - self.deviceV2.double_click( - random_x, random_y, duration=time_between_clicks + random_y = int( + uniform( + visible_bounds["top"] + vertical_padding, + visible_bounds["bottom"] - vertical_padding, + ) ) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + time_between_clicks = uniform(0.050, 0.200) + + try: + logger.debug( + f"Double click in ({random_x},{random_y}) with t={int(time_between_clicks*1000)}ms" + ) + self.deviceV2.double_click( + random_x, random_y, duration=time_between_clicks + ) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def scroll(self, direction): + if self.viewV1 is not None: + import uiautomator - try: - if direction == DeviceFacade.Direction.TOP: - self.viewV2.scroll.toBeginning(max_swipes=1) - else: - self.viewV2.scroll.toEnd(max_swipes=1) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + if direction == DeviceFacade.Direction.TOP: + self.viewV1.scroll.toBeginning(max_swipes=1) + else: + self.viewV1.scroll.toEnd(max_swipes=1) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + if direction == DeviceFacade.Direction.TOP: + self.viewV2.scroll.toBeginning(max_swipes=1) + else: + self.viewV2.scroll.toEnd(max_swipes=1) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def fling(self, direction): + if self.viewV1 is not None: + import uiautomator - try: - if direction == DeviceFacade.Direction.TOP: - self.viewV2.fling.toBeginning(max_swipes=5) - else: - self.viewV2.fling.toEnd(max_swipes=5) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + if direction == DeviceFacade.Direction.TOP: + self.viewV1.fling.toBeginning(max_swipes=5) + else: + self.viewV1.fling.toEnd(max_swipes=5) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + if direction == DeviceFacade.Direction.TOP: + self.viewV2.fling.toBeginning(max_swipes=5) + else: + self.viewV2.fling.toEnd(max_swipes=5) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def exists(self, quick=False): + if self.viewV1 is not None: + import uiautomator - try: - # Currently the methods left, rigth, up and down from - # uiautomator2 return None when a Selector does not exist. - # All other selectors return an UiObject with exists() == False. - # We will open a ticket to uiautomator2 to fix this incosistency. - if self.viewV2 == None: - return False - - return self.viewV2.exists( - UI_TIMEOUT_SHORT if quick else UI_TIMEOUT_LONG - ) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + return self.viewV1.exists + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + # Currently the methods left, rigth, up and down from + # uiautomator2 return None when a Selector does not exist. + # All other selectors return an UiObject with exists() == False. + # We will open a ticket to uiautomator2 to fix this incosistency. + if self.viewV2 is None: + return False + return self.viewV2.exists( + UI_TIMEOUT_SHORT if quick else UI_TIMEOUT_LONG + ) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def wait(self): + if self.viewV1 is not None: + import uiautomator - try: - return self.viewV2.wait(timeout=UI_TIMEOUT_LONG) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + self.deviceV1.wait.idle() + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + return True + else: + import uiautomator2 + + try: + return self.viewV2.wait(timeout=UI_TIMEOUT_LONG) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def get_bounds(self): + if self.viewV1 is not None: + import uiautomator - try: - return self.viewV2.info["bounds"] - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + return self.viewV1.bounds + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + return self.viewV2.info["bounds"] + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def get_text(self, retry=True): max_attempts = 1 if not retry else 3 attempts = 0 while attempts < max_attempts: attempts += 1 - try: - text = self.viewV2.info["text"] - if text == None: - logger.debug( - "Could not get text. Waiting 2 seconds and trying again..." - ) - sleep(2) # wait 2 seconds and retry - else: - return text - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + if self.viewV1 is not None: + import uiautomator + + try: + text = self.viewV1.text + if text is None: + logger.debug( + "Could not get text. Waiting 2 seconds and trying again..." + ) + sleep(2) # wait 2 seconds and retry + else: + return text + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + text = self.viewV2.info["text"] + if text is None: + logger.debug( + "Could not get text. Waiting 2 seconds and trying again..." + ) + sleep(2) # wait 2 seconds and retry + else: + return text + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) logger.error( f"Attempted to get text {attempts} times. You may have a slow network or are experiencing another problem." ) return "" def get_selected(self) -> bool: + if self.viewV1 is not None: + import uiautomator - try: - return self.viewV2.info["selected"] - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + try: + return self.viewV1.info["selected"] + except uiautomator.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + return self.viewV2.info["selected"] + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) def set_text(self, text): - try: - self.viewV2.set_text(text) - except uiautomator2.JSONRPCError as e: - raise DeviceFacade.JsonRpcError(e) + if self.viewV1 is not None: + import uiautomator + + try: + self.viewV1.set_text(text) + except uiautomator.JsonRPCError as e: + raise DeviceFacade.JsonRpcError(e) + else: + import uiautomator2 + + try: + self.viewV2.set_text(text) + except uiautomator2.JSONRPCError as e: + raise DeviceFacade.JsonRpcError(e) class Location(Enum): WHOLE = auto() diff --git a/GramAddict/core/filter.py b/GramAddict/core/filter.py index 63259ed0..83c081ad 100644 --- a/GramAddict/core/filter.py +++ b/GramAddict/core/filter.py @@ -5,13 +5,16 @@ import unicodedata from colorama import Fore -from GramAddict.core.views import ProfileView +from GramAddict.core.views import ProfileView, FollowStatus, OpenedPostView +from GramAddict.core.resources import ClassName, ResourceID as resources logger = logging.getLogger(__name__) FILENAME_CONDITIONS = "filter.json" FIELD_SKIP_BUSINESS = "skip_business" FIELD_SKIP_NON_BUSINESS = "skip_non_business" +FIELD_SKIP_FOLLOWING = "skip_following" +FIELD_SKIP_FOLLOWER = "skip_follower" FIELD_MIN_FOLLOWERS = "min_followers" FIELD_MAX_FOLLOWERS = "max_followers" FIELD_MIN_FOLLOWINGS = "min_followings" @@ -28,6 +31,15 @@ IGNORE_CHARSETS = ["MATHEMATICAL"] +def load_config(config): + global args + global configs + global ResourceID + args = config.args + configs = config + ResourceID = resources(config.args.app_id) + + class Filter: conditions = None @@ -36,6 +48,24 @@ def __init__(self): with open(FILENAME_CONDITIONS) as json_file: self.conditions = json.load(json_file) + def check_profile_from_list(self, device, item, username): + if self.conditions is None: + return True + + field_skip_following = self.conditions.get(FIELD_SKIP_FOLLOWING, False) + + if field_skip_following: + following = OpenedPostView(device)._isFollowing(item) + + if following: + logger.info( + f"You follow @{username}, skip.", + extra={"color": f"{Fore.GREEN}"}, + ) + return False + + return True + def check_profile(self, device, username): """ This method assumes being on someone's profile already. @@ -45,6 +75,8 @@ def check_profile(self, device, username): field_skip_business = self.conditions.get(FIELD_SKIP_BUSINESS, False) field_skip_non_business = self.conditions.get(FIELD_SKIP_NON_BUSINESS, False) + field_skip_following = self.conditions.get(FIELD_SKIP_FOLLOWING, False) + field_skip_follower = self.conditions.get(FIELD_SKIP_FOLLOWER, False) field_min_followers = self.conditions.get(FIELD_MIN_FOLLOWERS) field_max_followers = self.conditions.get(FIELD_MAX_FOLLOWERS) field_min_followings = self.conditions.get(FIELD_MIN_FOLLOWINGS) @@ -59,6 +91,26 @@ def check_profile(self, device, username): field_specific_alphabet = self.conditions.get(FIELD_SPECIFIC_ALPHABET) field_min_posts = self.conditions.get(FIELD_MIN_POSTS) + if field_skip_following or field_skip_follower: + profileView = ProfileView(device) + button, text = profileView.getFollowButton() + + if field_skip_following: + if text == FollowStatus.FOLLOWING: + logger.info( + f"You follow @{username}, skip.", + extra={"color": f"{Fore.GREEN}"}, + ) + return False + + if field_skip_follower: + if text == FollowStatus.FOLLOW_BACK: + logger.info( + f"@{username} follows you, skip.", + extra={"color": f"{Fore.GREEN}"}, + ) + return False + if field_interact_only_private: logger.debug("Checking if account is private...") is_private = self._is_private_account(device) @@ -273,10 +325,10 @@ def _get_followers_and_followings(device): @staticmethod def _has_business_category(device): business_category_view = device.find( - resourceId="com.instagram.android:id/profile_header_business_category", - className="android.widget.TextView", + resourceId=ResourceID.PROFILE_HEADER_BUSINESS_CATEGORY, + className=ClassName.TEXT_VIEW, ) - return business_category_view.exists() + return business_category_view.exists(True) @staticmethod def _is_private_account(device): diff --git a/GramAddict/core/interaction.py b/GramAddict/core/interaction.py index 86d09545..d1c613c1 100644 --- a/GramAddict/core/interaction.py +++ b/GramAddict/core/interaction.py @@ -3,25 +3,35 @@ from typing import Tuple from time import time from colorama import Fore -from GramAddict.core.device_facade import DeviceFacade from GramAddict.core.navigation import switch_to_english from GramAddict.core.report import print_short_report +from GramAddict.core.resources import ClassName, ResourceID as resources from GramAddict.core.utils import detect_block, get_value, random_sleep, save_crash from GramAddict.core.views import ( LanguageNotEnglishException, ProfileView, CurrentStoryView, PostsGridView, + UniversalActions, + Direction, ) logger = logging.getLogger(__name__) -BUTTON_REGEX = "android.widget.Button" FOLLOW_REGEX = "^Follow$" FOLLOWBACK_REGEX = "^Follow Back$" UNFOLLOW_REGEX = "^Following|^Requested" +def load_config(config): + global args + global configs + global ResourceID + args = config.args + configs = config + ResourceID = resources(config.args.app_id) + + def interact_with_user( device, username, @@ -36,6 +46,7 @@ def interact_with_user( profile_filter, args, session_state, + current_mode, ) -> Tuple[bool, bool]: """ :return: (whether interaction succeed, whether @username was followed during the interaction) @@ -63,11 +74,13 @@ def interact_with_user( private_empty = "Private" if is_private else "Empty" logger.info(f"{private_empty} account.", extra={"color": f"{Fore.GREEN}"}) if can_follow and profile_filter.can_follow_private_or_empty(): - followed = _follow(device, username, follow_percentage, args, session_state) + followed = _follow( + device, username, follow_percentage, args, session_state, 0 + ) + return True, followed else: - followed = False logger.info("Skip user.", extra={"color": f"{Fore.GREEN}"}) - return False, followed + return False, False _watch_stories( device, @@ -80,17 +93,22 @@ def interact_with_user( session_state, ) - ProfileView(device).swipe_to_fit_posts() + swipe_amount = ProfileView(device).swipe_to_fit_posts() random_sleep() start_time = time() full_rows, columns_last_row = profile_view.count_photo_in_view() end_time = format(time() - start_time, ".2f") photos_indices = list(range(0, full_rows * 3 + (columns_last_row))) + logger.info( f"There are {len(photos_indices)} posts fully visible. Calculated in {end_time}s" ) + if current_mode == "hashtag-posts-recent" or current_mode == "hashtag-posts-top": + session_state.totalLikes += 1 + photos_indices = photos_indices[1:] + if likes_value > len(photos_indices): - logger.info(f"Only {photos_indices} photos available") + logger.info(f"Only {len(photos_indices)} photo(s) available") else: shuffle(photos_indices) photos_indices = photos_indices[:likes_value] @@ -128,7 +146,12 @@ def interact_with_user( if can_follow and profile_filter.can_follow_private_or_empty(): followed = _follow( - device, username, follow_percentage, args, session_state + device, + username, + follow_percentage, + args, + session_state, + swipe_amount, ) else: followed = False @@ -139,7 +162,9 @@ def interact_with_user( random_sleep() if can_follow: - return True, _follow(device, username, follow_percentage, args, session_state) + return True, _follow( + device, username, follow_percentage, args, session_state, swipe_amount + ) return True, False @@ -212,7 +237,7 @@ def _on_interaction( return can_continue -def _follow(device, username, follow_percentage, args, session_state): +def _follow(device, username, follow_percentage, args, session_state, swipe_amount): if not session_state.check_limit( args, limit_type=session_state.Limit.FOLLOWS, output=False ): @@ -220,29 +245,28 @@ def _follow(device, username, follow_percentage, args, session_state): if follow_chance > follow_percentage: return False - logger.info("Following...") - coordinator_layout = device.find( - resourceId="com.instagram.android:id/coordinator_root_layout" - ) - if coordinator_layout.exists(): - coordinator_layout.scroll(DeviceFacade.Direction.TOP) + coordinator_layout = device.find(resourceId=ResourceID.COORDINATOR_ROOT_LAYOUT) + if coordinator_layout.exists() and swipe_amount != 0: + UniversalActions(device)._swipe_points( + direction=Direction.UP, delta_y=swipe_amount + ) random_sleep() follow_button = device.find( - classNameMatches=BUTTON_REGEX, + classNameMatches=ClassName.BUTTON, clickable=True, textMatches=FOLLOW_REGEX, ) if not follow_button.exists(): unfollow_button = device.find( - classNameMatches=BUTTON_REGEX, + classNameMatches=ClassName.BUTTON, clickable=True, textMatches=UNFOLLOW_REGEX, ) followback_button = device.find( - classNameMatches=BUTTON_REGEX, + classNameMatches=ClassName.BUTTON, clickable=True, textMatches=FOLLOWBACK_REGEX, ) diff --git a/GramAddict/core/log.py b/GramAddict/core/log.py index c825670a..0dbd8c2e 100644 --- a/GramAddict/core/log.py +++ b/GramAddict/core/log.py @@ -54,17 +54,23 @@ def create_log_file_handler(filename): return file_handler -def configure_logger(): +def configure_logger(debug, username): global g_session_id global g_log_file_name global g_logs_dir global g_file_handler global g_log_file_updated + console_level = logging.DEBUG if debug else logging.INFO + g_session_id = uuid4() g_logs_dir = "logs" - g_log_file_name = f"{g_session_id}.log" - g_log_file_updated = False + if username: + g_log_file_name = f"{username}.log" + g_log_file_updated = True + else: + g_log_file_name = f"{g_session_id}.log" + g_log_file_updated = False init_colorama() @@ -74,7 +80,7 @@ def configure_logger(): # Console logger (limited but colored log) console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) + console_handler.setLevel(console_level) console_handler.setFormatter( ColoredFormatter( fmt="%(asctime)s %(levelname)8s | %(message)s", datefmt="[%m/%d %H:%M:%S]" diff --git a/GramAddict/core/plugin_loader.py b/GramAddict/core/plugin_loader.py index 27173294..21467c89 100644 --- a/GramAddict/core/plugin_loader.py +++ b/GramAddict/core/plugin_loader.py @@ -16,14 +16,16 @@ def run(self): class PluginLoader(object): - def __init__(self, plugin_package): + def __init__(self, plugin_package, first_run): self.plugin_package = plugin_package + self.output = first_run self.reload_plugins() def reload_plugins(self): self.plugins = [] self.seen_paths = [] - logger.info("Loading plugins . . .") + if self.output: + logger.info("Loading plugins . . .") self.walk_package(self.plugin_package) def walk_package(self, package): @@ -37,5 +39,6 @@ def walk_package(self, package): clsmembers = inspect.getmembers(plugin_module, inspect.isclass) for (_, c) in clsmembers: if issubclass(c, Plugin) & (c is not Plugin): - logger.info(f" - {c.__name__}: {c.__doc__}") + if self.output: + logger.info(f" - {c.__name__}: {c.__doc__}") self.plugins.append(c()) diff --git a/GramAddict/core/resources.py b/GramAddict/core/resources.py new file mode 100644 index 00000000..f592c861 --- /dev/null +++ b/GramAddict/core/resources.py @@ -0,0 +1,126 @@ +class ResourceID: + def __init__(self, APP_ID): + self.ACTION_BAR_CONTAINER = f"{APP_ID}:id/action_bar_container" + self.ACTION_BAR_LARGE_TITLE = f"{APP_ID}:id/action_bar_large_title" + self.ACTION_BAR_NEW_TITLE_CONTAINER = ( + f"{APP_ID}:id/action_bar_new_title_container" + ) + self.ACTION_BAR_SEARCH_EDIT_TEXT = f"{APP_ID}:id/action_bar_search_edit_text" + self.ACTION_BAR_TEXTVIEW_TITLE = f"{APP_ID}:id/action_bar_textview_title" + self.ACTION_BAR_TITLE = f"{APP_ID}:id/action_bar_title" + self.BOTTOM_SHEET_CONTAINER_VIEW = f"{APP_ID}:id/bottom_sheet_container_view" + self.BUTTON = f"{APP_ID}:id/button" + self.CAROUSEL_MEDIA_GROUP = f"{APP_ID}:id/carousel_media_group" + self.COORDINATOR_ROOT_LAYOUT = f"{APP_ID}:id/coordinator_root_layout" + self.DIALOG_ROOT_VIEW = f"{APP_ID}:id/dialog_root_view" + self.FIXED_TABBAR_TABS_CONTAINER = f"{APP_ID}:id/fixed_tabbar_tabs_container" + self.FOLLOW_LIST_CONTAINER = f"{APP_ID}:id/follow_list_container" + self.FOLLOW_LIST_SORTING_OPTIONS_RECYCLER_VIEW = ( + f"{APP_ID}:id/follow_list_sorting_options_recycler_view" + ) + self.FOLLOW_LIST_USERNAME = f"{APP_ID}:id/follow_list_username" + self.FOLLOW_SHEET_UNFOLLOW_ROW = f"{APP_ID}:id/follow_sheet_unfollow_row" + self.FOOTER_SPACE = f"{APP_ID}:id/footer_space" + self.GAP_VIEW = f"{APP_ID}:id/gap_view" + self.IGDS_HEADLINE_EMPHASIZED_HEADLINE = ( + f"{APP_ID}:id/igds_headline_emphasized_headline" + ) + self.IMAGE_BUTTON = f"{APP_ID}:id/image_button" + self.LANGUAGE_LIST_LOCALE = f"{APP_ID}:id/language_locale_list" + self.LIST = "android:id/list" + self.MEDIA_GROUP = f"{APP_ID}:id/media_group" + self.MENU_SETTINGS_ROW = f"{APP_ID}:id/menu_settings_row" + self.PRIVATE_PROFILE_EMPTY_STATE = f"{APP_ID}:id/private_profile_empty_state" + self.PROFILE_HEADER_BIO_TEXT = f"{APP_ID}:id/profile_header_bio_text" + self.PROFILE_HEADER_BUSINESS_CATEGORY = ( + f"{APP_ID}:id/profile_header_business_category" + ) + self.PROFILE_HEADER_FULL_NAME = f"{APP_ID}:id/profile_header_full_name" + self.PROFILE_TAB_LAYOUT = f"{APP_ID}:id/profile_tab_layout" + self.PROFILE_TAB_ICON_VIEW = f"{APP_ID}:id/profile_tab_icon_view" + self.PROFILE_TABS_CONTAINER = f"{APP_ID}:id/profile_tabs_container" + self.REEL_RING = f"{APP_ID}:id/reel_ring" + self.REEL_VIEWER_IMAGE_VIEW = f"{APP_ID}:id/reel_viewer_image_view" + self.REEL_VIEWER_TIMESTAMP = f"{APP_ID}:id/reel_viewer_timestamp" + self.REEL_VIEWER_TITLE = f"{APP_ID}:id/reel_viewer_title" + self.ROW_FEED_BUTTON_COMMENT = f"{APP_ID}:id/row_feed_button_comment" + self.ROW_FEED_BUTTON_LIKE = f"{APP_ID}:id/row_feed_button_like" + self.ROW_FEED_COMMENT_TEXTVIEW_LAYOUT = ( + f"{APP_ID}:id/row_feed_comment_textview_layout" + ) + self.ROW_FEED_PHOTO_PROFILE_NAME = f"{APP_ID}:id/row_feed_photo_profile_name" + self.ROW_FEED_TEXTVIEW_LIKES = f"{APP_ID}:id/row_feed_textview_likes" + self.ROW_HASHTAG_TEXTVIEW_TAG_NAME = ( + f"{APP_ID}:id/row_hashtag_textview_tag_name" + ) + self.ROW_LOAD_MORE_BUTTON = f"{APP_ID}:id/row_load_more_button" + self.ROW_PROFILE_HEADER_EMPTY_PROFILE_NOTICE_CONTAINER = ( + f"{APP_ID}:id/row_profile_header_empty_profile_notice_container" + ) + self.ROW_PROFILE_HEADER_EMPTY_PROFILE_NOTICE_TITLE = ( + f"{APP_ID}:id/row_profile_header_empty_profile_notice_title" + ) + self.ROW_PROFILE_HEADER_FOLLOWERS_CONTAINER = f"{APP_ID}:id/row_profile_header_followers_container|{APP_ID}:id/row_profile_header_container_followers" + self.ROW_PROFILE_HEADER_FOLLOWING_CONTAINER = f"{APP_ID}:id/row_profile_header_following_container|{APP_ID}:id/row_profile_header_container_following" + self.ROW_PROFILE_HEADER_IMAGEVIEW = f"{APP_ID}:id/row_profile_header_imageview" + self.ROW_PROFILE_HEADER_TEXTVIEW_FOLLOWERS_COUNT = ( + f"{APP_ID}:id/row_profile_header_textview_followers_count" + ) + self.ROW_PROFILE_HEADER_TEXTVIEW_FOLLOWING_COUNT = ( + f"{APP_ID}:id/row_profile_header_textview_following_count" + ) + self.ROW_PROFILE_HEADER_TEXTVIEW_POST_COUNT = ( + f"{APP_ID}:id/row_profile_header_textview_post_count" + ) + self.ROW_SEARCH_EDIT_TEXT = f"{APP_ID}:id/row_search_edit_text" + self.ROW_SEARCH_USER_USERNAME = f"{APP_ID}:id/row_search_user_username" + self.ROW_SIMPLE_TEXT_TEXTVIEW = f"{APP_ID}:id/row_simple_text_textview" + self.ROW_USER_CONTAINER_BASE = f"{APP_ID}:id/row_user_container_base" + self.ROW_USER_PRIMARY_NAME = f"{APP_ID}:id/row_user_primary_name" + self.ROW_USER_TEXTVIEW = f"{APP_ID}:id/row_user_textview" + self.SEARCH = f"{APP_ID}:id/search" + self.SEE_ALL_BUTTON = f"{APP_ID}:id/see_all_button" + self.SORTING_ENTRY_ROW_ICON = f"{APP_ID}:id/sorting_entry_row_icon" + self.TAB_BAR = f"{APP_ID}:id/tab_bar" + self.TAB_BUTTON_NAME_TEXT = f"{APP_ID}:id/tab_button_name_text" + self.TAB_BUTTON_FALLBACK_ICON = f"{APP_ID}:id/tab_button_fallback_icon" + self.TITLE_VIEW = f"{APP_ID}:id/title_view" + self.UNIFIED_FOLLOW_LIST_TAB_LAYOUT = ( + f"{APP_ID}:id/unified_follow_list_tab_layout" + ) + self.ZOOMABLE_VIEW_CONTAINER = f"{APP_ID}:id/zoomable_view_container" + + self.CAROUSEL_MEDIA_GROUP_AND_ZOOMABLE_VIEW_CONTAINER = ( + f"{self.ZOOMABLE_VIEW_CONTAINER}|{self.CAROUSEL_MEDIA_GROUP}" + ) + self.GAP_VIEW_AND_FOOTER_SPACE = f"{self.GAP_VIEW}|{self.FOOTER_SPACE}" + + +class TabBarText: + ACTIVITY_CONTENT_DESC = "Activity" + EFFECTS_CONTENT_DESC = "Effects" + HOME_CONTENT_DESC = "Home" + IGTV_CONTENT_DESC = "IGTV" + ORDERS_CONTENT_DESC = "Orders" + PHOTOS_OF_YOU_CONTENT_DESC = "Photos of You" + POSTS_CONTENT_DESC = "Grid View" + PROFILE_CONTENT_DESC = "Profile" + RECENT_CONTENT_DESC = "Recent" + REELS_CONTENT_DESC = "Reels" + SEARCH_CONTENT_DESC = "[Ss]earch and [Ee]xplore" + + +class ClassName: + BUTTON = "android.widget.Button" + BUTTON_OR_TEXTVIEW_REGEX = "android.widget.Button|android.widget.TextView" + EDIT_TEXT = "android.widget.EditText" + FRAME_LAYOUT = "android.widget.FrameLayout" + HORIZONTAL_SCROLL_VIEW = "android.widget.HorizontalScrollView" + IMAGE_VIEW = "android.widget.ImageView" + LIST_VIEW = "android.widget.ListView" + LINEAR_LAYOUT = "android.widget.LinearLayout" + RECYCLER_VIEW = "androidx.recyclerview.widget.RecyclerView" + TEXT_VIEW = "android.widget.TextView" + VIEW = "android.view.View" + VIEW_GROUP = "android.view.ViewGroup" + VIEW_PAGER = "androidx.viewpager.widget.ViewPager" diff --git a/GramAddict/core/scroll_end_detector.py b/GramAddict/core/scroll_end_detector.py index 530a7393..0275bb1c 100644 --- a/GramAddict/core/scroll_end_detector.py +++ b/GramAddict/core/scroll_end_detector.py @@ -8,10 +8,16 @@ class ScrollEndDetector: # Specify how many times we'll have to iterate over same users to decide that it's the end of the list repeats_to_end = 0 + skipped_all = 0 + skipped_all_fling = 0 pages = [] - def __init__(self, repeats_to_end=5): + def __init__( + self, repeats_to_end=5, skipped_list_limit=999, skipped_fling_limit=999 + ): self.repeats_to_end = repeats_to_end + self.skipped_list_limit = skipped_list_limit + self.skipped_fling_limit = skipped_fling_limit def notify_new_page(self): self.pages.append([]) @@ -20,6 +26,29 @@ def notify_username_iterated(self, username): last_page = self.pages[-1] last_page.append(username) + def reset_skipped_all(self): + self.skipped_all = 0 + + def notify_skipped_all(self): + self.skipped_all += 1 + self.skipped_all_fling += 1 + + def is_skipped_limit_reached(self): + if self.skipped_all >= self.skipped_list_limit: + logger.info( + f"Skipped all users in list {self.skipped_list_limit} times. Finish.", + extra={"color": f"{Fore.BLUE}"}, + ) + return True + + def is_fling_limit_reached(self): + if ( + self.skipped_all_fling >= self.skipped_fling_limit + and self.skipped_fling_limit > 0 + ): + self.skipped_all_fling = 0 + return True + def is_the_end(self): if len(self.pages) < 2: return False diff --git a/GramAddict/core/session_state.py b/GramAddict/core/session_state.py index b301b9ec..25727195 100644 --- a/GramAddict/core/session_state.py +++ b/GramAddict/core/session_state.py @@ -3,6 +3,7 @@ from datetime import datetime from enum import Enum, auto from json import JSONEncoder +from GramAddict.core.utils import get_value logger = logging.getLogger(__name__) @@ -23,9 +24,9 @@ class SessionState: startTime = None finishTime = None - def __init__(self): + def __init__(self, configs): self.id = str(uuid.uuid4()) - self.args = {} + self.args = configs.args self.my_username = None self.my_followers_count = None self.my_following_count = None @@ -59,26 +60,27 @@ def add_interaction(self, source, succeed, followed): def check_limit(self, args, limit_type=None, output=False): """Returns True if limit reached - else False""" - limit_type = SessionState.Limit.ALL if limit_type == None else limit_type - total_likes = self.totalLikes >= int(args.total_likes_limit) - total_followed = sum(self.totalFollowed.values()) >= int( - args.total_follows_limit - ) - total_watched = self.totalWatched >= int(args.total_watches_limit) + limit_type = SessionState.Limit.ALL if limit_type is None else limit_type + likes_limit = get_value(args.total_likes_limit, None, 300) + total_likes = self.totalLikes >= int(likes_limit) + follow_limit = get_value(args.total_follows_limit, None, 50) + total_followed = sum(self.totalFollowed.values()) >= int(follow_limit) + watch_limit = get_value(args.total_watches_limit, None, 50) + total_watched = self.totalWatched >= int(watch_limit) + success_limit = get_value(args.total_successful_interactions_limit, None, 100) total_successful = sum(self.successfulInteractions.values()) >= int( - args.total_successful_interactions_limit - ) - total_interactions = sum(self.totalInteractions.values()) >= int( - args.total_interactions_limit + success_limit ) + total_limit = get_value(args.total_interactions_limit, None, 1000) + total_interactions = sum(self.totalInteractions.values()) >= int(total_limit) session_info = [ "Checking session limits:", - f"- Total Likes:\t\t\t\t{'Limit Reached' if total_likes else 'OK'} ({self.totalLikes}/{args.total_likes_limit})", - f"- Total Followed:\t\t\t\t{'Limit Reached' if total_followed else 'OK'} ({sum(self.totalFollowed.values())}/{args.total_follows_limit})", - f"- Total Watched:\t\t\t\t{'Limit Reached' if total_watched else 'OK'} ({self.totalWatched}/{args.total_watches_limit})", - f"- Total Successful Interactions:\t\t{'Limit Reached' if total_successful else 'OK'} ({sum(self.successfulInteractions.values())}/{args.total_successful_interactions_limit})", - f"- Total Interactions:\t\t\t{'Limit Reached' if total_interactions else 'OK'} ({sum(self.totalInteractions.values())}/{args.total_interactions_limit})", + f"- Total Likes:\t\t\t\t{'Limit Reached' if total_likes else 'OK'} ({self.totalLikes}/{likes_limit})", + f"- Total Followed:\t\t\t\t{'Limit Reached' if total_followed else 'OK'} ({sum(self.totalFollowed.values())}/{follow_limit})", + f"- Total Watched:\t\t\t\t{'Limit Reached' if total_watched else 'OK'} ({self.totalWatched}/{watch_limit})", + f"- Total Successful Interactions:\t\t{'Limit Reached' if total_successful else 'OK'} ({sum(self.successfulInteractions.values())}/{success_limit})", + f"- Total Interactions:\t\t\t{'Limit Reached' if total_interactions else 'OK'} ({sum(self.totalInteractions.values())}/{total_limit})", ] if limit_type == SessionState.Limit.ALL: @@ -154,6 +156,6 @@ def default(self, session_state: SessionState): "total_unfollowed": session_state.totalUnfollowed, "start_time": str(session_state.startTime), "finish_time": str(session_state.finishTime), - "args": session_state.args, + "args": session_state.args.__dict__, "profile": {"followers": str(session_state.my_followers_count)}, } diff --git a/GramAddict/core/utils.py b/GramAddict/core/utils.py index dc5879a8..16a983da 100644 --- a/GramAddict/core/utils.py +++ b/GramAddict/core/utils.py @@ -12,12 +12,24 @@ from colorama import Fore, Style from GramAddict.core.log import get_log_file_config +from GramAddict.core.resources import ClassName, ResourceID as resources from GramAddict.version import __version__ http = urllib3.PoolManager() logger = logging.getLogger(__name__) +def load_config(config): + global app_id + global args + global configs + global ResourceID + app_id = config.args.app_id + args = config.args + configs = config + ResourceID = resources(app_id) + + def update_available(): try: r = http.request( @@ -32,7 +44,8 @@ def update_available(): return False -def check_adb_connection(is_device_id_provided): +def check_adb_connection(): + is_device_id_provided = configs.device_id is not None stream = os.popen("adb devices") output = stream.read() devices_count = len(re.findall("device\n", output)) @@ -55,11 +68,11 @@ def check_adb_connection(is_device_id_provided): return is_ok -def get_instagram_version(device_id): +def get_instagram_version(): stream = os.popen( "adb" - + ("" if device_id is None else " -s " + device_id) - + " shell dumpsys package com.instagram.android" + + ("" if configs.device_id is None else " -s " + configs.device_id) + + f" shell dumpsys package {app_id}" ) output = stream.read() version_match = re.findall("versionName=(\\S+)", output) @@ -71,11 +84,11 @@ def get_instagram_version(device_id): return version -def open_instagram_with_url(device_id, url): +def open_instagram_with_url(url): logger.info("Open Instagram app with url: {}".format(url)) cmd = ( "adb" - + ("" if device_id is None else " -s " + device_id) + + ("" if configs.device_id is None else " -s " + configs.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") @@ -87,12 +100,12 @@ def open_instagram_with_url(device_id, url): return True -def open_instagram(device_id): +def open_instagram(): logger.info("Open Instagram app") cmd = ( "adb" - + ("" if device_id is None else " -s " + device_id) - + " shell am start -n com.instagram.android/com.instagram.mainactivity.MainActivity" + + ("" if configs.device_id is None else " -s " + configs.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() @@ -101,17 +114,24 @@ def open_instagram(device_id): random_sleep() -def close_instagram(device_id): +def close_instagram(): logger.info("Close Instagram app") os.popen( "adb" - + ("" if device_id is None else " -s " + device_id) - + " shell am force-stop com.instagram.android" + + ("" if configs.device_id is None else " -s " + configs.device_id) + + f" shell am force-stop {app_id}" + ).close() + # close out atx-agent + os.popen( + "adb" + + ("" if configs.device_id is None else " -s " + configs.device_id) + + " shell pkill atx-agent" ).close() -def random_sleep(): - delay = uniform(1.0, 4.0) +def random_sleep(inf=1.0, sup=4.0): + multiplier = float(args.speed_multiplier) + delay = uniform(inf, sup) * multiplier logger.debug(f"{str(delay)[0:4]}s sleep") sleep(delay) @@ -165,8 +185,8 @@ def save_crash(device): def detect_block(device): logger.debug("Checking for block...") block_dialog = device.find( - resourceId="com.instagram.android:id/dialog_root_view", - className="android.widget.FrameLayout", + resourceId=ResourceID.DIALOG_ROOT_VIEW, + className=ClassName.FRAME_LAYOUT, ) is_blocked = block_dialog.exists() if is_blocked: @@ -192,20 +212,25 @@ def print_error(): elif len(parts) == 1: try: value = int(count) - logger.info(name.format(value), extra={"color": Style.BRIGHT}) + if name is not None: + logger.info(name.format(value), extra={"color": Style.BRIGHT}) except ValueError: value = default print_error() elif len(parts) == 2: try: value = randint(int(parts[0]), int(parts[1])) - logger.info(name.format(value), extra={"color": Style.BRIGHT}) + if name is not None: + logger.info(name.format(value), extra={"color": Style.BRIGHT}) except ValueError: value = default print_error() else: value = default print_error() + + if value == 69: + logger.info("69, Noice 😎 https://www.youtube.com/watch?v=VLNxvl3-CpA") return value diff --git a/GramAddict/core/views.py b/GramAddict/core/views.py index 2fc92e7c..af4dba99 100644 --- a/GramAddict/core/views.py +++ b/GramAddict/core/views.py @@ -2,13 +2,24 @@ import logging import re from enum import Enum, auto +from colorama import Fore, Style from GramAddict.core.device_facade import DeviceFacade +from GramAddict.core.resources import ClassName, ResourceID as resources, TabBarText from GramAddict.core.utils import random_sleep, save_crash logger = logging.getLogger(__name__) +def load_config(config): + global args + global configs + global ResourceID + args = config.args + configs = config + ResourceID = resources(config.args.app_id) + + def case_insensitive_re(str_list): if isinstance(str_list, str): strings = str_list @@ -34,29 +45,41 @@ class SearchTabs(Enum): PLACES = auto() -class ProfileTabs(Enum): - POSTS = auto() - IGTV = auto() - REELS = auto() - EFFECTS = auto() - PHOTOS_OF_YOU = auto() +class FollowStatus(Enum): + FOLLOW = auto() + FOLLOWING = auto() + FOLLOW_BACK = auto() + REQUESTED = auto() -class TabBarView: - 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" +class SwipeTo(Enum): + HALF_PHOTO = auto() + NEXT_POST = auto() + + +class LikeMode(Enum): + SINGLE_CLICK = auto() + DOUBLE_CLICK = auto() + +class Direction(Enum): + UP = auto() + DOWN = auto() + + +class Owner(Enum): + OPEN = auto() + GET_NAME = auto() + + +class TabBarView: def __init__(self, device: DeviceFacade): self.device = device def _getTabBar(self): tab_bar = self.device.find( - resourceIdMatches=case_insensitive_re("com.instagram.android:id/tab_bar"), - className="android.widget.LinearLayout", + resourceIdMatches=case_insensitive_re(ResourceID.TAB_BAR), + className=ClassName.LINEAR_LAYOUT, ) return tab_bar @@ -85,14 +108,15 @@ def _navigateTo(self, tab: TabBarTabs): tab_name = tab.name logger.debug(f"Navigate to {tab_name}") button = None - tabBarView = self._getTabBar() if tab == TabBarTabs.HOME: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.HOME_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re(TabBarText.HOME_CONTENT_DESC), ) elif tab == TabBarTabs.SEARCH: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.SEARCH_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re(TabBarText.SEARCH_CONTENT_DESC), ) if not button.exists(): # Some accounts display the search btn only in Home -> action bar @@ -101,26 +125,36 @@ def _navigateTo(self, tab: TabBarTabs): home_view.navigateToSearch() return elif tab == TabBarTabs.REELS: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.REELS_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re(TabBarText.REELS_CONTENT_DESC), ) elif tab == TabBarTabs.ORDERS: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.ORDERS_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re(TabBarText.ORDERS_CONTENT_DESC), ) elif tab == TabBarTabs.ACTIVITY: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.ACTIVITY_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re( + TabBarText.ACTIVITY_CONTENT_DESC + ), ) elif tab == TabBarTabs.PROFILE: - button = tabBarView.child( - descriptionMatches=case_insensitive_re(TabBarView.PROFILE_CONTENT_DESC) + button = self.device.find( + className=ClassName.BUTTON, + descriptionMatches=case_insensitive_re(TabBarText.PROFILE_CONTENT_DESC), ) if button.exists(): # Two clicks to reset tab content + random_sleep(1, 2) button.click() - button.click() + random_sleep(1, 2) + if tab is not TabBarTabs.PROFILE: + button.click() + random_sleep(1, 2) return @@ -138,10 +172,8 @@ def __init__(self, device: DeviceFacade): def _getActionBar(self): tab_bar = self.device.find( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/action_bar_container" - ), - className="android.widget.FrameLayout", + resourceIdMatches=case_insensitive_re(ResourceID.ACTION_BAR_CONTAINER), + className=ClassName.FRAME_LAYOUT, ) return tab_bar @@ -154,7 +186,7 @@ def __init__(self, device: DeviceFacade): def navigateToSearch(self): logger.debug("Navigate to Search") search_btn = self.action_bar.child( - descriptionMatches=case_insensitive_re(TabBarView.SEARCH_CONTENT_DESC) + descriptionMatches=case_insensitive_re(TabBarText.SEARCH_CONTENT_DESC) ) search_btn.click() @@ -166,22 +198,27 @@ def __init__(self, device: DeviceFacade): self.device = device def _getRecyclerView(self): - CLASSNAME = "(androidx.recyclerview.widget.RecyclerView|android.view.View)" + views = f"({ClassName.RECYCLER_VIEW}|{ClassName.VIEW})" - return self.device.find(classNameMatches=CLASSNAME) + return self.device.find(classNameMatches=views) def _getFistImageView(self, recycler): return recycler.child( - className="android.widget.ImageView", - resourceIdMatches="com.instagram.android:id/image_button", + className=ClassName.IMAGE_VIEW, + resourceIdMatches=ResourceID.IMAGE_BUTTON, ) def _getRecentTab(self): return self.device.find( - className="android.widget.TextView", - text="Recent", + className=ClassName.TEXT_VIEW, + textMatches=case_insensitive_re(TabBarText.RECENT_CONTENT_DESC), ) + def _check_if_no_posts(self): + return self.device.find( + resourceId=ResourceID.IGDS_HEADLINE_EMPHASIZED_HEADLINE + ).exists(True) + class SearchView: def __init__(self, device: DeviceFacade): @@ -190,42 +227,38 @@ def __init__(self, device: DeviceFacade): def _getSearchEditText(self): return self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/action_bar_search_edit_text" + ResourceID.ACTION_BAR_SEARCH_EDIT_TEXT ), - className="android.widget.EditText", + className=ClassName.EDIT_TEXT, ) def _getUsernameRow(self, username): return self.device.find( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/row_search_user_username" - ), - className="android.widget.TextView", + resourceIdMatches=case_insensitive_re(ResourceID.ROW_SEARCH_USER_USERNAME), + className=ClassName.TEXT_VIEW, text=username, ) def _getHashtagRow(self, hashtag): return self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/row_hashtag_textview_tag_name" + ResourceID.ROW_HASHTAG_TEXTVIEW_TAG_NAME ), - className="android.widget.TextView", + className=ClassName.TEXT_VIEW, text=f"#{hashtag}", ) def _getTabTextView(self, tab: SearchTabs): tab_layout = self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/fixed_tabbar_tabs_container" + ResourceID.FIXED_TABBAR_TABS_CONTAINER ), - className="android.widget.LinearLayout", + className=ClassName.LINEAR_LAYOUT, ) tab_text_view = tab_layout.child( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/tab_button_name_text" - ), - className="android.widget.TextView", + resourceIdMatches=case_insensitive_re(ResourceID.TAB_BUTTON_NAME_TEXT), + className=ClassName.TEXT_VIEW, textMatches=case_insensitive_re(tab.name), ) return tab_text_view @@ -233,9 +266,9 @@ def _getTabTextView(self, tab: SearchTabs): def _searchTabWithTextPlaceholder(self, tab: SearchTabs): tab_layout = self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/fixed_tabbar_tabs_container" + ResourceID.FIXED_TABBAR_TABS_CONTAINER ), - className="android.widget.LinearLayout", + className=ClassName.LINEAR_LAYOUT, ) search_edit_text = self._getSearchEditText() @@ -247,18 +280,17 @@ def _searchTabWithTextPlaceholder(self, tab: SearchTabs): ) for item in tab_layout.child( - resourceId="com.instagram.android:id/tab_button_fallback_icon", - className="android.widget.ImageView", + resourceId=ResourceID.TAB_BUTTON_FALLBACK_ICON, + className=ClassName.IMAGE_VIEW, ): 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", + className=ClassName.TEXT_VIEW, textMatches=case_insensitive_re(fixed_text), ).exists(): return item @@ -268,15 +300,20 @@ def navigateToUsername(self, username): logger.debug("Navigate to profile @" + username) search_edit_text = self._getSearchEditText() search_edit_text.click() - - search_edit_text.set_text(username) - username_view = self._getUsernameRow(username) - - if not username_view.exists(): - logger.error("Cannot find user @" + username + ", abort.") - return None - - username_view.click() + logger.debug("Close the keyboad") + DeviceFacade.back(self.device) + random_sleep(1, 2) + searched_user_recent = self._getUsernameRow(username) + if searched_user_recent.exists(True): + searched_user_recent.click() + else: + search_edit_text.set_text(username) + random_sleep(1, 2) + username_view = self._getUsernameRow(username) + if not username_view.exists(): + logger.error("Cannot find user @" + username + ".") + return None + username_view.click() return ProfileView(self.device, is_own_profile=False) @@ -284,8 +321,7 @@ def navigateToHashtag(self, hashtag): logger.info(f"Navigate to hashtag {hashtag}") search_edit_text = self._getSearchEditText() search_edit_text.click() - - random_sleep() + random_sleep(1, 2) hashtag_tab = self._getTabTextView(SearchTabs.TAGS) if not hashtag_tab.exists(): logger.debug( @@ -296,22 +332,23 @@ def navigateToHashtag(self, hashtag): logger.error("Cannot find tab: Tags.") save_crash(self.device) return None - hashtag_tab.click() - random_sleep() + random_sleep(1, 2) + logger.debug("Close the keyboad") DeviceFacade.back(self.device) - random_sleep() + random_sleep(1, 2) # check if that hashtag already exists in the recent search list -> act as human hashtag_view_recent = self._getHashtagRow(hashtag[1:]) if hashtag_view_recent.exists(): hashtag_view_recent.click() - random_sleep() + random_sleep(5, 10) return HashTagView(self.device) - logger.info(f"{hashtag} is not in recent searching hystory..") + logger.info(f"{hashtag} is not in recent searching history..") search_edit_text.set_text(hashtag) hashtag_view = self._getHashtagRow(hashtag[1:]) + random_sleep(4, 8) if not hashtag_view.exists(): logger.error(f"Cannot find hashtag {hashtag}, abort.") @@ -328,57 +365,261 @@ class PostsViewList: def __init__(self, device: DeviceFacade): self.device = device - 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 = "com.instagram.android:id/zoomable_view_container|com.instagram.android:id/carousel_media_group" + def swipe_to_fit_posts(self, swipe: SwipeTo): + """calculate the right swipe amount necessary to swipe to next post in hashtag post view + in order to make it available to other plug-ins I cutted it in two moves""" displayWidth = self.device.get_info()["displayWidth"] - if first_post: + containers_content = ResourceID.CAROUSEL_MEDIA_GROUP_AND_ZOOMABLE_VIEW_CONTAINER + containers_gap = ResourceID.GAP_VIEW_AND_FOOTER_SPACE + + # move type: half photo + if swipe == SwipeTo.HALF_PHOTO: zoomable_view_container = self.device.find( - resourceIdMatches=POST_CONTAINER + resourceIdMatches=containers_content ).get_bounds()["bottom"] - - logger.info("Scrolled down to see more posts.") self.device.swipe_points( displayWidth / 2, - zoomable_view_container - 1, + zoomable_view_container - 5, displayWidth / 2, - zoomable_view_container * 2 / 3, + zoomable_view_container * 0.5, ) - else: - - gap_view = self.device.find( - resourceIdMatches="com.instagram.android: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) + # move type: gap/footer to next post + elif swipe == SwipeTo.NEXT_POST: + logger.info( + "Scroll down to see next post.", extra={"color": f"{Fore.GREEN}"} ) - + gap_view_obj = self.device.find(resourceIdMatches=containers_gap) + for _ in range(2): + if not gap_view_obj.exists(True): + logger.debug("Can't find the gap obj, scroll down a little more.") + PostsViewList(self.device).swipe_to_fit_posts(SwipeTo.HALF_PHOTO) + gap_view_obj = self.device.find(resourceIdMatches=containers_gap) + if not gap_view_obj.exists(True): + continue + else: + break + gap_view = gap_view_obj.get_bounds()["top"] zoomable_view_container = self.device.find( - resourceIdMatches=POST_CONTAINER - ).get_bounds()["bottom"] - + resourceIdMatches=(containers_content) + ).get_bounds()["top"] self.device.swipe_points( displayWidth / 2, - zoomable_view_container - 1, + gap_view - 5, displayWidth / 2, - zoomable_view_container * 2 / 3, + zoomable_view_container + 5, ) - return + return True + + def _find_likers_container(self): + containers_gap = ResourceID.GAP_VIEW_AND_FOOTER_SPACE + gap_view_obj = self.device.find(resourceIdMatches=containers_gap) + likes_view = self.device.find( + resourceId=ResourceID.ROW_FEED_TEXTVIEW_LIKES, + className=ClassName.TEXT_VIEW, + ) + PostsViewList(self.device).swipe_to_fit_posts(SwipeTo.HALF_PHOTO) + for _ in range(2): + if not likes_view.exists(True): + if not gap_view_obj.exists(True): + PostsViewList(self.device).swipe_to_fit_posts(SwipeTo.HALF_PHOTO) + else: + return True + else: + return True + return False + + def _check_if_only_one_liker_or_none(self): + likes_view = self.device.find( + resourceId=ResourceID.ROW_FEED_TEXTVIEW_LIKES, + className=ClassName.TEXT_VIEW, + ) + if likes_view.exists(True): + likes_view_text = likes_view.get_text() + if ( + likes_view_text[-6:].upper() == "OTHERS" + or likes_view_text.upper()[-5:] == "LIKES" + ): + return False + else: + logger.info("This post has only 1 liker, skip.") + return True + else: + logger.info("This post has no likers, skip.") + return True + + def open_likers_container(self): + likes_view = self.device.find( + resourceId=ResourceID.ROW_FEED_TEXTVIEW_LIKES, + className=ClassName.TEXT_VIEW, + ) + logger.info("Opening post likers.") + random_sleep() + likes_view.click(likes_view.Location.RIGHT) - def check_if_last_post(self, last_description): + def _check_if_last_post(self, last_description): """check if that post has been just interacted""" - post_description = self.device.find( - resourceId="com.instagram.android:id/row_feed_comment_textview_layout" - ) - if post_description.exists(True): - new_description = post_description.get_text().upper() - if new_description == last_description: - logger.info("This is the last post for this hashtag") - return True, new_description + swiped_a_bit = False + n = 1 + while n < 3: + post_description = self.device.find( + resourceId=ResourceID.ROW_FEED_COMMENT_TEXTVIEW_LAYOUT + ) + if post_description.exists(True): + new_description = post_description.get_text().upper() + if swiped_a_bit: + logger.debug("Revert the last swipe.") + UniversalActions(self.device)._swipe_points(direction=Direction.UP) + if new_description == last_description: + logger.info( + "This post has the same description and author as the last one." + ) + return True, new_description + else: + return False, new_description + else: + if n < 2: + logger.debug( + "Can't find the description, try to swipe a little bit down." + ) + UniversalActions(self.device)._swipe_points( + direction=Direction.DOWN + ) + swiped_a_bit = True + n += 1 + else: + logger.warning("Can't find the description of this post.") + return False, "" + + def _if_action_bar_is_over_obj_swipe(self, obj): + """do a swipe of the amount of the action bar""" + action_bar_exists, _, action_bar_bottom = PostsViewList( + self.device + )._get_action_bar_position() + if action_bar_exists: + obj_top = obj.get_bounds()["top"] + if action_bar_bottom > obj_top: + UniversalActions(self.device)._swipe_points( + direction=Direction.UP, delta_y=action_bar_bottom + ) + + def _get_action_bar_position(self): + """action bar is overlayed, if you press on it you go back to the first post + knowing his position is important to avoid it""" + action_bar = self.device.find( + resourceIdMatches=(ResourceID.ACTION_BAR_CONTAINER) + ) + if action_bar.exists(True): + return ( + True, + action_bar.get_bounds()["top"], + action_bar.get_bounds()["bottom"], + ) + else: + return False, 0, 0 + + def _post_owner(self, mode: Owner): + post_owner_obj = self.device.find( + resourceIdMatches=(ResourceID.ROW_FEED_PHOTO_PROFILE_NAME) + ) + post_owner_clickable = False + for _ in range(2): + if not post_owner_obj.exists(True): + UniversalActions(self.device)._swipe_points(direction=Direction.UP) + post_owner_obj = self.device.find( + resourceIdMatches=(ResourceID.ROW_FEED_PHOTO_PROFILE_NAME) + ) else: - return False, new_description + post_owner_clickable = True + break + + if not post_owner_clickable: + logger.info("Can't find the owner name.") + return False + if mode == Owner.OPEN: + logger.info("Open post owner.") + PostsViewList(self.device)._if_action_bar_is_over_obj_swipe(post_owner_obj) + post_owner_obj.click() + return True + elif mode == Owner.GET_NAME: + return post_owner_obj.get_text() + else: + return False + + def _open_likers(self): + while True: + likes_view = self.device.find( + resourceId=ResourceID.ROW_FEED_TEXTVIEW_LIKES, + className=ClassName.TEXT_VIEW, + ) + if likes_view.exists(True): + likes_view_text = likes_view.get_text() + if ( + likes_view_text[-6:].upper() == "OTHERS" + or likes_view_text.upper()[-5:] == "LIKES" + ): + logger.info("Opening post likers") + random_sleep() + PostsViewList(self.device)._if_action_bar_is_over_obj_swipe( + likes_view + ) + likes_view.click(likes_view.Location.RIGHT) + return True + else: + logger.info("This post has only 1 liker, skip") + return False + else: + return False + + def _get_post_owner_name(self): + return self.device.find( + resourceIdMatches=(ResourceID.ROW_FEED_PHOTO_PROFILE_NAME) + ).get_text() + + def _like_in_post_view(self, mode: LikeMode): + POST_CONTAINER = ResourceID.CAROUSEL_MEDIA_GROUP_AND_ZOOMABLE_VIEW_CONTAINER + + if mode == LikeMode.DOUBLE_CLICK: + logger.info("Double click photo.") + _, _, action_bar_bottom = PostsViewList( + self.device + )._get_action_bar_position() + self.device.find(resourceIdMatches=(POST_CONTAINER)).double_click( + obj_over=action_bar_bottom + ) + elif mode == LikeMode.SINGLE_CLICK: + logger.info("Like photo from button.") + self.device.find(resourceIdMatches=ResourceID.ROW_FEED_BUTTON_LIKE).click() + + def _follow_in_post_view(self): + logger.info("Follow blogger in place.") + self.device.find(resourceIdMatches=(ResourceID.BUTTON)).click() + + def _comment_in_post_view(self): + logger.info("Open comments of post.") + self.device.find(resourceIdMatches=(ResourceID.ROW_FEED_BUTTON_COMMENT)).click() + + def _check_if_liked(self, first_attemp=True): + STR = "Liked" + logger.debug("Check if like succeded in post view.") + bnt_like_obj = self.device.find( + resourceIdMatches=ResourceID.ROW_FEED_BUTTON_LIKE + ) + if bnt_like_obj.exists(True): + if self.device.find(descriptionMatches=case_insensitive_re(STR)).exists( + True + ): + logger.debug("Like is present.") + return True + else: + logger.debug("Like is not present.") + return False + else: + UniversalActions(self.device)._swipe_points(direction=Direction.DOWN) + if first_attemp: + return PostsViewList(self.device)._check_if_liked(False) + else: + logger.debug("Like btn not present.") + return False class LanguageView: @@ -388,14 +629,14 @@ def __init__(self, device: DeviceFacade): def setLanguage(self, language: str): logger.debug(f"Set language to {language}") search_edit_text = self.device.find( - resourceId="com.instagram.android:id/search", - className="android.widget.EditText", + resourceId=ResourceID.SEARCH, + className=ClassName.EDIT_TEXT, ) search_edit_text.set_text(language) list_view = self.device.find( - resourceId="com.instagram.android:id/language_locale_list", - className="android.widget.ListView", + resourceId=ResourceID.LANGUAGE_LIST_LOCALE, + className=ClassName.LIST_VIEW, ) first_item = list_view.child(index=0) first_item.click() @@ -409,13 +650,44 @@ def navigateToLanguage(self): logger.debug("Navigate to Language") button = self.device.find( textMatches=case_insensitive_re("Language"), - resourceId="com.instagram.android:id/row_simple_text_textview", - className="android.widget.TextView", + resourceId=ResourceID.ROW_SIMPLE_TEXT_TEXTVIEW, + className=ClassName.TEXT_VIEW, ) button.click() return LanguageView(self.device) + def changeToUsername(self, username): + action_bar = self.device.find(resourceId=ResourceID.ACTION_BAR_LARGE_TITLE) + current_profile_name = action_bar.get_text().upper() + if current_profile_name == username.upper(): + logger.info( + f"You are already logged as {username}!", + extra={"color": f"{Style.BRIGHT}{Fore.BLUE}"}, + ) + return True + if action_bar.exists(): + action_bar.click() + random_sleep() + found_obj = self.device.find( + resourceId=ResourceID.ROW_USER_TEXTVIEW, + textMatches=case_insensitive_re(username), + ) + if found_obj.exists(): + logger.info( + f"Switching to {configs.args.username}...", + extra={"color": f"{Style.BRIGHT}{Fore.BLUE}"}, + ) + found_obj.click() + random_sleep() + action_bar = self.device.find( + resourceId=ResourceID.ACTION_BAR_LARGE_TITLE + ) + current_profile_name = action_bar.get_text().upper() + if current_profile_name == username.upper(): + return True + return False + class SettingsView: def __init__(self, device: DeviceFacade): @@ -425,8 +697,8 @@ def navigateToAccount(self): logger.debug("Navigate to Account") button = self.device.find( textMatches=case_insensitive_re("Account"), - resourceId="com.instagram.android:id/row_simple_text_textview", - className="android.widget.TextView", + resourceId=ResourceID.ROW_SIMPLE_TEXT_TEXTVIEW, + className=ClassName.TEXT_VIEW, ) button.click() return AccountView(self.device) @@ -440,16 +712,14 @@ def navigateToSettings(self): logger.debug("Navigate to Settings") button = self.device.find( textMatches=case_insensitive_re("Settings"), - resourceId="com.instagram.android:id/menu_settings_row", - className="android.widget.TextView", + resourceId=ResourceID.MENU_SETTINGS_ROW, + className=ClassName.TEXT_VIEW, ) button.click() return SettingsView(self.device) class OpenedPostView: - BTN_LIKE_RES_ID = "com.instagram.android:id/row_feed_button_like" - def __init__(self, device: DeviceFacade): self.device = device @@ -461,22 +731,22 @@ def _getPostLikeButton(self, scroll_to_find=True): scroll_to_find: if the like button is not found, scroll a bit down to try to find it. Default: True """ - MEDIA_GROUP_RE = case_insensitive_re( + media_group = case_insensitive_re( [ - "com.instagram.android:id/media_group", - "com.instagram.android:id/carousel_media_group", + ResourceID.MEDIA_GROUP, + ResourceID.CAROUSEL_MEDIA_GROUP, ] ) post_view_area = self.device.find( - resourceIdMatches=case_insensitive_re("android:id/list") + resourceIdMatches=case_insensitive_re(ResourceID.LIST) ) if not post_view_area.exists(): logger.debug("Cannot find post recycler view area") return None post_media_view = self.device.find( - resourceIdMatches=MEDIA_GROUP_RE, - className="android.widget.FrameLayout", + resourceIdMatches=media_group, + className=ClassName.FRAME_LAYOUT, ) if not post_media_view.exists(): @@ -484,7 +754,7 @@ def _getPostLikeButton(self, scroll_to_find=True): return None like_btn_view = post_media_view.down( - resourceIdMatches=case_insensitive_re(OpenedPostView.BTN_LIKE_RES_ID) + resourceIdMatches=case_insensitive_re(ResourceID.ROW_FEED_BUTTON_LIKE) ) if like_btn_view.exists(): @@ -508,21 +778,24 @@ def _getPostLikeButton(self, scroll_to_find=True): logger.debug("Like button not found bellow the post.") if ( - not like_btn_view.exists() + not like_btn_view.exists(True) or not is_like_btn_in_the_bottom or not is_like_btn_visible ): if scroll_to_find: logger.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 + for _ in range(3): + self.device.swipe(DeviceFacade.Direction.TOP, scale=0.25) + like_btn_view = self.device.find( + resourceIdMatches=case_insensitive_re( + ResourceID.ROW_FEED_BUTTON_LIKE + ) ) - ) + if like_btn_view.exists(True): + break - if not scroll_to_find or not like_btn_view.exists(): + if not scroll_to_find or not like_btn_view.exists(True): logger.error("Could not find like button bellow the post") return None @@ -537,14 +810,14 @@ def _isPostLiked(self): return like_btn_view.get_selected() def likePost(self, click_btn_like=False): - MEDIA_GROUP_RE = case_insensitive_re( + media_group = case_insensitive_re( [ - "com.instagram.android:id/media_group", - "com.instagram.android:id/carousel_media_group", + ResourceID.MEDIA_GROUP, + ResourceID.CAROUSEL_MEDIA_GROUP, ] ) post_media_view = self.device.find( - resourceIdMatches=MEDIA_GROUP_RE, className="android.widget.FrameLayout" + resourceIdMatches=media_group, className=ClassName.FRAME_LAYOUT ) if click_btn_like: @@ -554,7 +827,7 @@ def likePost(self, click_btn_like=False): like_btn_view.click() else: - if post_media_view.exists(): + if post_media_view.exists(True): post_media_view.double_click() else: logger.error("Could not find post area to double click") @@ -564,40 +837,32 @@ def likePost(self, click_btn_like=False): return self._isPostLiked() - def open_likers(self): - while True: - likes_view = self.device.find( - resourceId="com.instagram.android:id/row_feed_textview_likes", - className="android.widget.TextView", - ) - if likes_view.exists(True): - if likes_view.get_text()[-6:].upper() == "OTHERS": - logger.info("Opening post likers") - random_sleep() - likes_view.click(likes_view.Location.RIGHT) - return True - else: - logger.info("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" + resourceId=ResourceID.LIST, className=ClassName.LIST_VIEW ) def _getUserCountainer(self): return self.device.find( - resourceId="com.instagram.android:id/row_user_container_base", - className="android.widget.LinearLayout", + resourceId=ResourceID.ROW_USER_CONTAINER_BASE, + className=ClassName.LINEAR_LAYOUT, ) def _getUserName(self, countainer): return countainer.child( - resourceId="com.instagram.android:id/row_user_primary_name", - className="android.widget.TextView", + resourceId=ResourceID.ROW_USER_PRIMARY_NAME, + className=ClassName.TEXT_VIEW, + ) + + def _isFollowing(self, countainer): + text = countainer.child( + resourceId=ResourceID.BUTTON, + classNameMatches=ClassName.BUTTON_OR_TEXTVIEW_REGEX, ) + # UIA1 doesn't use .get_text() + if type(text) != str: + text = text.get_text() + return True if text == "Following" or text == "Requested" else False class PostsGridView: @@ -606,9 +871,7 @@ def __init__(self, device: DeviceFacade): def scrollDown(self): coordinator_layout = self.device.find( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/coordinator_root_layout" - ) + resourceIdMatches=case_insensitive_re(ResourceID.COORDINATOR_ROOT_LAYOUT) ) if coordinator_layout.exists(): coordinator_layout.scroll(DeviceFacade.Direction.BOTTOM) @@ -618,7 +881,7 @@ def scrollDown(self): def navigateToPost(self, row, col): post_list_view = self.device.find( - resourceIdMatches=case_insensitive_re("android:id/list") + resourceIdMatches=case_insensitive_re(ResourceID.LIST) ) OFFSET = 1 # row with post starts from index 1 row_view = post_list_view.child(index=row + OFFSET) @@ -648,17 +911,45 @@ def navigateToOptions(self): return OptionsView(self.device) def _getActionBarTitleBtn(self): - re_case_insensitive = case_insensitive_re( + action_bar = case_insensitive_re( [ - "com.instagram.android:id/title_view", - "com.instagram.android:id/action_bar_title", - "com.instagram.android:id/action_bar_large_title", - "com.instagram.android:id/action_bar_textview_title", + ResourceID.TITLE_VIEW, + ResourceID.ACTION_BAR_TITLE, + ResourceID.ACTION_BAR_LARGE_TITLE, + ResourceID.ACTION_BAR_TEXTVIEW_TITLE, ] ) - return self.action_bar.child( - resourceIdMatches=re_case_insensitive, className="android.widget.TextView" + bar = self.action_bar.child( + resourceIdMatches=action_bar, className=ClassName.TEXT_VIEW ) + if not bar.exists(): + bar = self.device.find( + resourceIdMatches=action_bar, className=ClassName.TEXT_VIEW + ) + return bar + + def getFollowButton(self): + button_regex = f"{ClassName.BUTTON}|{ClassName.TEXT_VIEW}" + following_regex = "^Following|^Requested" + followback_regex = "^Follow Back$" + + following_button = self.device.find( + classNameMatches=button_regex, + clickable=True, + textMatches=following_regex, + ) + followback_button = self.device.find( + classNameMatches=button_regex, + clickable=True, + textMatches=followback_regex, + ) + if following_button.exists(): + return following_button, FollowStatus.FOLLOWING + + if followback_button.exists(): + return followback_button, FollowStatus.FOLLOW_BACK + + return None, None def getUsername(self, error=True): title_view = self._getActionBarTitleBtn() @@ -688,9 +979,9 @@ def _parseCounter(self, text): def _getFollowersTextView(self): followers_text_view = self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/row_profile_header_textview_followers_count" + ResourceID.ROW_PROFILE_HEADER_TEXTVIEW_FOLLOWERS_COUNT ), - className="android.widget.TextView", + className=ClassName.TEXT_VIEW, ) return followers_text_view @@ -711,9 +1002,9 @@ def getFollowersCount(self): def _getFollowingTextView(self): following_text_view = self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/row_profile_header_textview_following_count" + ResourceID.ROW_PROFILE_HEADER_TEXTVIEW_FOLLOWING_COUNT ), - className="android.widget.TextView", + className=ClassName.TEXT_VIEW, ) return following_text_view @@ -734,13 +1025,13 @@ def getFollowingCount(self): def getPostsCount(self): post_count_view = self.device.find( resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/row_profile_header_textview_post_count" + ResourceID.ROW_PROFILE_HEADER_TEXTVIEW_POST_COUNT ), - className="android.widget.TextView", + className=ClassName.TEXT_VIEW, ) if post_count_view.exists(): count = post_count_view.get_text() - if count != None: + if count is not None: return self._parseCounter(count) else: logger.error("Cannot get posts count text") @@ -751,16 +1042,14 @@ def getPostsCount(self): def count_photo_in_view(self): """return rows filled and the number of post in the last row""" - RECYCLER_VIEW = "androidx.recyclerview.widget.RecyclerView" + views = f"({ClassName.RECYCLER_VIEW}|{ClassName.VIEW})" grid_post = self.device.find( - className=RECYCLER_VIEW, resourceIdMatches="android:id/list" + classNameMatches=views, resourceIdMatches=ResourceID.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): + for i in range(2, 6): + lin_layout = grid_post.child(index=i, className=ClassName.LINEAR_LAYOUT) + if i == 5 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): @@ -782,10 +1071,8 @@ def getProfileInfo(self): def getProfileBiography(self): biography = self.device.find( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/profile_header_bio_text" - ), - className="android.widget.TextView", + resourceIdMatches=case_insensitive_re(ResourceID.PROFILE_HEADER_BIO_TEXT), + className=ClassName.TEXT_VIEW, ) if biography.exists(): biography_text = biography.get_text() @@ -794,17 +1081,27 @@ def getProfileBiography(self): 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() + logger.debug('Found "… more" in bio - trying to expand') + # Clicking the biography is dangerous. Clicking "right" is safest so we can try to avoid hashtags + biography.click(biography.Location.RIGHT) + # If we do click a hashtag (VERY possible) - let's back out + # a short bio is better than no bio + try: + return biography.get_text() + except: + logger.debug( + "Can't find biography - did we click a hashtag? Go back." + ) + logger.info("Failed to expand biography - checking short view.") + self.device.back() + return biography.get_text() return biography_text return "" def getFullName(self): full_name_view = self.device.find( - resourceIdMatches=case_insensitive_re( - "com.instagram.android:id/profile_header_full_name" - ), - className="android.widget.TextView", + resourceIdMatches=case_insensitive_re(ResourceID.PROFILE_HEADER_FULL_NAME), + className=ClassName.TEXT_VIEW, ) if full_name_view.exists(): fullname_text = full_name_view.get_text() @@ -816,98 +1113,94 @@ def isPrivateAccount(self): private_profile_view = self.device.find( resourceIdMatches=case_insensitive_re( [ - "com.instagram.android:id/private_profile_empty_state", - "com.instagram.android:id/row_profile_header_empty_profile_notice_title", + ResourceID.PRIVATE_PROFILE_EMPTY_STATE, + ResourceID.ROW_PROFILE_HEADER_EMPTY_PROFILE_NOTICE_TITLE, + ResourceID.ROW_PROFILE_HEADER_EMPTY_PROFILE_NOTICE_CONTAINER, ] ) ) - return private_profile_view.exists() + return private_profile_view.exists(True) def isStoryAvailable(self): return self.device.find( - resourceId="com.instagram.android:id/reel_ring", - className="android.view.View", + resourceId=ResourceID.REEL_RING, + className=ClassName.VIEW, ).exists() def profileImage(self): return self.device.find( - resourceId="com.instagram.android:id/row_profile_header_imageview", - className="android.widget.ImageView", + resourceId=ResourceID.ROW_PROFILE_HEADER_IMAGEVIEW, + className=ClassName.IMAGE_VIEW, ) def navigateToFollowers(self): logger.debug("Navigate to Followers") - FOLLOWERS_BUTTON_ID_REGEX = case_insensitive_re( - [ - "com.instagram.android:id/row_profile_header_followers_container", - "com.instagram.android:id/row_profile_header_container_followers", - ] + followers_button = self.device.find( + resourceIdMatches=case_insensitive_re( + ResourceID.ROW_PROFILE_HEADER_FOLLOWERS_CONTAINER + ) ) - followers_button = self.device.find(resourceIdMatches=FOLLOWERS_BUTTON_ID_REGEX) followers_button.click() 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="com.instagram.android:id/profile_tabs_container" - ).get_bounds()["top"] - bar_countainer = self.device.find( - resourceIdMatches="com.instagram.android:id/action_bar_container" - ).get_bounds()["bottom"] - - logger.info("Scrolled down to see more posts.") - self.device.swipe_points( - displayWidth / 2, element_to_swipe_over, displayWidth / 2, bar_countainer + element_to_swipe_over_obj = self.device.find( + resourceIdMatches=ResourceID.PROFILE_TABS_CONTAINER ) - return + if not element_to_swipe_over_obj.exists(): + UniversalActions(self.device)._swipe_points(direction=Direction.DOWN) + element_to_swipe_over_obj = self.device.find( + resourceIdMatches=ResourceID.PROFILE_TABS_CONTAINER + ) + + element_to_swipe_over = element_to_swipe_over_obj.get_bounds()["top"] + try: + bar_countainer = self.device.find( + resourceIdMatches=ResourceID.ACTION_BAR_CONTAINER + ).get_bounds()["bottom"] + + logger.info("Scrolled down to see more posts.") + self.device.swipe_points( + displayWidth / 2, + element_to_swipe_over, + displayWidth / 2, + bar_countainer, + ) + return element_to_swipe_over - bar_countainer + except: + logger.info("I'm not able to scroll down.") + return 0 def navigateToPostsTab(self): - self._navigateToTab(ProfileTabs.POSTS) + self._navigateToTab(TabBarText.POSTS_CONTENT_DESC) return PostsGridView(self.device) def navigateToIgtvTab(self): - self._navigateToTab(ProfileTabs.IGTV) + self._navigateToTab(TabBarText.IGTV_CONTENT_DESC) raise Exception("Not implemented") def navigateToReelsTab(self): - self._navigateToTab(ProfileTabs.REELS) + self._navigateToTab(TabBarText.REELS_CONTENT_DESC) raise Exception("Not implemented") def navigateToEffectsTab(self): - self._navigateToTab(ProfileTabs.EFFECTS) + self._navigateToTab(TabBarText.EFFECTS_CONTENT_DESC) raise Exception("Not implemented") def navigateToPhotosOfYouTab(self): - self._navigateToTab(ProfileTabs.PHOTOS_OF_YOU) + self._navigateToTab(TabBarText.PHOTOS_OF_YOU_CONTENT_DESC) raise Exception("Not implemented") - def _navigateToTab(self, tab: ProfileTabs): - TABS_RES_ID = "com.instagram.android:id/profile_tab_layout" - TABS_CLASS_NAME = "android.widget.HorizontalScrollView" + def _navigateToTab(self, tab: TabBarText): tabs_view = self.device.find( - resourceIdMatches=case_insensitive_re(TABS_RES_ID), - className=TABS_CLASS_NAME, - ) - - TAB_RES_ID = "com.instagram.android: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" - + resourceIdMatches=case_insensitive_re(ResourceID.PROFILE_TAB_LAYOUT), + className=ClassName.HORIZONTAL_SCROLL_VIEW, + ) button = tabs_view.child( - descriptionMatches=case_insensitive_re(description), - resourceIdMatches=case_insensitive_re(TAB_RES_ID), - className=TAB_CLASS_NAME, + descriptionMatches=case_insensitive_re(tab), + resourceIdMatches=case_insensitive_re(ResourceID.PROFILE_TAB_ICON_VIEW), + className=ClassName.IMAGE_VIEW, ) attempts = 0 @@ -915,16 +1208,16 @@ def _navigateToTab(self, tab: ProfileTabs): attempts += 1 self.device.swipe(DeviceFacade.Direction.TOP, scale=0.1) if attempts > 2: - logger.error(f"Cannot navigate to tab '{description}'") + logger.error(f"Cannot navigate to tab '{tab}'") save_crash(self.device) return button.click() def _getRecyclerView(self): - CLASSNAME = "(androidx.recyclerview.widget.RecyclerView|android.view.View)" + views = f"({ClassName.RECYCLER_VIEW}|{ClassName.VIEW})" - return self.device.find(classNameMatches=CLASSNAME) + return self.device.find(classNameMatches=views) class CurrentStoryView: @@ -933,21 +1226,23 @@ def __init__(self, device: DeviceFacade): def getStoryFrame(self): return self.device.find( - resourceId="com.instagram.android:id/reel_viewer_image_view", - className="android.widget.FrameLayout", + resourceId=ResourceID.REEL_VIEWER_IMAGE_VIEW, + className=ClassName.FRAME_LAYOUT, ) def getUsername(self): reel_viewer_title = self.device.find( - resourceId="com.instagram.android:id/reel_viewer_title", - className="android.widget.TextView", + resourceId=ResourceID.REEL_VIEWER_TITLE, + className=ClassName.TEXT_VIEW, + ) + return ( + "" if not reel_viewer_title.exists(True) else reel_viewer_title.get_text() ) - return "" if not reel_viewer_title.exists() else reel_viewer_title.get_text() def getTimestamp(self): reel_viewer_timestamp = self.device.find( - resourceId="com.instagram.android:id/reel_viewer_timestamp", - className="android.widget.TextView", + resourceId=ResourceID.REEL_VIEWER_TIMESTAMP, + className=ClassName.TEXT_VIEW, ) if reel_viewer_timestamp.exists(): timestamp = reel_viewer_timestamp.get_text().strip() @@ -973,3 +1268,33 @@ def getTimestamp(self): class LanguageNotEnglishException(Exception): pass + + +class UniversalActions: + def __init__(self, device: DeviceFacade): + self.device = device + + def _swipe_points(self, direction: Direction, start_point_y=0, delta_y=450): + middle_point_x = self.device.get_info()["displayWidth"] / 2 + if start_point_y == 0: + start_point_y = self.device.get_info()["displayHeight"] / 2 + if start_point_y - delta_y < 0: + delta_y = start_point_y / 2 + if direction == Direction.UP: + self.device.swipe_points( + middle_point_x, + start_point_y, + middle_point_x, + start_point_y + delta_y, + ) + elif direction == Direction.DOWN: + self.device.swipe_points( + middle_point_x, + start_point_y, + middle_point_x, + start_point_y - delta_y, + ) + + def _reload_page(self): + logger.info("Reload page") + UniversalActions(self.device)._swipe_points(direction=Direction.UP) diff --git a/GramAddict/plugins/__init__.py b/GramAddict/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/GramAddict/plugins/action_unfollow_followers.py b/GramAddict/plugins/action_unfollow_followers.py index 8aad1658..6dc7896e 100644 --- a/GramAddict/plugins/action_unfollow_followers.py +++ b/GramAddict/plugins/action_unfollow_followers.py @@ -6,18 +6,17 @@ from GramAddict.core.device_facade import DeviceFacade from GramAddict.core.navigation import switch_to_english from GramAddict.core.plugin_loader import Plugin +from GramAddict.core.resources import ClassName, ResourceID as resources from GramAddict.core.storage import FollowingStatus from GramAddict.core.utils import detect_block, random_sleep, save_crash, get_value -from GramAddict.core.views import LanguageNotEnglishException +from GramAddict.core.views import ( + LanguageNotEnglishException, + UniversalActions, + Direction, +) logger = logging.getLogger(__name__) -FOLLOWING_BUTTON_ID_REGEX = ( - "com.instagram.android:id/row_profile_header_following_container" - "|com.instagram.android:id/row_profile_header_container_following" -) -BUTTON_REGEX = "android.widget.Button" -BUTTON_OR_TEXTVIEW_REGEX = "android.widget.Button|android.widget.TextView" FOLLOWING_REGEX = "^Following|^Requested" UNFOLLOW_REGEX = "^Unfollow" @@ -32,24 +31,32 @@ def __init__(self): { "arg": "--unfollow", "nargs": None, - "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", + "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. 10) or a range (e.g. 10-20)", + "metavar": "10-20", "default": None, "operation": True, }, { "arg": "--unfollow-non-followers", "nargs": None, - "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", + "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. 10) or a range (e.g. 10-20)", + "metavar": "10-20", + "default": None, + "operation": True, + }, + { + "arg": "--unfollow-any-non-followers", + "nargs": None, + "help": "unfollow at most given number of users, that don't follow you back. The order is from oldest to newest followings. It can be a number (e.g. 10) or a range (e.g. 10-20)", + "metavar": "10-20", "default": None, "operation": True, }, { "arg": "--unfollow-any", "nargs": None, - "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", + "help": "unfollow at most given number of users. The order is from oldest to newest followings. It can be a number (e.g. 10) or a range (e.g. 10-20)", + "metavar": "10-20", "default": None, "operation": True, }, @@ -62,7 +69,7 @@ def __init__(self): }, ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, configs, storage, sessions, plugin): class State: def __init__(self): pass @@ -70,27 +77,31 @@ def __init__(self): unfollowed_count = 0 is_job_completed = False - self.device_id = device_id + self.args = configs.args + self.device_id = configs.args.device self.state = State() self.session_state = sessions[-1] self.sessions = sessions - self.unfollow_type = plugin[2:] + self.unfollow_type = plugin + self.ResourceID = resources(self.args.app_id) count_arg = get_value( - getattr(args, self.unfollow_type.replace("-", "_")), + getattr(self.args, self.unfollow_type.replace("-", "_")), "Unfollow count: {}", 10, ) count = min( count_arg, - self.session_state.my_following_count - int(args.min_following), + self.session_state.my_following_count - int(self.args.min_following), ) if self.unfollow_type == "unfollow": self.unfollow_type = UnfollowRestriction.FOLLOWED_BY_SCRIPT elif self.unfollow_type == "unfollow-non-followers": self.unfollow_type = UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS + elif self.unfollow_type == "unfollow-any-non-followers": + self.unfollow_type = UnfollowRestriction.ANY_NON_FOLLOWERS else: self.unfollow_type = UnfollowRestriction.ANY @@ -101,7 +112,7 @@ def __init__(self): + ", you have " + str(self.session_state.my_following_count) + " followings, min following is " - + str(args.min_following) + + str(self.args.min_following) + ". Finish." ) return @@ -123,6 +134,7 @@ def job(): ) logger.info(f"Unfollowed {self.state.unfollowed_count}, finish.") self.state.is_job_completed = True + device.back() while not self.state.is_job_completed and (self.state.unfollowed_count < count): job() @@ -144,14 +156,20 @@ def on_unfollow(self): def open_my_followings(self, device): logger.info("Open my followings") - followings_button = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + followings_button = device.find( + resourceIdMatches=self.ResourceID.ROW_PROFILE_HEADER_FOLLOWING_CONTAINER + ) followings_button.click() def sort_followings_by_date(self, device): logger.info("Sort followings by date: from oldest to newest.") + UniversalActions(device)._swipe_points( + direction=Direction.DOWN, + ) + sort_button = device.find( - resourceId="com.instagram.android:id/sorting_entry_row_icon", - className="android.widget.ImageView", + resourceId=self.ResourceID.SORTING_ENTRY_ROW_ICON, + className=ClassName.IMAGE_VIEW, ) if not sort_button.exists(): logger.error( @@ -161,7 +179,7 @@ def sort_followings_by_date(self, device): sort_button.click() sort_options_recycler_view = device.find( - resourceId="com.instagram.android:id/follow_list_sorting_options_recycler_view" + resourceId=self.ResourceID.FOLLOW_LIST_SORTING_OPTIONS_RECYCLER_VIEW ) if not sort_options_recycler_view.exists(): logger.error( @@ -176,19 +194,37 @@ def iterate_over_followings( ): # Wait until list is rendered device.find( - resourceId="com.instagram.android:id/follow_list_container", - className="android.widget.LinearLayout", + resourceId=self.ResourceID.FOLLOW_LIST_CONTAINER, + className=ClassName.LINEAR_LAYOUT, ).wait() + sort_container_obj = device.find( + resourceId=self.ResourceID.SORTING_ENTRY_ROW_ICON + ) + top_tab_obj = device.find( + resourceId=self.ResourceID.UNIFIED_FOLLOW_LIST_TAB_LAYOUT + ) + if sort_container_obj.exists() and top_tab_obj.exists(): + sort_container_bounds = sort_container_obj.get_bounds()["top"] + list_tab_bounds = top_tab_obj.get_bounds()["bottom"] + delta = sort_container_bounds - list_tab_bounds + UniversalActions(device)._swipe_points( + direction=Direction.DOWN, + start_point_y=sort_container_bounds, + delta_y=delta - 50, + ) + else: + UniversalActions(device)._swipe_points( + direction=Direction.DOWN, + ) checked = {} unfollowed_count = 0 while True: logger.info("Iterate over visible followings") random_sleep() screen_iterated_followings = 0 - for item in device.find( - resourceId="com.instagram.android:id/follow_list_container", - className="android.widget.LinearLayout", + resourceId=self.ResourceID.FOLLOW_LIST_CONTAINER, + className=ClassName.LINEAR_LAYOUT, ): user_info_view = item.child(index=1) user_name_view = user_info_view.child(index=0).child() @@ -225,7 +261,10 @@ def iterate_over_followings( ) continue - if unfollow_restriction == UnfollowRestriction.ANY: + if ( + unfollow_restriction == UnfollowRestriction.ANY + or unfollow_restriction == UnfollowRestriction.ANY_NON_FOLLOWERS + ): following_status = storage.get_following_status(username) if following_status == FollowingStatus.UNFOLLOWED: logger.info( @@ -233,13 +272,14 @@ def iterate_over_followings( ) continue - logger.info("Unfollow @" + username) unfollowed = self.do_unfollow( device, username, my_username, unfollow_restriction - == UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS, + == UnfollowRestriction.FOLLOWED_BY_SCRIPT_NON_FOLLOWERS + or unfollow_restriction + == UnfollowRestriction.ANY_NON_FOLLOWERS, ) if unfollowed: storage.add_interacted_user(username, unfollowed=True) @@ -255,7 +295,7 @@ def iterate_over_followings( if screen_iterated_followings > 0: logger.info("Need to scroll now", extra={"color": f"{Fore.GREEN}"}) list_view = device.find( - resourceId="android:id/list", className="android.widget.ListView" + resourceId=self.ResourceID.LIST, className=ClassName.LIST_VIEW ) list_view.scroll(DeviceFacade.Direction.BOTTOM) else: @@ -265,13 +305,15 @@ def iterate_over_followings( ) return - def do_unfollow(self, device, username, my_username, check_if_is_follower): + def do_unfollow( + self, device: DeviceFacade, username, my_username, check_if_is_follower + ): """ :return: whether unfollow was successful """ username_view = device.find( - resourceId="com.instagram.android:id/follow_list_username", - className="android.widget.TextView", + resourceId=self.ResourceID.FOLLOW_LIST_USERNAME, + className=ClassName.TEXT_VIEW, text=username, ) if not username_view.exists(): @@ -287,22 +329,26 @@ def do_unfollow(self, device, username, my_username, check_if_is_follower): device.back() return False - attempts = 0 + unfollow_button = device.find( + classNameMatches=ClassName.BUTTON, + clickable=True, + textMatches=FOLLOWING_REGEX, + ) + # I don't know/remember the origin of this, if someone does - let's document it + attempts = 2 + for _ in range(attempts): + if unfollow_button.exists(): + break + + scrollable = device.find(classNameMatches=ClassName.VIEW_PAGER) + if scrollable.exists(): + scrollable.scroll(DeviceFacade.Direction.TOP) - while True: unfollow_button = device.find( - classNameMatches=BUTTON_REGEX, + classNameMatches=ClassName.BUTTON, clickable=True, textMatches=FOLLOWING_REGEX, ) - if not unfollow_button.exists() and attempts <= 1: - scrollable = device.find( - classNameMatches="androidx.viewpager.widget.ViewPager" - ) - scrollable.scroll(DeviceFacade.Direction.TOP) - attempts += 1 - else: - break if not unfollow_button.exists(): logger.error( @@ -311,24 +357,36 @@ def do_unfollow(self, device, username, my_username, check_if_is_follower): save_crash(device) switch_to_english(device) raise LanguageNotEnglishException() + unfollow_button.click() + logger.info(f"Unfollow @{username}.", extra={"color": f"{Fore.YELLOW}"}) + + # Weirdly enough, this is a fix for after you unfollow someone that follows + # you back - the next person you unfollow the button is missing on first find + # additional find - finds it. :shrug: + confirm_unfollow_button = None + attempts = 2 + for _ in range(attempts): + confirm_unfollow_button = device.find( + resourceId=self.ResourceID.FOLLOW_SHEET_UNFOLLOW_ROW, + className=ClassName.TEXT_VIEW, + ) + if confirm_unfollow_button.exists(): + break - confirm_unfollow_button = device.find( - resourceId="com.instagram.android:id/follow_sheet_unfollow_row", - className="android.widget.TextView", - ) - if not confirm_unfollow_button.exists(): + if not confirm_unfollow_button or not confirm_unfollow_button.exists(): logger.error("Cannot confirm unfollow.") save_crash(device) device.back() return False + confirm_unfollow_button.click() - random_sleep() + random_sleep(0, 1) # Check if private account confirmation private_unfollow_button = device.find( - classNameMatches=BUTTON_OR_TEXTVIEW_REGEX, + classNameMatches=ClassName.BUTTON_OR_TEXTVIEW_REGEX, textMatches=UNFOLLOW_REGEX, ) @@ -346,14 +404,16 @@ def check_is_follower(self, device, username, my_username): logger.info( f"Check if @{username} is following you.", extra={"color": f"{Fore.GREEN}"} ) - following_container = device.find(resourceIdMatches=FOLLOWING_BUTTON_ID_REGEX) + following_container = device.find( + resourceIdMatches=self.ResourceID.ROW_PROFILE_HEADER_FOLLOWING_CONTAINER + ) following_container.click() - random_sleep() + random_sleep(4, 6) my_username_view = device.find( - resourceId="com.instagram.android:id/follow_list_username", - className="android.widget.TextView", + resourceId=self.ResourceID.FOLLOW_LIST_USERNAME, + className=ClassName.TEXT_VIEW, text=my_username, ) result = my_username_view.exists() @@ -367,3 +427,4 @@ class UnfollowRestriction(Enum): ANY = 0 FOLLOWED_BY_SCRIPT = 1 FOLLOWED_BY_SCRIPT_NON_FOLLOWERS = 2 + ANY_NON_FOLLOWERS = 3 diff --git a/GramAddict/plugins/cloned_app.py b/GramAddict/plugins/cloned_app.py new file mode 100644 index 00000000..4a50d902 --- /dev/null +++ b/GramAddict/plugins/cloned_app.py @@ -0,0 +1,20 @@ +from GramAddict.core.plugin_loader import Plugin + +# Not really a plugin, but didn't wanna add the parameter to coreå + + +class ClonedApp(Plugin): + """Adds support for cloned apps""" + + def __init__(self): + super().__init__() + self.description = "Adds support for cloned apps" + self.arguments = [ + { + "arg": "--app-id", + "nargs": None, + "help": "provide app-id if using a custom/cloned app", + "metavar": "com.instagram.android", + "default": "com.instagram.android", + }, + ] diff --git a/GramAddict/plugins/core_arguments.py b/GramAddict/plugins/core_arguments.py index cba018a5..2f84686e 100644 --- a/GramAddict/plugins/core_arguments.py +++ b/GramAddict/plugins/core_arguments.py @@ -17,10 +17,17 @@ def __init__(self): "metavar": "2443de990e017ece", "default": None, }, + { + "arg": "--username", + "nargs": None, + "help": "username of the instagram account being used", + "metavar": "justinbieber", + "default": None, + }, { "arg": "--likes-count", "nargs": None, - "help": "number of likes for each interacted user, 2 by default. It can be a number (e.g. 2) or a range (e.g. 2-4)", + "help": "number of likes for each interacted user, 1-2 by default. It can be a number (e.g. 2) or a range (e.g. 2-4)", "metavar": "2-4", "default": "1-2", }, @@ -69,14 +76,14 @@ def __init__(self): { "arg": "--stories-percentage", "nargs": None, - "help": "chance of watching stories on a particular profile, 30-40 by default. It can be a number (e.g. 2) or a range (e.g. 2-4)", + "help": "chance of watching stories on a particular profile, 30-40 by default. It can be a number (e.g. 20) or a range (e.g. 20-40)", "metavar": "50-70", "default": "30-40", }, { "arg": "--interactions-count", "nargs": None, - "help": "number of interactions per each blogger, 70 by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count", + "help": "number of interactions per each blogger, 30-50 by default. It can be a number (e.g. 70) or a range (e.g. 60-80). Only successful interactions count", "metavar": "60-80", "default": "30-50", }, @@ -101,18 +108,50 @@ def __init__(self): "metavar": "0", "default": None, }, + { + "arg": "--skipped-list-limit", + "nargs": None, + "help": "limit how many scrolls tried, with already interacted users, until we move to next source. Does not apply for unfollows.", + "metavar": "10-15", + "default": "10-15", + }, + { + "arg": "--fling-when-skipped", + "nargs": None, + "help": 'fling after "X" many scrolls tried, with already interacted users. (not recommended - disabled by default)', + "metavar": "10-12", + "default": "0", + }, + { + "arg": "--speed-multiplier", + "nargs": None, + "help": "modifier for random sleep values - slows down (>1) or speeds up (<1) depending on multiplier passed.", + "metavar": 1, + "default": 1, + }, { "arg": "--screen-sleep", "help": "save your screen by turning it off during the inactive time, disabled by default", "action": "store_true", }, + { + "arg": "--debug", + "help": "enable debug logging", + "action": "store_true", + }, + { + "arg": "--uia-version", + "nargs": None, + "help": "uiautomator version, defaults to 2.", + "metavar": 2, + "default": 2, + }, { "arg": "--interact", "nargs": "+", "help": "list of @usernames or #hashtags with whose followers you want to interact", "metavar": ("@username1", "@username2"), "default": None, - "operation": True, }, { "arg": "--hashtag-likers", @@ -120,6 +159,10 @@ def __init__(self): "help": "list of hashtags with whose likers you want to interact", "metavar": ("hashtag1", "hashtag2"), "default": None, - "operation": True, + }, + { + "arg": "--delete-interacted-users", + "help": "delete the user from the file after processing it", + "action": "store_true", }, ] diff --git a/GramAddict/plugins/data_analytics.py b/GramAddict/plugins/data_analytics.py index 9cd765af..b4253bca 100644 --- a/GramAddict/plugins/data_analytics.py +++ b/GramAddict/plugins/data_analytics.py @@ -25,7 +25,7 @@ def __init__(self): self.arguments = [ { "arg": "--analytics", - "nargs": 1, + "nargs": None, "help": "generates a PDF analytics report of specified username session data", "metavar": "username1", "default": None, @@ -33,8 +33,9 @@ def __init__(self): } ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): - self.username = args.analytics[0] + def run(self, device, configs, storage, sessions, plugin): + self.args = configs.args + self.username = self.args.analytics sessions = self.load_sessions() if not sessions: return @@ -43,7 +44,7 @@ def run(self, device, device_id, args, enabled, storage, sessions, plugin): "report_" + self.username + "_" - + datetime.now().strftime("%Y-%m-%d") + + datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + ".pdf" ) with PdfPages(filename) as pdf: @@ -79,11 +80,15 @@ def load_sessions(self): return None def plot_followers_growth(self, sessions, pdf, username, period): - followers_count = [int(session["profile"]["followers"]) for session in sessions] + followers_count = [ + int(session.get("profile", {}).get("followers", 0)) for session in sessions + ] dates = [self.get_start_time(session) for session in sessions] - total_followed = [int(session["total_followed"]) for session in sessions] - total_unfollowed = [-int(session["total_unfollowed"]) for session in sessions] - total_likes = [int(session["total_likes"]) for session in sessions] + total_followed = [int(session.get("total_followed", 0)) for session in sessions] + total_unfollowed = [ + -int(session.get("total_unfollowed", 0)) for session in sessions + ] + total_likes = [int(session.get("total_likes", 0)) for session in sessions] fig, (axes1, axes2, axes3) = plt.subplots( ncols=1, diff --git a/GramAddict/plugins/force_interact.dis b/GramAddict/plugins/force_interact.dis index 5e294119..263c24fd 100644 --- a/GramAddict/plugins/force_interact.dis +++ b/GramAddict/plugins/force_interact.dis @@ -23,11 +23,12 @@ from GramAddict.core.filter import Filter from GramAddict.core.interaction import ( _on_interaction, _on_like, + _on_watch, interact_with_user, is_follow_limit_reached_for_source, ) from GramAddict.core.plugin_loader import Plugin -from GramAddict.core.scroll_end_detector import ScrollEndDetector +from GramAddict.core.resources import ClassName, ResourceID as resources from GramAddict.core.storage import FollowingStatus from GramAddict.core.utils import get_value, random_sleep, save_crash @@ -35,8 +36,6 @@ logger = logging.getLogger(__name__) from GramAddict.core.views import TabBarView -BUTTON_REGEX = "android.widget.Button" -BUTTON_OR_TEXTVIEW_REGEX = "android.widget.Button|android.widget.TextView" FOLLOWING_REGEX = "^Following|^Requested" UNFOLLOW_REGEX = "^Unfollow" @@ -62,18 +61,21 @@ class ForceIteract(Plugin): } ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, configs, storage, sessions, plugin): class State: def __init__(self): pass is_job_completed = False - self.device_id = device_id + self.args = configs.args + self.device_id = configs.args.device self.state = None self.sessions = sessions self.session_state = sessions[-1] + self.ResourceID = resources(self.args.app_id) profile_filter = Filter() + self.current_mode = plugin # IMPORTANT: in each job we assume being on the top of the Profile tab already sources = [source for source in args.force_interact] @@ -94,13 +96,17 @@ class ForceIteract(Plugin): ), sessions=self.sessions, session_state=self.session_state, - args=args, + args=self.args, ) on_like = partial( _on_like, sessions=self.sessions, session_state=self.session_state ) + on_watch = partial( + _on_watch, sessions=self.sessions, session_state=self.session_state + ) + if args.stories_count != "0": stories_percentage = get_value( args.stories_percentage, "Chance of watching stories: {}%", 40 @@ -126,6 +132,7 @@ class ForceIteract(Plugin): storage, profile_filter, on_like, + on_watch, on_interaction, ) self.state.is_job_completed = True @@ -145,6 +152,7 @@ class ForceIteract(Plugin): storage, profile_filter, on_like, + on_watch, on_interaction, ): is_myself = username == self.session_state.my_username @@ -156,7 +164,11 @@ class ForceIteract(Plugin): stories_percentage=stories_percentage, follow_percentage=follow_percentage, on_like=on_like, + on_watch=on_watch, profile_filter=profile_filter, + args=self.args, + session_state=self.session_state, + current_mode=self.current_mode, ) is_follow_limit_reached = partial( is_follow_limit_reached_for_source, @@ -172,11 +184,9 @@ class ForceIteract(Plugin): not is_myself and not is_follow_limit_reached() and ( - storage.get_following_status(username) - == FollowingStatus.NONE - or storage.get_following_status(username) - == FollowingStatus.NOT_IN_LIST - ) + storage.get_following_status(username) == FollowingStatus.NONE + or storage.get_following_status(username) == FollowingStatus.NOT_IN_LIST + ) ) interaction_succeed, followed = interaction( @@ -188,30 +198,30 @@ class ForceIteract(Plugin): logger.info("Unfollow @" + username) attempts = 0 - while True: + unfollow_button = None + attempts = 2 + for _ in range(attempts) unfollow_button = device.find( - classNameMatches=BUTTON_REGEX, + classNameMatches=ClassName.BUTTON, clickable=True, textMatches=FOLLOWING_REGEX, ) - if not unfollow_button.exists() and attempts <= 1: - scrollable = device.find( - classNameMatches="androidx.viewpager.widget.ViewPager" - ) - scrollable.scroll(DeviceFacade.Direction.TOP) - attempts += 1 - else: + if unfollow_button.exists(): break + scrollable = device.find( + classNameMatches=ClassName.VIEW_PAGER + ) + scrollable.scroll(DeviceFacade.Direction.TOP) - if not unfollow_button.exists(): + if not unfollow_button or not unfollow_button.exists(): logger.error("Cannot find Following button.") save_crash(device) unfollow_button.click() confirm_unfollow_button = device.find( - resourceId="com.instagram.android:id/follow_sheet_unfollow_row", - className="android.widget.TextView", + resourceId=self.ResourceID.FOLLOW_SHEET_UNFOLLOW_ROW, + className=ClassName.TEXT_VIEW, ) if not confirm_unfollow_button.exists(): logger.error("Cannot confirm unfollow.") @@ -222,7 +232,7 @@ class ForceIteract(Plugin): # Check if private account confirmation private_unfollow_button = device.find( - classNameMatches=BUTTON_OR_TEXTVIEW_REGEX, + classNameMatches=ClassName.BUTTON_OR_TEXTVIEW_REGEX, textMatches=UNFOLLOW_REGEX, ) diff --git a/GramAddict/plugins/interact_blogger_followers.py b/GramAddict/plugins/interact_blogger_followers.py index 38cd5976..17c4336d 100644 --- a/GramAddict/plugins/interact_blogger_followers.py +++ b/GramAddict/plugins/interact_blogger_followers.py @@ -14,6 +14,7 @@ is_follow_limit_reached_for_source, ) from GramAddict.core.plugin_loader import Plugin +from GramAddict.core.resources import ClassName, ResourceID as resources from GramAddict.core.scroll_end_detector import ScrollEndDetector from GramAddict.core.storage import FollowingStatus from GramAddict.core.utils import get_value, random_sleep @@ -22,11 +23,6 @@ from GramAddict.core.views import TabBarView -FOLLOWERS_BUTTON_ID_REGEX = ( - "com.instagram.android:id/row_profile_header_followers_container" - "|com.instagram.android:id/row_profile_header_container_followers" -) - # Script Initialization seed() @@ -50,29 +46,31 @@ def __init__(self): } ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, configs, storage, sessions, plugin): class State: def __init__(self): pass is_job_completed = False - self.device_id = device_id + self.device_id = configs.args.device self.state = None self.sessions = sessions self.session_state = sessions[-1] - self.args = args + self.args = configs.args + self.ResourceID = resources(self.args.app_id) profile_filter = Filter() + self.current_mode = plugin # IMPORTANT: in each job we assume being on the top of the Profile tab already - sources = [source for source in args.blogger_followers] + sources = [source for source in self.args.blogger_followers] shuffle(sources) for source in sources: limit_reached = self.session_state.check_limit( - args, limit_type=self.session_state.Limit.LIKES + self.args, limit_type=self.session_state.Limit.LIKES ) and self.session_state.check_limit( - args, limit_type=self.session_state.Limit.FOLLOWS + self.args, limit_type=self.session_state.Limit.FOLLOWS ) self.state = State() @@ -82,10 +80,10 @@ def __init__(self): on_interaction = partial( _on_interaction, - likes_limit=int(args.total_likes_limit), + likes_limit=int(self.args.total_likes_limit), source=source, interactions_limit=get_value( - args.interactions_count, "Interactions count: {}", 70 + self.args.interactions_count, "Interactions count: {}", 70 ), sessions=self.sessions, session_state=self.session_state, @@ -100,9 +98,9 @@ def __init__(self): _on_watch, sessions=self.sessions, session_state=self.session_state ) - if args.stories_count != "0": + if self.args.stories_count != "0": stories_percentage = get_value( - args.stories_percentage, "Chance of watching stories: {}%", 40 + self.args.stories_percentage, "Chance of watching stories: {}%", 40 ) else: stories_percentage = 0 @@ -117,11 +115,11 @@ def job(): self.handle_blogger( device, source[1:] if "@" in source else source, - args.likes_count, - args.stories_count, + self.args.likes_count, + self.args.stories_count, stories_percentage, - int(args.follow_percentage), - int(args.follow_limit) if args.follow_limit else None, + int(self.args.follow_percentage), + int(self.args.follow_limit) if self.args.follow_limit else None, storage, profile_filter, on_like, @@ -136,7 +134,7 @@ def job(): if limit_reached: logger.info("Likes and follows limit reached.") self.session_state.check_limit( - args, limit_type=self.session_state.Limit.ALL, output=True + self.args, limit_type=self.session_state.Limit.ALL, output=True ) break @@ -168,6 +166,7 @@ def handle_blogger( profile_filter=profile_filter, args=self.args, session_state=self.session_state, + current_mode=self.current_mode, ) is_follow_limit_reached = partial( is_follow_limit_reached_for_source, @@ -187,6 +186,8 @@ def handle_blogger( storage, on_interaction, is_myself, + skipped_list_limit=get_value(self.args.skipped_list_limit, None, 15), + skipped_fling_limit=get_value(self.args.fling_when_skipped, None, 0), ) def open_user_followers(self, device, username): @@ -211,13 +212,13 @@ def scroll_to_bottom(self, device): def is_end_reached(): see_all_button = device.find( - resourceId="com.instagram.android:id/see_all_button", - className="android.widget.TextView", + resourceId=self.ResourceID.SEE_ALL_BUTTON, + className=ClassName.TEXT_VIEW, ) return see_all_button.exists() list_view = device.find( - resourceId="android:id/list", className="android.widget.ListView" + resourceId=self.ResourceID.LIST, className=ClassName.LIST_VIEW ) while not is_end_reached(): list_view.fling(DeviceFacade.Direction.BOTTOM) @@ -226,8 +227,8 @@ def is_end_reached(): def is_at_least_one_follower(): follower = device.find( - resourceId="com.instagram.android:id/follow_list_container", - className="android.widget.LinearLayout", + resourceId=self.ResourceID.FOLLOW_LIST_CONTAINER, + className=ClassName.LINEAR_LAYOUT, ) return follower.exists() @@ -242,21 +243,26 @@ def iterate_over_followers( storage, on_interaction, is_myself, + skipped_list_limit, + skipped_fling_limit, ): # Wait until list is rendered device.find( - resourceId="com.instagram.android:id/follow_list_container", - className="android.widget.LinearLayout", + resourceId=self.ResourceID.FOLLOW_LIST_CONTAINER, + className=ClassName.LINEAR_LAYOUT, ).wait() def scrolled_to_top(): row_search = device.find( - resourceId="com.instagram.android:id/row_search_edit_text", - className="android.widget.EditText", + resourceId=self.ResourceID.ROW_SEARCH_EDIT_TEXT, + className=ClassName.EDIT_TEXT, ) return row_search.exists() - scroll_end_detector = ScrollEndDetector() + scroll_end_detector = ScrollEndDetector( + skipped_list_limit=skipped_list_limit, + skipped_fling_limit=skipped_fling_limit, + ) while True: logger.info("Iterate over visible followers") random_sleep() @@ -266,8 +272,8 @@ def scrolled_to_top(): try: for item in device.find( - resourceId="com.instagram.android:id/follow_list_container", - className="android.widget.LinearLayout", + resourceId=self.ResourceID.FOLLOW_LIST_CONTAINER, + className=ClassName.LINEAR_LAYOUT, ): user_info_view = item.child(index=1) user_name_view = user_info_view.child(index=0).child() @@ -335,7 +341,7 @@ def scrolled_to_top(): return elif len(screen_iterated_followers) > 0: load_more_button = device.find( - resourceId="com.instagram.android:id/row_load_more_button" + resourceId=self.ResourceID.ROW_LOAD_MORE_BUTTON ) load_more_button_exists = load_more_button.exists(quick=True) @@ -346,7 +352,7 @@ def scrolled_to_top(): screen_iterated_followers ) list_view = device.find( - resourceId="android:id/list", className="android.widget.ListView" + resourceId=self.ResourceID.LIST, className=ClassName.LIST_VIEW ) if not list_view.exists(): logger.error( @@ -354,8 +360,8 @@ def scrolled_to_top(): ) device.back() list_view = device.find( - resourceId="android:id/list", - className="android.widget.ListView", + resourceId=self.ResourceID.LIST, + className=ClassName.LIST_VIEW, ) if is_myself: @@ -365,7 +371,7 @@ def scrolled_to_top(): pressed_retry = False if load_more_button_exists: retry_button = load_more_button.child( - className="android.widget.ImageView" + className=ClassName.IMAGE_VIEW ) if retry_button.exists(): logger.info('Press "Load" button') @@ -374,11 +380,21 @@ def scrolled_to_top(): pressed_retry = True if need_swipe and not pressed_retry: - logger.info( - "All followers skipped, let's scroll.", - extra={"color": f"{Fore.GREEN}"}, - ) - list_view.scroll(DeviceFacade.Direction.BOTTOM) + scroll_end_detector.notify_skipped_all() + if scroll_end_detector.is_skipped_limit_reached(): + return + if scroll_end_detector.is_fling_limit_reached(): + logger.info( + "Limit of all followers skipped reached, let's fling.", + extra={"color": f"{Fore.GREEN}"}, + ) + list_view.fling(DeviceFacade.Direction.BOTTOM) + else: + logger.info( + "All followers skipped, let's scroll.", + extra={"color": f"{Fore.GREEN}"}, + ) + list_view.scroll(DeviceFacade.Direction.BOTTOM) else: logger.info( "Need to scroll now", extra={"color": f"{Fore.GREEN}"} diff --git a/GramAddict/plugins/interact_hashtag_likers.py b/GramAddict/plugins/interact_hashtag_likers.py index ba986a5d..47527859 100644 --- a/GramAddict/plugins/interact_hashtag_likers.py +++ b/GramAddict/plugins/interact_hashtag_likers.py @@ -20,9 +20,9 @@ from GramAddict.core.views import ( TabBarView, HashTagView, - ProfileView, OpenedPostView, PostsViewList, + SwipeTo, ) logger = logging.getLogger(__name__) @@ -58,30 +58,36 @@ def __init__(self): }, ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, configs, storage, sessions, plugin): class State: def __init__(self): pass is_job_completed = False - self.device_id = device_id + self.device_id = configs.args.device self.sessions = sessions self.session_state = sessions[-1] - self.args = args + self.args = configs.args profile_filter = Filter() + self.current_mode = plugin # IMPORTANT: in each job we assume being on the top of the Profile tab already sources = [ - source for source in (args.hashtag_likers_top or args.hashtag_likers_recent) + source + for source in ( + self.args.hashtag_likers_top + if self.current_mode == "hashtag-likers-top" + else self.args.hashtag_likers_recent + ) ] shuffle(sources) for source in sources: limit_reached = self.session_state.check_limit( - args, limit_type=self.session_state.Limit.LIKES + self.args, limit_type=self.session_state.Limit.LIKES ) and self.session_state.check_limit( - args, limit_type=self.session_state.Limit.FOLLOWS + self.args, limit_type=self.session_state.Limit.FOLLOWS ) self.state = State() @@ -91,10 +97,10 @@ def __init__(self): on_interaction = partial( _on_interaction, - likes_limit=int(args.total_likes_limit), + likes_limit=int(self.args.total_likes_limit), source=source, interactions_limit=get_value( - args.interactions_count, "Interactions count: {}", 70 + self.args.interactions_count, "Interactions count: {}", 70 ), sessions=self.sessions, session_state=self.session_state, @@ -109,9 +115,9 @@ def __init__(self): _on_watch, sessions=self.sessions, session_state=self.session_state ) - if args.stories_count != "0": + if self.args.stories_count != "0": stories_percentage = get_value( - args.stories_percentage, "Chance of watching stories: {}%", 40 + self.args.stories_percentage, "Chance of watching stories: {}%", 40 ) else: stories_percentage = 0 @@ -126,13 +132,12 @@ def job(): self.handle_hashtag( device, source, - args.likes_count, - args.stories_count, + self.args.likes_count, + self.args.stories_count, stories_percentage, - int(args.follow_percentage), - int(args.follow_limit) if args.follow_limit else None, - args.hashtag_likers_recent, - # args.recent_tab, + int(self.args.follow_percentage), + int(self.args.follow_limit) if self.args.follow_limit else None, + plugin, storage, profile_filter, on_like, @@ -147,7 +152,7 @@ def job(): if limit_reached: logger.info("Likes and follows limit reached.") self.session_state.check_limit( - args, limit_type=self.session_state.Limit.ALL, output=True + self.args, limit_type=self.session_state.Limit.ALL, output=True ) break @@ -160,8 +165,7 @@ def handle_hashtag( stories_percentage, follow_percentage, follow_limit, - hashtag_likers_recent, - # recent_tab, + current_job, storage, profile_filter, on_like, @@ -180,6 +184,7 @@ def handle_hashtag( profile_filter=profile_filter, args=self.args, session_state=self.session_state, + current_mode=self.current_mode, ) is_follow_limit_reached = partial( @@ -189,15 +194,16 @@ def handle_hashtag( session_state=self.session_state, ) search_view = TabBarView(device).navigateToSearch() - random_sleep() if not search_view.navigateToHashtag(hashtag): return - if hashtag_likers_recent != None: + if current_job == "hashtag-likers-recent": logger.info("Switching to Recent tab") HashTagView(device)._getRecentTab().click() - random_sleep() - random_sleep() # wonder if it possible to check if everything is loaded instead of doing multiple random_sleep.. + random_sleep(5, 10) + if HashTagView(device)._check_if_no_posts(): + HashTagView(device)._reload_page() + random_sleep(4, 8) logger.info("Opening the first result") @@ -205,43 +211,60 @@ def handle_hashtag( HashTagView(device)._getFistImageView(result_view).click() random_sleep() - posts_list_view = ProfileView(device)._getRecyclerView() - posts_end_detector = ScrollEndDetector(repeats_to_end=2) - first_post = True + skipped_list_limit = get_value(self.args.skipped_list_limit, None, 15) + skipped_fling_limit = get_value(self.args.fling_when_skipped, None, 0) + + posts_end_detector = ScrollEndDetector( + repeats_to_end=2, + skipped_list_limit=skipped_list_limit, + skipped_fling_limit=skipped_fling_limit, + ) + post_description = "" + nr_same_post = 0 + nr_same_posts_max = 3 while True: - if first_post: - PostsViewList(device).swipe_to_fit_posts(True) - first_post = False - if not OpenedPostView(device).open_likers(): - logger.info( - "No likes, let's scroll down.", extra={"color": f"{Fore.GREEN}"} - ) - PostsViewList(device).swipe_to_fit_posts(False) + likers_container_exists = PostsViewList(device)._find_likers_container() + has_one_liker_or_none = PostsViewList( + device + )._check_if_only_one_liker_or_none() - flag, post_description = PostsViewList(device).check_if_last_post( - post_description + flag, post_description = PostsViewList(device)._check_if_last_post( + post_description + ) + if flag: + nr_same_post += 1 + logger.info( + f"Warning: {nr_same_post}/{nr_same_posts_max} repeated posts." ) - if not flag: - continue - else: + if nr_same_post == nr_same_posts_max: + logger.info( + f"Scrolled through {nr_same_posts_max} posts with same description and author. Finish." + ) break + else: + nr_same_post = 0 + + if likers_container_exists and not has_one_liker_or_none: + PostsViewList(device).open_likers_container() + else: + PostsViewList(device).swipe_to_fit_posts(SwipeTo.NEXT_POST) + continue - logger.info("List of likers is opened.") + logger.info("Open list of likers.") posts_end_detector.notify_new_page() random_sleep() likes_list_view = OpenedPostView(device)._getListViewLikers() prev_screen_iterated_likers = [] - while True: logger.info("Iterate over visible likers.") screen_iterated_likers = [] + opened = False try: for item in OpenedPostView(device)._getUserCountainer(): username_view = OpenedPostView(device)._getUserName(item) - if not username_view.exists(quick=True): logger.info( "Next item not found: probably reached end of the screen.", @@ -250,10 +273,14 @@ def handle_hashtag( break username = username_view.get_text() + profile_interact = profile_filter.check_profile_from_list( + device, item, username + ) screen_iterated_likers.append(username) posts_end_detector.notify_username_iterated(username) - - if storage.is_user_in_blacklist(username): + if not profile_interact: + continue + elif storage.is_user_in_blacklist(username): logger.info(f"@{username} is in blacklist. Skip.") continue elif storage.check_user_was_interacted(username): @@ -274,13 +301,14 @@ def handle_hashtag( device, username=username, can_follow=can_follow ) storage.add_interacted_user(username, followed=followed) + opened = True can_continue = on_interaction( succeed=interaction_succeed, followed=followed ) if not can_continue: return - logger.info("Back to likers list") + logger.info("Back to likers list.") device.back() random_sleep() except IndexError: @@ -288,23 +316,53 @@ def handle_hashtag( "Cannot get next item: probably reached end of the screen.", extra={"color": f"{Fore.GREEN}"}, ) + break + go_back = False + if not opened: + logger.info( + "All likers skipped.", + extra={"color": f"{Fore.GREEN}"}, + ) + posts_end_detector.notify_skipped_all() + if posts_end_detector.is_skipped_limit_reached(): + posts_end_detector.reset_skipped_all() + device.back() + PostsViewList(device).swipe_to_fit_posts(False) + break if screen_iterated_likers == prev_screen_iterated_likers: logger.info( - "Iterated exactly the same likers twice, finish.", + "Iterated exactly the same likers twice.", + extra={"color": f"{Fore.GREEN}"}, + ) + go_back = True + if posts_end_detector.is_fling_limit_reached(): + prev_screen_iterated_likers.clear() + prev_screen_iterated_likers += screen_iterated_likers + logger.info( + "Reached fling limit. Fling to see other likers", + extra={"color": f"{Fore.GREEN}"}, + ) + likes_list_view.fling(DeviceFacade.Direction.BOTTOM) + else: + prev_screen_iterated_likers.clear() + prev_screen_iterated_likers += screen_iterated_likers + logger.info( + "Scroll to see other likers", + extra={"color": f"{Fore.GREEN}"}, + ) + likes_list_view.scroll(DeviceFacade.Direction.BOTTOM) + if go_back: + prev_screen_iterated_likers.clear() + prev_screen_iterated_likers += screen_iterated_likers + logger.info( + f"Back to {hashtag}'s posts list.", extra={"color": f"{Fore.GREEN}"}, ) - logger.info(f"Back to {hashtag}") device.back() + logger.info("Going to the next post.") + PostsViewList(device).swipe_to_fit_posts(SwipeTo.NEXT_POST) break - prev_screen_iterated_likers.clear() - prev_screen_iterated_likers += screen_iterated_likers - - logger.info("Need to scroll now", extra={"color": f"{Fore.GREEN}"}) - likes_list_view.scroll(DeviceFacade.Direction.BOTTOM) - - if posts_end_detector.is_the_end(): - break - else: - posts_list_view.scroll(DeviceFacade.Direction.BOTTOM) + if posts_end_detector.is_the_end(): + break diff --git a/GramAddict/plugins/interact_hashtag_posts.py b/GramAddict/plugins/interact_hashtag_posts.py new file mode 100644 index 00000000..072ec43d --- /dev/null +++ b/GramAddict/plugins/interact_hashtag_posts.py @@ -0,0 +1,291 @@ +import logging +from functools import partial +from random import seed, shuffle + +from colorama import Style +from GramAddict.core.decorators import run_safely +from GramAddict.core.filter import Filter +from GramAddict.core.interaction import ( + _on_interaction, + _on_like, + _on_watch, + interact_with_user, + is_follow_limit_reached_for_source, +) +from GramAddict.core.plugin_loader import Plugin +from GramAddict.core.storage import FollowingStatus +from GramAddict.core.utils import get_value, random_sleep, detect_block +from GramAddict.core.views import ( + TabBarView, + HashTagView, + PostsViewList, + SwipeTo, + LikeMode, + Owner, + UniversalActions, +) + +logger = logging.getLogger(__name__) + +# Script Initialization +seed() + + +class InteractHashtagLikers(Plugin): + """Handles the functionality of interacting with a hashtags post owners""" + + def __init__(self): + super().__init__() + self.description = ( + "Handles the functionality of interacting with a hashtags post owners" + ) + self.arguments = [ + { + "arg": "--hashtag-posts-recent", + "nargs": "+", + "help": "interact to hashtag post owners in recent tab", + "metavar": ("hashtag1", "hashtag2"), + "default": None, + "operation": True, + }, + { + "arg": "--hashtag-posts-top", + "nargs": "+", + "help": "interact to hashtag post owners in top tab", + "metavar": ("hashtag1", "hashtag2"), + "default": None, + "operation": True, + }, + { + "arg": "--interact-percentage", + "nargs": None, + "help": "chance to interact with user/hashtag when applicable (currently in hashtag-posts-recent/top)", + "metavar": "50", + "default": "50", + }, + ] + + def run(self, device, configs, storage, sessions, plugin): + class State: + def __init__(self): + pass + + is_job_completed = False + + self.device_id = configs.args.device + self.sessions = sessions + self.session_state = sessions[-1] + self.args = configs.args + profile_filter = Filter() + self.current_mode = plugin + + # IMPORTANT: in each job we assume being on the top of the Profile tab already + sources = [ + source + for source in ( + self.args.hashtag_posts_top + if self.current_mode == "hashtag-posts-top" + else self.args.hashtag_posts_recent + ) + ] + shuffle(sources) + + for source in sources: + limit_reached = self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.LIKES + ) and self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.FOLLOWS + ) + + self.state = State() + if source[0] != "#": + source = "#" + source + logger.info(f"Handle {source}", extra={"color": f"{Style.BRIGHT}"}) + + on_interaction = partial( + _on_interaction, + likes_limit=int(self.args.total_likes_limit), + source=source, + interactions_limit=get_value( + self.args.interactions_count, "Interactions count: {}", 70 + ), + sessions=self.sessions, + session_state=self.session_state, + args=self.args, + ) + + on_like = partial( + _on_like, sessions=self.sessions, session_state=self.session_state + ) + + on_watch = partial( + _on_watch, sessions=self.sessions, session_state=self.session_state + ) + + if self.args.stories_count != "0": + stories_percentage = get_value( + self.args.stories_percentage, "Chance of watching stories: {}%", 40 + ) + else: + stories_percentage = 0 + + @run_safely( + device=device, + device_id=self.device_id, + sessions=self.sessions, + session_state=self.session_state, + ) + def job(): + self.handle_hashtag( + device, + source, + self.args.likes_count, + self.args.stories_count, + stories_percentage, + int(self.args.follow_percentage), + int(self.args.follow_limit) if self.args.follow_limit else None, + int(self.args.interact_percentage), + plugin, + storage, + profile_filter, + on_like, + on_watch, + on_interaction, + ) + self.state.is_job_completed = True + + while not self.state.is_job_completed and not limit_reached: + job() + + if limit_reached: + logger.info("Likes and follows limit reached.") + self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.ALL, output=True + ) + break + + def handle_hashtag( + self, + device, + hashtag, + likes_count, + stories_count, + stories_percentage, + follow_percentage, + follow_limit, + interact_percentage, + current_job, + storage, + profile_filter, + on_like, + on_watch, + on_interaction, + ): + interaction = partial( + interact_with_user, + my_username=self.session_state.my_username, + likes_count=likes_count, + stories_count=stories_count, + stories_percentage=stories_percentage, + follow_percentage=follow_percentage, + on_like=on_like, + on_watch=on_watch, + profile_filter=profile_filter, + args=self.args, + session_state=self.session_state, + current_mode=self.current_mode, + ) + + is_follow_limit_reached = partial( + is_follow_limit_reached_for_source, + follow_limit=follow_limit, + source=hashtag, + session_state=self.session_state, + ) + search_view = TabBarView(device).navigateToSearch() + if not search_view.navigateToHashtag(hashtag): + return + if current_job == "hashtag-posts-recent": + logger.info("Switching to Recent tab") + HashTagView(device)._getRecentTab().click() + random_sleep(5, 10) + if HashTagView(device)._check_if_no_posts(): + UniversalActions(device)._reload_page() + random_sleep(4, 8) + + logger.info("Opening the first result") + + result_view = HashTagView(device)._getRecyclerView() + HashTagView(device)._getFistImageView(result_view).click() + random_sleep() + + def interact(): + can_follow = not is_follow_limit_reached() and ( + storage.get_following_status(username) == FollowingStatus.NONE + or storage.get_following_status(username) == FollowingStatus.NOT_IN_LIST + ) + + interaction_succeed, followed = interaction( + device, username=username, can_follow=can_follow + ) + storage.add_interacted_user(username, followed=followed) + can_continue = on_interaction( + succeed=interaction_succeed, followed=followed + ) + if not can_continue: + return False + else: + return True + + def random_choice(): + from random import randint + + random_number = randint(1, 100) + if interact_percentage > random_number: + return True + else: + return False + + post_description = "" + nr_same_post = 0 + nr_same_posts_max = 3 + while True: + flag, post_description = PostsViewList(device)._check_if_last_post( + post_description + ) + if flag: + nr_same_post += 1 + logger.info( + f"Warning: {nr_same_post}/{nr_same_posts_max} repeated posts." + ) + if nr_same_post == nr_same_posts_max: + logger.info( + f"Scrolled through {nr_same_posts_max} posts with same description and author. Finish." + ) + break + else: + nr_same_post = 0 + if random_choice(): + username = PostsViewList(device)._post_owner(Owner.GET_NAME)[:-3] + if storage.is_user_in_blacklist(username): + logger.info(f"@{username} is in blacklist. Skip.") + elif storage.check_user_was_interacted(username): + logger.info(f"@{username}: already interacted. Skip.") + else: + logger.info(f"@{username}: interact") + PostsViewList(device)._like_in_post_view(LikeMode.DOUBLE_CLICK) + detect_block(device) + if not PostsViewList(device)._check_if_liked(): + PostsViewList(device)._like_in_post_view(LikeMode.SINGLE_CLICK) + detect_block(device) + random_sleep(1, 2) + if PostsViewList(device)._post_owner(Owner.OPEN): + if not interact(): + break + device.back() + + PostsViewList(device).swipe_to_fit_posts(SwipeTo.HALF_PHOTO) + random_sleep(0, 1) + PostsViewList(device).swipe_to_fit_posts(SwipeTo.NEXT_POST) + random_sleep() + continue diff --git a/GramAddict/plugins/interact_usernames.py b/GramAddict/plugins/interact_usernames.py new file mode 100644 index 00000000..3ba8c2c7 --- /dev/null +++ b/GramAddict/plugins/interact_usernames.py @@ -0,0 +1,221 @@ +from GramAddict.core.filter import Filter +import logging +from functools import partial +from colorama import Style +from os import path +from random import shuffle +from GramAddict.core.decorators import run_safely +from GramAddict.core.plugin_loader import Plugin +from GramAddict.core.storage import FollowingStatus +from GramAddict.core.views import TabBarView +from GramAddict.core.utils import ( + get_value, + random_sleep, +) +from GramAddict.core.interaction import ( + _on_interaction, + _on_like, + _on_watch, + interact_with_user, + is_follow_limit_reached_for_source, +) + +logger = logging.getLogger(__name__) + + +class InteractUsernames(Plugin): + """Interact with users that are given from a file""" + + def __init__(self): + super().__init__() + self.description = "Interact with users that are given from a file" + self.arguments = [ + { + "arg": "--interact-from-file", + "nargs": "+", + "help": "filenames of the list of users [*.txt]", + "metavar": ("filename1", "filename2"), + "default": None, + "operation": True, + } + ] + + def run(self, device, configs, storage, sessions, plugin): + class State: + def __init__(self): + pass + + is_job_completed = False + + self.args = configs.args + self.device_id = configs.args.device + self.sessions = sessions + self.session_state = sessions[-1] + profile_filter = Filter() + self.current_mode = plugin + + file_list = [file for file in (self.args.interact_from_file)] + shuffle(file_list) + + for file in file_list: + limit_reached = self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.LIKES + ) and self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.FOLLOWS + ) + + self.state = State() + logger.info(f"Handle {file}", extra={"color": f"{Style.BRIGHT}"}) + + on_interaction = partial( + _on_interaction, + likes_limit=int(self.args.total_likes_limit), + source=file, + interactions_limit=get_value( + self.args.interactions_count, "Interactions count: {}", 70 + ), + sessions=self.sessions, + session_state=self.session_state, + args=self.args, + ) + + on_like = partial( + _on_like, sessions=self.sessions, session_state=self.session_state + ) + on_watch = partial( + _on_watch, sessions=self.sessions, session_state=self.session_state + ) + + if self.args.stories_count != "0": + stories_percentage = get_value( + self.args.stories_percentage, "Chance of watching stories: {}%", 40 + ) + else: + stories_percentage = 0 + + @run_safely( + device=device, + device_id=self.device_id, + sessions=self.sessions, + session_state=self.session_state, + ) + def job(): + self.handle_username_file( + device, + file, + self.args.likes_count, + self.args.stories_count, + stories_percentage, + int(self.args.follow_percentage), + int(self.args.follow_limit) if self.args.follow_limit else None, + plugin, + storage, + profile_filter, + on_like, + on_watch, + on_interaction, + ) + self.state.is_job_completed = True + + while not self.state.is_job_completed and not limit_reached: + job() + + if limit_reached: + logger.info("Likes and follows limit reached.") + self.session_state.check_limit( + self.args, limit_type=self.session_state.Limit.ALL, output=True + ) + break + + def handle_username_file( + self, + device, + current_file, + likes_count, + stories_count, + stories_percentage, + follow_percentage, + follow_limit, + current_job, + storage, + profile_filter, + on_like, + on_watch, + on_interaction, + ): + interaction = partial( + interact_with_user, + my_username=self.session_state.my_username, + likes_count=likes_count, + stories_count=stories_count, + stories_percentage=stories_percentage, + follow_percentage=follow_percentage, + on_like=on_like, + on_watch=on_watch, + profile_filter=profile_filter, + args=self.args, + session_state=self.session_state, + current_mode=self.current_mode, + ) + is_follow_limit_reached = partial( + is_follow_limit_reached_for_source, + follow_limit=follow_limit, + source=current_file, + session_state=self.session_state, + ) + + if path.isfile(current_file): + with open(current_file, "r") as f: + for line in f: + username = line.strip() + if username != "": + if storage.is_user_in_blacklist(username): + logger.info(f"@{username} is in blacklist. Skip.") + continue + elif storage.check_user_was_interacted(username): + logger.info(f"@{username}: already interacted. Skip.") + continue + + search_view = TabBarView(device).navigateToSearch() + random_sleep() + profile_view = search_view.navigateToUsername(username) + if not profile_view: + continue + random_sleep() + + def interact(): + can_follow = not is_follow_limit_reached() and ( + storage.get_following_status(username) + == FollowingStatus.NONE + or storage.get_following_status(username) + == FollowingStatus.NOT_IN_LIST + ) + + interaction_succeed, followed = interaction( + device, username=username, can_follow=can_follow + ) + storage.add_interacted_user(username, followed=followed) + can_continue = on_interaction( + succeed=interaction_succeed, followed=followed + ) + if not can_continue: + return False + else: + return True + + logger.info(f"@{username}: interact") + if not interact(): + break + device.back() + else: + logger.info("Line in file is blank, skip.") + remaining = f.readlines() + if self.args.delete_interacted_users: + with open(current_file, "w") as f: + f.writelines(remaining) + else: + logger.warning(f"File {current_file} not found.") + return + + logger.info(f"Interact with users in {current_file} complete.") + device.back() diff --git a/GramAddict/plugins/like_from_urls.py b/GramAddict/plugins/like_from_urls.py index c96ea954..1dac6ef5 100644 --- a/GramAddict/plugins/like_from_urls.py +++ b/GramAddict/plugins/like_from_urls.py @@ -1,10 +1,15 @@ import logging -import os from functools import partial +from random import shuffle +from os import path from GramAddict.core.decorators import run_safely from GramAddict.core.interaction import _on_like, do_like from GramAddict.core.plugin_loader import Plugin -from GramAddict.core.utils import random_sleep, open_instagram_with_url, validate_url +from GramAddict.core.utils import ( + random_sleep, + open_instagram_with_url, + validate_url, +) logger = logging.getLogger(__name__) @@ -30,48 +35,69 @@ def __init__(self): } ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, config, storage, sessions, plugin): class State: def __init__(self): pass is_job_completed = False - self.device_id = device_id + self.args = config.args + self.device = device + self.device_id = config.args.device self.state = None self.sessions = sessions self.session_state = sessions[-1] + self.current_mode = plugin - self.urls = [] - if os.path.isfile(args.urls_file): - with open(args.urls_file, "r") as f: - self.urls = f.readlines() + file_list = [file for file in (self.args.interact_from_file)] + shuffle(file_list) - self.state = State() on_like = partial( _on_like, sessions=self.sessions, session_state=self.session_state ) + for filename in file_list: + self.state = State() - @run_safely( - device=device, - device_id=self.device_id, - sessions=self.sessions, - session_state=self.session_state, - ) - def job(): - for url in self.urls: - url = url.strip().replace("\n", "") - if validate_url(url) and "instagram.com/p/" in url: - if open_instagram_with_url(self.device_id, url) is True: - opened_post_view = OpenedPostView(device) - like_succeed = do_like(opened_post_view, device, on_like) - logger.info( - "Like for: {}, status: {}".format(url, like_succeed) - ) + @run_safely( + device=self.device, + device_id=self.device_id, + sessions=self.sessions, + session_state=self.session_state, + ) + def job(): + self.process_file(filename, on_like, storage) - if like_succeed: - logger.info("Back to profile") - device.back() - random_sleep() + job() - job() + def process_file(self, current_file, on_like, storage): + # TODO: We need to add interactions properly, honor session/source limits, honor filter, + # etc. Not going to try to do this now, but adding a note to do it later + if path.isfile(current_file): + with open(current_file, "r") as f: + for line in f: + url = line.strip() + if validate_url(url) and "instagram.com/p/" in url: + if open_instagram_with_url(url) is True: + opened_post_view = OpenedPostView(self.device) + username = opened_post_view._getUserName + like_succeed = do_like( + opened_post_view, self.device, on_like + ) + logger.info( + "Like for: {}, status: {}".format(url, like_succeed) + ) + if like_succeed: + logger.info("Back to profile") + storage.add_interacted_user(username) + self.device.back() + random_sleep() + else: + logger.info("Line in file is blank, skip.") + remaining = f.readlines() + if self.args.delete_interacted_users: + with open(current_file, "w") as f: + f.writelines(remaining) + else: + logger.warning(f"File {current_file} not found.") + return diff --git a/GramAddict/plugins/plugin.example b/GramAddict/plugins/plugin.example index e597d4b7..e3c7f89f 100644 --- a/GramAddict/plugins/plugin.example +++ b/GramAddict/plugins/plugin.example @@ -30,7 +30,7 @@ class ExamplePlugin(Plugin): }, ] - def run(self, device, device_id, args, enabled, storage, sessions, plugin): + def run(self, device, config, storage, sessions, plugin): # Your code here. All variables above must be in function definition, but # do not have to be used. If not needed, just ignore it. If you need anything # else from the main script - please include it in __init__.py and update diff --git a/GramAddict/version.py b/GramAddict/version.py index a82b376d..c68196d1 100644 --- a/GramAddict/version.py +++ b/GramAddict/version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.2.0" diff --git a/README.md b/README.md index d72aaf12..e2bacfb3 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,87 @@ -# GramAddict -![Python](https://img.shields.io/badge/built%20with-Python3-red.svg) -![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat) +

+ +
+

GramAddict

+
+

The best 100% free forever instagram bot. Grow your following and engagement by liking and following automatically with your Android phone/tablet/emulator. No root required.

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +

+

-> The best 100% free forever instagram bot. +
-

- -

+

+ +

+
-

-## Looking for more information about GramAddict? Check out: [https://docs.gramaddict.org](https://docs.gramaddict.org) -

+## Full documentation available on [docs.gramaddict.org](https://docs.gramaddict.org) +**Table of contents** +- [Introduction](https://docs.gramaddict.org/#/?id=introduction) +- [Quick Start](https://docs.gramaddict.org/#/quickstart) + * [Requirements](https://docs.gramaddict.org/#/quickstart?id=requirements) + * [How to Install](https://docs.gramaddict.org/#/quickstart?id=how-to-install) + * [Raspberry Pi](https://docs.gramaddict.org/#/quickstart?id=how-to-install-on-raspberry-pi-os) + * [Running GramAddict](https://docs.gramaddict.org/#/quickstart?id=running-gramaddict) +- [Configuration](https://docs.gramaddict.org/#/configuration) +- [Contributing](https://docs.gramaddict.org/#/contributing) +- [Community](https://docs.gramaddict.org/#/community) +- [FAQ](https://docs.gramaddict.org/#/faq) -## Introduction +
-Liking and following automatically on your Android phone/tablet. No root required: it works on [uiautomator2](https://github.com/openatx/uiautomator2), which is a faster and more efficient fork of the official Android UI testing framework [UI Automator](https://developer.android.com/training/testing/ui-automator). +## Contributors -This is a completely free and open source project that is forked from the freemium project [Insomniac](https://github.com/alexal1/Insomniac/) when they decided to do a controversial monetization strategy. Since then we've significantly improved their codebase in many ways. We've also been adding countless new features and other improvements. +This project exists thanks to all of our Contibutors [[Contribute](https://docs.gramaddict.org/#/contributing)]. -Like what you see? Help us by [Contributing](/?id=contributing)! + -## Why GramAddict? -There already is [InstaPy](https://github.com/timgrossmann/InstaPy), which works on Instagram web version. Unfortunately, Instagram bots detection system has become very suspicious to browser actions. Now InstaPy and similar scripts work at most an hour, then Instagram blocks possibility to do any actions, and if you continue using InstaPy, it may ban your account. +
-There is also [Insomniac](https://github.com/alexal1/Insomniac/) which is the origin of this project, but there were issues that cropped up when the project organizers decided to monetize it. We wanted to keep this project completely free and open source so we forked it! Now this project is the better option. 😇 +## Backers -Our objective is to make a free solution for mobile devices. Instagram can't distinguish bot from a human when it comes to your phone. However, even a human can reach limits when using the app, so make sure you are careful with your limits. Always set `--total-likes-limit` to 300 or less. Also it's better to use `--repeat` to act periodically for 2-3 hours, because Instagram keeps track of how long the app works. +Thank you to everyone that supports us financially! 🙏 [[Become a backer](https://opencollective.com/gramaddict#backer)] -## Want to talk about the bot? + + +
+ +## Talk botty with us

Join us on Discord -

\ No newline at end of file +

+ +--- + +> **Disclaimer**: This project comes with no gurantee or warranty. You are responsible for whatever happens from using this project. It is possible to get soft or hard banned by using this project if you are not careful. diff --git a/config-examples/all-parameters.yml b/config-examples/all-parameters.yml new file mode 100644 index 00000000..f18580c2 --- /dev/null +++ b/config-examples/all-parameters.yml @@ -0,0 +1,70 @@ +--- +############################################################################## +# For more information on parameters, refer to: +# https://docs.gramaddict.org/#/configuration?id=configuration-file +# +# Note: be sure to comment out any parameters not used by adding a # in front +############################################################################## +# General Configuration +############################################################################## + +username: myusername +device: abcdefg123456 +app-id: com.instagram.android +screen-sleep: true +uia-version: 2 +speed-multiplier: 1 +debug: false + +############################################################################## +# Actions +############################################################################## + +## Interaction +blogger-followers: [ username1, username2 ] +hashtag-likers-top: [ hashtag1, hashtag2 ] +hashtag-likers-recent: [ hashtag1, hashtag2 ] +hashtag-posts-top: [ hashtag1, hashtag2 ] +hashtag-posts-recent: [ hashtag1, hashtag2 ] +interact-from-file: usernames.txt +posts-from-file: posts.txt + +## Unfollow +unfollow: 10-20 +unfollow-any: 10-20 +unfollow-non-followers: 10-20 +unfollow-any-non-followers: 10-20 + +## Post Processing +analytics: myusername + +############################################################################## +# Source Limits +############################################################################## + +likes-count: 1-2 +stories-count: 1-2 +stories-percentage: 30-40 +interactions-count: 20-30 +follow-percentage: 30 +follow-limit: 50 +skipped-list-limit: 10-15 +fling-when-skipped: 0 +interact-percentage: 50 +min-following: 100 + +############################################################################## +# Total Limits +############################################################################## + +total-likes-limit: 300 +total-follows-limit: 50 +total-watches-limit: 500 +total-successful-interactions-limit: 100 +total-interactions-limit: 500 + +############################################################################## +# Scheduling +############################################################################## + +repeat: 280-320 diff --git a/blacklist.example b/config-examples/blacklist.txt similarity index 100% rename from blacklist.example rename to config-examples/blacklist.txt diff --git a/filter.example b/config-examples/filter.json similarity index 87% rename from filter.example rename to config-examples/filter.json index 4141d914..87c88e38 100644 --- a/filter.example +++ b/config-examples/filter.json @@ -1,6 +1,8 @@ { "skip_business": true, "skip_non_business": false, + "skip_following": false, + "skip_follower": false, "min_followers": 100, "max_followers": 5000, "min_followings": 10, diff --git a/whitelist.example b/config-examples/whitelist.txt similarity index 100% rename from whitelist.example rename to config-examples/whitelist.txt diff --git a/requirements.txt b/requirements.txt index bad49d1b..5ecbd795 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -colorama==0.3.7 +colorama==0.4.3 +ConfigArgParse==1.2.3 matplotlib==3.3.3 -uiautomator2==2.11.2 numpy==1.19.3 +PyYAML==5.3.1 +uiautomator==1.0.2 +uiautomator2==2.11.2 +urllib3==1.26.2 \ No newline at end of file diff --git a/res/demo.gif b/res/demo.gif index 7872249a..d801bb00 100644 Binary files a/res/demo.gif and b/res/demo.gif differ diff --git a/res/logo.png b/res/logo.png new file mode 100644 index 00000000..67d3c686 Binary files /dev/null and b/res/logo.png differ diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..3613b938 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +import setuptools + +with open("README.md", "r", errors="ignore") as readme: + long_description = readme.read() + +setuptools.setup( + name="gramaddict", + version="1.2.0b2", + author="GramAddict Team", + author_email="maintainers@gramaddict.org", + description="Completely free and open source human-like Instagram bot. Powered by UIAutomator2 and compatible with basically any android device that can run instagram - real or emulated.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/GramAddict/bot/", + packages=setuptools.find_packages(), + include_package_data=True, + install_requires=[ + "colorama==0.4.3", + "ConfigArgParse==1.2.3", + "matplotlib==3.3.3", + "numpy==1.19.3", + "PyYAML==5.3.1", + "uiautomator==1.0.2", + "uiautomator2==2.11.2", + "urllib3==1.26.2", + ], + classifiers=[ + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Operating System :: OS Independent", + ], + python_requires=">=3.6", +)