From aeb94666e4b12434abe826466a3fc60e009639be Mon Sep 17 00:00:00 2001
From: Philip Ulrich <11166773+philip-ulrich@users.noreply.github.com>
Date: Mon, 21 Dec 2020 20:35:49 -0600
Subject: [PATCH] v1.2.0 (#137)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Release 1.2.0
### Bug Fixes
```
🐛 add new resource IDs to support more instagram versions
🐛 add short sleep before checking if the screen is unlocked to reduce issues
🐛 modify story watching to reduce crashes
🐛 substitute the .down with .find that caused a crash
🐛 fix analytics report when there is broken data
🐛 fix errors related to resourceid refactor
🐛 fix unfollow after unfollowing a follower
🐛 fix error on hashtag in biography
🐛 fix an infinite loop issue when running unfollow plugin
🐛 fix follow button class
🐛 fix follow not count when profile private
🐛 fix issue with sort followers and overlaying element
```
### New Features
```
🎁 add support for config files
🎁 add support for cloned apps
🎁 add feature - hashtag-posts-recent
🎁 add feature - hashtag-posts-top
🎁 add feature - interact-from-file
🎁 add feature - unfollow-any-non-followers
🎁 add feature - debug flag for debug output to console
🎁 add feature - speed modifier
🎁 add feature - add option to pause when exiting with ctrl-c
🎁 add feature - exit source after scrolling a configurable number of times
🎁 add filter - following
🎁 add filter - follower
🎁 add back optional fling support
🎁 add support for uia1 (kinda)
```
### Improvements
```
🐎 increase speed of things that don't need to be slow
🐎 sped up sleep after unfollow
🐈 kill uia2 agent while closing instagram app
🐈 ensure we are on profile at start of each job
🐈 improve swipe on hashtag-likers to support more instagram versions
🐈 randomize swipe points for better human-like emulation
🐈 refactor resource ids
🐈 include time in analytics report filename so you can generate multiple a day
🐈 change distro method to pypi
🐈 improve the unfollow message (only show if unfollowed)
🐈 remove other unnecessary `while True` loops
🐈 add more debug output to help with fixing issues
📝 fix some issues with argument help not matching
📝 add new logo
📝 add new demo
📝 update formatting and info
```
Co-authored-by: Philip Ulrich <11166773+philip-ulrich@users.noreply.github.com>
Co-authored-by: Dennis <52335835+mastrolube@users.noreply.github.com>
Co-authored-by: narkopolo
Co-authored-by: Arthur Silva
---
.github/ISSUE_TEMPLATE/feature_request.md | 2 +-
.gitignore | 9 +-
DEPLOYMENT.MD | 7 +
GramAddict/__init__.py | 197 ++--
GramAddict/core/__init__.py | 0
GramAddict/core/config.py | 142 +++
GramAddict/core/decorators.py | 54 +-
GramAddict/core/device_facade.py | 716 +++++++++++----
GramAddict/core/filter.py | 60 +-
GramAddict/core/interaction.py | 62 +-
GramAddict/core/log.py | 14 +-
GramAddict/core/plugin_loader.py | 9 +-
GramAddict/core/resources.py | 126 +++
GramAddict/core/scroll_end_detector.py | 31 +-
GramAddict/core/session_state.py | 38 +-
GramAddict/core/utils.py | 61 +-
GramAddict/core/views.py | 851 ++++++++++++------
GramAddict/plugins/__init__.py | 0
.../plugins/action_unfollow_followers.py | 175 ++--
GramAddict/plugins/cloned_app.py | 20 +
GramAddict/plugins/core_arguments.py | 53 +-
GramAddict/plugins/data_analytics.py | 21 +-
GramAddict/plugins/force_interact.dis | 58 +-
.../plugins/interact_blogger_followers.py | 100 +-
GramAddict/plugins/interact_hashtag_likers.py | 174 ++--
GramAddict/plugins/interact_hashtag_posts.py | 291 ++++++
GramAddict/plugins/interact_usernames.py | 221 +++++
GramAddict/plugins/like_from_urls.py | 86 +-
GramAddict/plugins/plugin.example | 2 +-
GramAddict/version.py | 2 +-
README.md | 90 +-
config-examples/all-parameters.yml | 70 ++
.../blacklist.txt | 0
filter.example => config-examples/filter.json | 2 +
.../whitelist.txt | 0
requirements.txt | 8 +-
res/demo.gif | Bin 1960859 -> 3860582 bytes
res/logo.png | Bin 0 -> 417584 bytes
setup.py | 35 +
39 files changed, 2897 insertions(+), 890 deletions(-)
create mode 100644 DEPLOYMENT.MD
create mode 100644 GramAddict/core/__init__.py
create mode 100644 GramAddict/core/config.py
create mode 100644 GramAddict/core/resources.py
create mode 100644 GramAddict/plugins/__init__.py
create mode 100644 GramAddict/plugins/cloned_app.py
create mode 100644 GramAddict/plugins/interact_hashtag_posts.py
create mode 100644 GramAddict/plugins/interact_usernames.py
create mode 100644 config-examples/all-parameters.yml
rename blacklist.example => config-examples/blacklist.txt (100%)
rename filter.example => config-examples/filter.json (87%)
rename whitelist.example => config-examples/whitelist.txt (100%)
create mode 100644 res/logo.png
create mode 100644 setup.py
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
-
\ 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 7872249aeca96ce2a96f98d5c823ac598b2af436..d801bb001733b5078272af1ff2477994370c2f64 100644
GIT binary patch
literal 3860582
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Ξ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