diff --git a/.dockerignore b/.dockerignore index fb2bb68..c56db2a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,4 @@ config/config.ini config/debug.log config/sqlite.db config/info.log -alembic.ini README.ini \ No newline at end of file diff --git a/.gitignore b/.gitignore index e292dc2..8304bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ env/ venv/ -*.ini __pycache__/ .idea config/config.ini config/*.log* -config/sqlite.db +config/sqlite* .DS_STORE \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..06ec989 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = src/alembic + +# template used to generate migration files +file_template = %%(year)d_%%(month).2d_%%(day).2d-%%(hour).2d_%%(minute).2d_%%(second).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions:src/alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///config/sqlite.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/config/config.ini.example b/config/config.ini.example index 33efbae..437a2bb 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -2,16 +2,20 @@ cookie = PHPSESSIONCOOKIEGOESHERE # should we consider giveaways on the 'ALL' page? it is the main page enabled = true + +[ALL] # minimum number of points in your account before entering into giveaways -minimum_points = 50 +all.minimum_points = 50 # max number of entries in a giveaway for it to be considered -max_entries = 2000 +all.max_entries = 2000 # time left in minutes of a giveaway for it to be considered -max_time_left = 300 +all.max_time_left = 300 +# points after that giveaway is considered regardless of entries and time left +all.max_points = 350 # the minimum point value for a giveaway to be considered -minimum_game_points = 1 +all.minimum_game_points = 1 # a comma separated list of keywords in game titles to ignore -blacklist_keywords = hentai,adult +all.blacklist_keywords = hentai,adult [WISHLIST] # should we consider giveaways on the 'Wishlist' page? @@ -22,6 +26,20 @@ wishlist.minimum_points = 1 wishlist.max_entries = 10000 # time left in minutes of a giveaway for it to be considered wishlist.max_time_left = 300 +# points after that giveaway is considered regardless of entries and time left +wishlist.max_points = 350 + +[DLC] +# should we consider giveaways on the 'DLC' page? +dlc.enabled = true +# minimum number of points in your account before entering into giveaways +dlc.minimum_points = 1 +# max number of entries in a giveaway for it to be considered +dlc.max_entries = 10000 +# time left in minutes of a giveaway for it to be considered +dlc.max_time_left = 300 +# points after that giveaway is considered regardless of entries and time left +dlc.max_points = 350 [NOTIFICATIONS] # a prefix for messages sent via notifications diff --git a/main.py b/main.py index 3131670..d19fa56 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from src.bot import run +from src import run if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index 47e10c5..bb009d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,11 @@ beautifulsoup4==4.11.1 urllib3==1.26.9 sqlalchemy==1.4.36 sqlalchemy_utils==0.38.2 +paginate-sqlalchemy==0.3.1 +alembic==1.7.7 python-dateutil==2.8.2 Flask==2.1.2 Flask-BasicAuth==0.2.0 pyopenssl==22.0.0 -alembic==1.7.7 \ No newline at end of file +apscheduler==3.9.1 +werkzeug==2.2.2 diff --git a/src/alembic/versions/2022_05_21-10_25_41-15c028536ef5_won_column_removed.py b/src/alembic/versions/2022_05_21-10_25_41-15c028536ef5_won_column_removed.py new file mode 100644 index 0000000..7abd9af --- /dev/null +++ b/src/alembic/versions/2022_05_21-10_25_41-15c028536ef5_won_column_removed.py @@ -0,0 +1,31 @@ +"""won column removed + +Revision ID: 15c028536ef5 +Revises: 1da33402b659 +Create Date: 2022-05-21 10:25:41.647723 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '15c028536ef5' +down_revision = '1da33402b659' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column('giveaway', 'won') + + +def downgrade(): + # add columns as nullable as existing records don't have that column value set + op.add_column('giveaway', sa.Column('won', sa.Boolean(), nullable=True)) + # set value on new columns for all records + with op.get_context().autocommit_block(): + op.execute("UPDATE giveaway SET won=false WHERE won is null;") + # SQLite doesn't support ALTERs so alembic uses a batch mode + # Set columns to non-nullable now that all records have a value + with op.batch_alter_table('giveaway') as batch_op: + batch_op.alter_column('won', nullable=False) diff --git a/src/alembic/versions/2022_05_24-08_31_25-ff0a728ba3da_add_games_won_column.py b/src/alembic/versions/2022_05_24-08_31_25-ff0a728ba3da_add_games_won_column.py new file mode 100644 index 0000000..e6c0c79 --- /dev/null +++ b/src/alembic/versions/2022_05_24-08_31_25-ff0a728ba3da_add_games_won_column.py @@ -0,0 +1,27 @@ +"""add games_won column + +Revision ID: ff0a728ba3da +Revises: 15c028536ef5 +Create Date: 2022-05-24 08:31:25.684099 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ff0a728ba3da' +down_revision = '15c028536ef5' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('notification', sa.Column('games_won', sa.Integer(), nullable=True)) + # set a default for previous won notifications + with op.get_context().autocommit_block(): + op.execute("UPDATE notification SET games_won=1 WHERE type='won';") + + +def downgrade(): + op.drop_column('notification', 'games_won') diff --git a/src/alembic/versions/2022_06_01-09_20_36-8c1784114d65_won_column_added_back.py b/src/alembic/versions/2022_06_01-09_20_36-8c1784114d65_won_column_added_back.py new file mode 100644 index 0000000..7694dfb --- /dev/null +++ b/src/alembic/versions/2022_06_01-09_20_36-8c1784114d65_won_column_added_back.py @@ -0,0 +1,31 @@ +"""won column added backed + +Revision ID: 8c1784114d65 +Revises: ff0a728ba3da +Create Date: 2022-06-01 09:20:36.762279 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '8c1784114d65' +down_revision = 'ff0a728ba3da' +branch_labels = None +depends_on = None + + +def upgrade(): + # add columns as nullable as existing records don't have that column value set + op.add_column('giveaway', sa.Column('won', sa.Boolean(), nullable=True)) + # set value on new columns for all records + with op.get_context().autocommit_block(): + op.execute("UPDATE giveaway SET won=false WHERE won is null;") + # SQLite doesn't support ALTERs so alembic uses a batch mode + # Set columns to non-nullable now that all records have a value + with op.batch_alter_table('giveaway') as batch_op: + batch_op.alter_column('won', nullable=False) + + +def downgrade(): + op.drop_column('giveaway', 'won') diff --git a/src/bot/config_reader.py b/src/bot/config_reader.py index 5450023..ec62352 100644 --- a/src/bot/config_reader.py +++ b/src/bot/config_reader.py @@ -29,17 +29,28 @@ def choose_user_agent(): class ConfigReader(ConfigParser): required_values = { 'DEFAULT': { - 'enabled': ('true', 'false'), - 'minimum_points': '%s' % (value_range(0, 400)), - 'max_entries': '%s' % (value_range(0, 100000)), - 'max_time_left': '%s' % (value_range(0, 21600)), - 'minimum_game_points': '%s' % (value_range(0, 50)) + }, + 'ALL': { + 'all.enabled': ('true', 'false'), + 'all.minimum_points': '%s' % (value_range(0, 400)), + 'all.max_entries': '%s' % (value_range(0, 100000)), + 'all.max_time_left': '%s' % (value_range(0, 21600)), + 'all.max_points': '%s' % (value_range(0, 400)), + 'all.minimum_game_points': '%s' % (value_range(0, 50)) }, 'WISHLIST': { 'wishlist.enabled': ('true', 'false'), 'wishlist.minimum_points': '%s' % (value_range(0, 400)), 'wishlist.max_entries': '%s' % (value_range(0, 100000)), - 'wishlist.max_time_left': '%s' % (value_range(0, 21600)) + 'wishlist.max_time_left': '%s' % (value_range(0, 21600)), + 'wishlist.max_points': '%s' % (value_range(0, 400)), + }, + 'DLC': { + 'dlc.enabled': ('true', 'false'), + 'dlc.minimum_points': '%s' % (value_range(0, 400)), + 'dlc.max_entries': '%s' % (value_range(0, 100000)), + 'dlc.max_time_left': '%s' % (value_range(0, 21600)), + 'dlc.max_points': '%s' % (value_range(0, 400)), }, 'NOTIFICATIONS': { 'pushover.enabled': ('true', 'false'), @@ -52,19 +63,30 @@ class ConfigReader(ConfigParser): default_values = { 'DEFAULT': { 'cookie': '', - 'user_agent': f"{choose_user_agent()}", - 'enabled': 'true', - 'minimum_points': f"{randint(20, 50)}", - 'max_entries': f"{randint(1000, 2500)}", - 'max_time_left': f"{randint(180, 500)}", - 'minimum_game_points': "0", - 'blacklist_keywords': 'hentai,adult' + 'user_agent': f"{choose_user_agent()}" + }, + 'ALL': { + 'all.enabled': 'true', + 'all.minimum_points': f"{randint(20, 50)}", + 'all.max_entries': f"{randint(1000, 2500)}", + 'all.max_time_left': f"{randint(180, 500)}", + 'all.max_points': f"{randint(300, 400)}", + 'all.minimum_game_points': "0", + 'all.blacklist_keywords': 'hentai,adult' }, 'WISHLIST': { 'wishlist.enabled': 'true', 'wishlist.minimum_points': '1', 'wishlist.max_entries': f"{randint(10000, 100000)}", - 'wishlist.max_time_left': f"{randint(180, 500)}" + 'wishlist.max_time_left': f"{randint(180, 500)}", + 'wishlist.max_points': f"{randint(300, 400)}", + }, + 'DLC': { + 'dlc.enabled': 'true', + 'dlc.minimum_points': '1', + 'dlc.max_entries': f"{randint(10000, 100000)}", + 'dlc.max_time_left': f"{randint(180, 500)}", + 'dlc.max_points': f"{randint(300, 400)}", }, 'NOTIFICATIONS': { 'notification.prefix': '', diff --git a/src/bot/database.py b/src/bot/database.py index 849fcad..99cbfb0 100644 --- a/src/bot/database.py +++ b/src/bot/database.py @@ -1,12 +1,13 @@ import os from datetime import datetime, timedelta +import paginate_sqlalchemy import sqlalchemy from alembic import command from alembic.config import Config from dateutil import tz -from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload from sqlalchemy_utils import database_exists from .log import get_logger @@ -17,23 +18,18 @@ engine = sqlalchemy.create_engine(f"{os.getenv('BOT_DB_URL', 'sqlite:///./config engine.connect() -def create_engine(db_url: str): - return engine - - -def run_migrations(script_location: str, db_url: str) -> None: +def run_db_migrations(script_location: str, db_url: str) -> None: logger.debug('Running DB migrations in %r on %r', script_location, db_url) alembic_cfg = Config() alembic_cfg.set_main_option('script_location', script_location) alembic_cfg.set_main_option('sqlalchemy.url', db_url) if not database_exists(db_url): - logger.debug(f"'{db_url}' does not exist. Running normal migration to create db and tables." ) + logger.debug(f"'{db_url}' does not exist. Running normal migration to create db and tables.") command.upgrade(alembic_cfg, 'head') - if database_exists(db_url): - logger.debug(f"'{db_url} exists.") - e = create_engine(db_url) - insp = sqlalchemy.inspect(e) + elif database_exists(db_url): + logger.debug(f"'{db_url}' exists.") + insp = sqlalchemy.inspect(engine) alembic_version_table_name = 'alembic_version' has_alembic_table = insp.has_table(alembic_version_table_name) if has_alembic_table: @@ -50,9 +46,15 @@ def run_migrations(script_location: str, db_url: str) -> None: class NotificationHelper: @classmethod - def insert(cls, type_of_error, message, medium, success): + def get(cls): with Session(engine) as session: - n = TableNotification(type=type_of_error, message=message, medium=medium, success=success) + return session.query(TableNotification).order_by(TableNotification.created_at.desc()).all() + + @classmethod + def insert(cls, type_of_error, message, medium, success, number_won): + with Session(engine) as session: + n = TableNotification(type=type_of_error, message=message, medium=medium, success=success, + games_won=number_won) session.add(n) session.commit() @@ -64,7 +66,7 @@ class NotificationHelper: within_3_days = session.query(TableNotification) \ .filter(func.DATE(TableNotification.created_at) >= (datetime.utcnow().date() - timedelta(days=1))) \ .filter(func.DATE(TableNotification.created_at) <= (datetime.utcnow().date() + timedelta(days=1))) \ - .filter_by(type='won').all() + .filter_by(type='won').order_by(TableNotification.created_at.asc()).all() actual = [] for r in within_3_days: if r.created_at.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).date() == datetime.now( @@ -89,6 +91,41 @@ class NotificationHelper: class GiveawayHelper: + @classmethod + def get(cls): + with Session(engine) as session: + return session.query(TableGiveaway).options(joinedload('steam_item')) \ + .order_by(TableGiveaway.giveaway_ended_at.desc()).all() + + @classmethod + def paginate(cls, page=1): + with Session(engine) as session: + paginated_giveaways = paginate_sqlalchemy.SqlalchemyOrmPage(session.query(TableGiveaway) + .options(joinedload('steam_item')) + .order_by( + TableGiveaway.giveaway_ended_at.desc()), page=page) + return paginated_giveaways + + @classmethod + def total_giveaways(cls): + with Session(engine) as session: + return session.execute(select(func.count(TableGiveaway.giveaway_id))).scalar_one() + + @classmethod + def total_entered(cls): + with Session(engine) as session: + return session.query(TableGiveaway).filter_by(entered=True).count() + + @classmethod + def total_won(cls): + with Session(engine) as session: + return session.query(TableGiveaway).filter_by(won=True).count() + + @classmethod + def get_by_giveaway_id(cls, game_id): + with Session(engine) as session: + return session.query(TableGiveaway).filter_by(giveaway_id=game_id).one_or_none() + @classmethod def unix_timestamp_to_utc_datetime(cls, timestamp): return datetime.utcfromtimestamp(timestamp) @@ -100,7 +137,18 @@ class GiveawayHelper: steam_id=giveaway.steam_app_id).all() @classmethod - def insert(cls, giveaway, entered): + def mark_game_as_won(cls, game_id): + with Session(engine) as session: + result = session.query(TableGiveaway).filter_by(giveaway_id=game_id).all() + if result: + won_giveaway = result[0] + if not won_giveaway.won: + won_giveaway.won = True + session.add(won_giveaway) + session.commit() + + @classmethod + def insert(cls, giveaway, entered, won): with Session(engine) as session: result = session.query(TableSteamItem).filter_by(steam_id=giveaway.steam_app_id).all() if result: @@ -124,16 +172,16 @@ class GiveawayHelper: copies=giveaway.copies, contributor_level=giveaway.contributor_level, entered=entered, - won=False, + won=won, game_entries=giveaway.game_entries) session.add(g) session.commit() @classmethod - def upsert_giveaway(cls, giveaway, entered): + def upsert_giveaway_with_details(cls, giveaway, entered, won): result = GiveawayHelper.get_by_ids(giveaway) if not result: - GiveawayHelper.insert(giveaway, entered) + GiveawayHelper.insert(giveaway, entered, won) else: with Session(engine) as session: g = TableGiveaway( @@ -147,7 +195,28 @@ class GiveawayHelper: copies=giveaway.copies, contributor_level=giveaway.contributor_level, entered=entered, - won=False, + won=won, game_entries=giveaway.game_entries) session.merge(g) - session.commit() \ No newline at end of file + session.commit() + + @classmethod + def upsert_giveaway(cls, giveaway): + result = GiveawayHelper.get_by_ids(giveaway) + if not result: + GiveawayHelper.insert(giveaway, False, False) + else: + with Session(engine) as session: + g = TableGiveaway( + giveaway_id=giveaway.giveaway_game_id, + steam_id=result[0].steam_id, + giveaway_uri=giveaway.giveaway_uri, + user=giveaway.user, + giveaway_created_at=GiveawayHelper.unix_timestamp_to_utc_datetime(giveaway.time_created_timestamp), + giveaway_ended_at=GiveawayHelper.unix_timestamp_to_utc_datetime(giveaway.time_remaining_timestamp), + cost=giveaway.cost, + copies=giveaway.copies, + contributor_level=giveaway.contributor_level, + game_entries=giveaway.game_entries) + session.merge(g) + session.commit() diff --git a/src/bot/enter_giveaways.py b/src/bot/enter_giveaways.py index bcd25c8..7865000 100644 --- a/src/bot/enter_giveaways.py +++ b/src/bot/enter_giveaways.py @@ -9,7 +9,7 @@ from urllib3.util import Retry from .log import get_logger from .database import NotificationHelper, GiveawayHelper -from .giveaway import Giveaway +from .giveaway_entry import GiveawayEntry logger = get_logger(__name__) @@ -19,28 +19,30 @@ class SteamGiftsException(Exception): class EnterGiveaways: + def __init__(self, cookie, user_agent, gifts_type, pinned, min_points, max_entries, - max_time_left, minimum_game_points, blacklist, notification): - self.contributor_level = None - self.xsrf_token = None - self.points = None - self.cookie = { + max_time_left, max_points, minimum_game_points, blacklist, notification): + self._contributor_level = None + self._xsrf_token = None + self._points = None + self._cookie = { 'PHPSESSID': cookie } - self.user_agent = user_agent - self.gifts_type = gifts_type - self.pinned = pinned - self.min_points = int(min_points) - self.max_entries = int(max_entries) - self.max_time_left = int(max_time_left) - self.minimum_game_points = int(minimum_game_points) - self.blacklist = blacklist.split(',') - self.notification = notification + self._user_agent = user_agent + self._gifts_type = gifts_type + self._pinned = pinned + self._min_points = int(min_points) + self._max_entries = int(max_entries) + self._max_time_left = int(max_time_left) + self._max_points = int(max_points) + self._minimum_game_points = int(minimum_game_points) + self._blacklist = blacklist.split(',') + self._notification = notification - self.base = "https://www.steamgifts.com" - self.session = requests.Session() + self._base = "https://www.steamgifts.com" + self._session = requests.Session() - self.filter_url = { + self._filter_url = { 'All': "search?page=%d", 'Wishlist': "search?page=%d&type=wishlist", 'Recommended': "search?page=%d&type=recommended", @@ -49,12 +51,23 @@ class EnterGiveaways: 'New': "search?page=%d&type=new" } - def requests_retry_session( + def start(self): + self._update_info() + if self._points >= self._min_points: + txt = f"〰 You have {self._points} points. Evaluating '{self._gifts_type}' giveaways..." + logger.info(txt) + self._evaluate_giveaways() + else: + txt = f"🟡 You have {self._points} points which is below your minimum point threshold of " \ + f"{self._min_points} points for '{self._gifts_type}' giveaways. Not evaluating right now." + logger.info(txt) + + def _requests_retry_session( self, retries=5, backoff_factor=0.3 ): - session = self.session or requests.Session() + session = self._session or requests.Session() retry = Retry( total=retries, read=retries, @@ -67,85 +80,93 @@ class EnterGiveaways: session.mount('https://', adapter) return session - def get_soup_from_page(self, url): + def _get_soup_from_page(self, url): headers = { - 'User-Agent': self.user_agent + 'User-Agent': self._user_agent } - self.requests_retry_session().get(url, headers=headers) - r = requests.get(url, cookies=self.cookie) + self._requests_retry_session().get(url, headers=headers) + r = requests.get(url, cookies=self._cookie) soup = BeautifulSoup(r.text, 'html.parser') return soup - def update_info(self): - soup = self.get_soup_from_page(self.base) + def _update_info(self): + soup = self._get_soup_from_page(self._base) try: - self.xsrf_token = soup.find('input', {'name': 'xsrf_token'})['value'] - self.points = int(soup.find('span', {'class': 'nav__points'}).text) # storage points - self.contributor_level = int(float(soup.select_one('nav a>span[title]')['title'])) + self._xsrf_token = soup.find('input', {'name': 'xsrf_token'})['value'] + self._points = int(soup.find('span', {'class': 'nav__points'}).text) # storage points + self._contributor_level = int(float(soup.select_one('nav a>span[title]')['title'])) except TypeError: logger.error("⛔⛔⛔ Cookie is not valid. A new one must be added.⛔⛔⛔") raise SteamGiftsException("Cookie is not valid. A new one must be added.") won = soup.select("a[title='Giveaways Won'] div") if won: - number_won = soup.select_one("a[title='Giveaways Won'] div").text + number_won = int(soup.select_one("a[title='Giveaways Won'] div").text) won_notifications = NotificationHelper.get_won_notifications_today() if won_notifications and len(won_notifications) >= 1: - logger.info("🆒️ Win(s) detected, but we have already notified that there are won games waiting " - "to be received. Doing nothing.") + if number_won == won_notifications[-1].games_won: + logger.info("🆒️ Win(s) detected, but we have already notified that there are won games waiting " + "to be received. Doing nothing.") + elif number_won > won_notifications[-1].games_won: + logger.info("🔥🔥 MORE win(s) detected. Notifying again.") + self._notification.send_won(f"You won ANOTHER game. You now have {number_won} game(s) " + f"waiting to be claimed.", number_won) + else: # we have less games waiting to be claimed than notified, meaning some have been claimed + logger.info("🆒️ Win(s) detected, but we have already notified that there are won games waiting " + "to be received. Some have been claimed. Doing nothing.") else: logger.info(f"💰💰 WINNER! You have {number_won} game(s) waiting to be claimed. 💰💰") - self.notification.send_won(f"WINNER! You have {number_won} game(s) waiting to be claimed.") + self._notification.send_won(f"WINNER! You have {number_won} game(s) waiting to be claimed.", number_won) else: logger.debug('No wins detected. Doing nothing.') - def should_we_enter_giveaway(self, giveaway): + def _should_we_enter_giveaway(self, giveaway): if giveaway.time_remaining_in_minutes is None: return False if giveaway.time_created_in_minutes is None: return False - if self.blacklist is not None and self.blacklist != ['']: - for keyword in self.blacklist: + if self._blacklist is not None and self._blacklist != ['']: + for keyword in self._blacklist: if giveaway.game_name.lower().find(keyword.lower()) != -1: txt = f"〰️ Game {giveaway.game_name} contains the blacklisted keyword {keyword}" logger.info(txt) return False - if giveaway.contributor_level is None or self.contributor_level < giveaway.contributor_level: + if giveaway.contributor_level is None or self._contributor_level < giveaway.contributor_level: txt = f"〰️ Game {giveaway.game_name} requires at least level {giveaway.contributor_level} contributor " \ - f"level to enter. Your level: {self.contributor_level}" + f"level to enter. Your level: {self._contributor_level}" logger.info(txt) return False - if self.points - int(giveaway.cost) < 0: + if giveaway.time_remaining_in_minutes > self._max_time_left and self._points < self._max_points: + txt = f"〰️ Game {giveaway.game_name} has {giveaway.time_remaining_in_minutes} minutes left and is " \ + f"above your cutoff of {self._max_time_left} minutes." + logger.info(txt) + return False + if giveaway.game_entries / giveaway.copies > self._max_entries and self._points < self._max_points: + txt = f"〰️ Game {giveaway.game_name} has {giveaway.game_entries} entries and is above your cutoff " \ + f"of {self._max_entries} entries." + logger.info(txt) + return False + if self._points - int(giveaway.cost) < 0: txt = f"〰️ Not enough points to enter: {giveaway.game_name}" logger.info(txt) return False - if giveaway.cost < self.minimum_game_points: + if giveaway.cost < self._minimum_game_points: txt = f"〰️ Game {giveaway.game_name} costs {giveaway.cost}P and is below your cutoff of " \ - f"{self.minimum_game_points}P." - logger.info(txt) - return False - if giveaway.time_remaining_in_minutes > self.max_time_left: - txt = f"〰️ Game {giveaway.game_name} has {giveaway.time_remaining_in_minutes} minutes left and is " \ - f"above your cutoff of {self.max_time_left} minutes." - logger.info(txt) - return False - if giveaway.game_entries / giveaway.copies > self.max_entries: - txt = f"〰️ Game {giveaway.game_name} has {giveaway.game_entries} entries and is above your cutoff " \ - f"of {self.max_entries} entries." + f"{self._minimum_game_points}P." logger.info(txt) return False return True - def enter_giveaway(self, giveaway): + def _enter_giveaway(self, giveaway): headers = { - 'User-Agent': self.user_agent + 'User-Agent': self._user_agent } - payload = {'xsrf_token': self.xsrf_token, 'do': 'entry_insert', 'code': giveaway.giveaway_game_id} + payload = {'xsrf_token': self._xsrf_token, 'do': 'entry_insert', 'code': giveaway.giveaway_game_id} logger.debug(f"Sending enter giveaway payload: {payload}") - entry = requests.post('https://www.steamgifts.com/ajax.php', data=payload, cookies=self.cookie, + entry = requests.post('https://www.steamgifts.com/ajax.php', data=payload, cookies=self._cookie, headers=headers) json_data = json.loads(entry.text) @@ -156,17 +177,17 @@ class EnterGiveaways: logger.error(f"❌ Failed entering giveaway {giveaway.giveaway_game_id}: {json_data}") return False - def evaluate_giveaways(self, page=1): + def _evaluate_giveaways(self, page=1): n = page run = True while run and n < 3: # hard stop safety net at page 3 as idk why we would ever get to this point txt = "〰️ Retrieving games from %d page." % n logger.info(txt) - filtered_url = self.filter_url[self.gifts_type] % n - paginated_url = f"{self.base}/giveaways/{filtered_url}" + filtered_url = self._filter_url[self._gifts_type] % n + paginated_url = f"{self._base}/giveaways/{filtered_url}" - soup = self.get_soup_from_page(paginated_url) + soup = self._get_soup_from_page(paginated_url) pinned_giveaway_count = len(soup.select('div.pinned-giveaways__outer-wrap div.giveaway__row-inner-wrap')) all_games_list_count = len(soup.select('div.giveaway__row-inner-wrap')) @@ -181,17 +202,17 @@ class EnterGiveaways: break for item in unentered_game_list: - giveaway = Giveaway(item) + giveaway = GiveawayEntry(item) txt = f"〰 {giveaway.game_name} - {giveaway.cost}P - {giveaway.game_entries} entries " \ f"(w/ {giveaway.copies} copies) - Created {giveaway.time_created_string} ago " \ f"with {giveaway.time_remaining_string} remaining by {giveaway.user}." logger.info(txt) - if giveaway.pinned and not self.pinned: + if giveaway.pinned and not self._pinned: logger.info(f"〰️ Giveaway {giveaway.game_name} is pinned. Ignoring.") continue - if self.points == 0 or self.points < self.min_points: - txt = f"🟡 We have {self.points} points, but we need {self.min_points} to start." + if self._points == 0 or self._points < self._min_points: + txt = f"🟡 We have {self._points} points, but we need {self._min_points} to start." logger.info(txt) run = False break @@ -199,37 +220,27 @@ class EnterGiveaways: if not giveaway.cost: logger.error(f"Cost could not be determined for '{giveaway.game_name}'") continue - if_enter_giveaway = self.should_we_enter_giveaway(giveaway) + if_enter_giveaway = self._should_we_enter_giveaway(giveaway) if if_enter_giveaway: - res = self.enter_giveaway(giveaway) + res = self._enter_giveaway(giveaway) if res: - GiveawayHelper.upsert_giveaway(giveaway, True) - self.points -= int(giveaway.cost) + GiveawayHelper.upsert_giveaway_with_details(giveaway, True, False) + self._points -= int(giveaway.cost) txt = f"✅ Entered giveaway '{giveaway.game_name}'" logger.info(txt) sleep(randint(4, 15)) else: - GiveawayHelper.upsert_giveaway(giveaway, False) + GiveawayHelper.upsert_giveaway_with_details(giveaway, False, False) else: - GiveawayHelper.upsert_giveaway(giveaway, False) + GiveawayHelper.upsert_giveaway(giveaway) # if we are on any filter type except New and we get to a giveaway that exceeds our # max time left amount, then we don't need to continue to look at giveaways as any # after this point will also exceed the max time left - if self.gifts_type != "New" and not giveaway.pinned and \ - giveaway.time_remaining_in_minutes > self.max_time_left: + if self._gifts_type != "New" and not giveaway.pinned and \ + giveaway.time_remaining_in_minutes > self._max_time_left and \ + self._points < self._max_points: logger.info("🟡 We have run out of gifts to consider.") run = False break n = n + 1 - - def start(self): - self.update_info() - if self.points >= self.min_points: - txt = f"〰 You have {self.points} points. Evaluating '{self.gifts_type}' giveaways..." - logger.info(txt) - self.evaluate_giveaways() - else: - txt = f"🟡 You have {self.points} points which is below your minimum point threshold of " \ - f"{self.min_points} points for '{self.gifts_type}' giveaways. Not evaluating right now." - logger.info(txt) diff --git a/src/bot/evaluate_won_giveaways.py b/src/bot/evaluate_won_giveaways.py new file mode 100644 index 0000000..e565213 --- /dev/null +++ b/src/bot/evaluate_won_giveaways.py @@ -0,0 +1,86 @@ +import requests +from bs4 import BeautifulSoup +from requests.adapters import HTTPAdapter +from urllib3.util import Retry + +from .database import GiveawayHelper +from .log import get_logger +from .won_entry import WonEntry + +logger = get_logger(__name__) + + +class SteamGiftsException(Exception): + pass + + +class EvaluateWonGiveaways: + + def __init__(self, cookie, user_agent, notification): + self._contributor_level = None + self._xsrf_token = None + self._cookie = { + 'PHPSESSID': cookie + } + self._user_agent = user_agent + self._notification = notification + + self._base = "https://www.steamgifts.com/giveaways/won" + self._session = requests.Session() + + def start(self): + self._evaluate_won_giveaways() + + def _requests_retry_session( + self, + retries=5, + backoff_factor=0.3 + ): + session = self._session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=(500, 502, 504), + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + def _get_soup_from_page(self, url): + headers = { + 'User-Agent': self._user_agent + } + self._requests_retry_session().get(url, headers=headers) + r = requests.get(url, cookies=self._cookie) + soup = BeautifulSoup(r.text, 'html.parser') + return soup + + def _evaluate_won_giveaways(self, page=1): + soup = self._get_soup_from_page(self._base) + + try: + self._xsrf_token = soup.find('input', {'name': 'xsrf_token'})['value'] + self._points = int(soup.find('span', {'class': 'nav__points'}).text) # storage points + self._contributor_level = int(float(soup.select_one('nav a>span[title]')['title'])) + except TypeError: + logger.error("⛔⛔⛔ Cookie is not valid. A new one must be added.⛔⛔⛔") + raise SteamGiftsException("Cookie is not valid. A new one must be added.") + + won_game_list = soup.select('div[class=table__row-inner-wrap]') + + if not len(won_game_list): + txt = f"🟡 No won games to evaluate" + logger.info(txt) + + for item in won_game_list: + won_giveaway = WonEntry(item) + w = GiveawayHelper.get_by_giveaway_id(won_giveaway.giveaway_game_id) + logger.debug(f"Giveaway in db: {w}") + if w and not w.won and w.entered: + logger.info(f"Marking {won_giveaway.game_name} as won.") + logger.debug(f"Marking: {w}") + GiveawayHelper.mark_game_as_won(won_giveaway.giveaway_game_id) + diff --git a/src/bot/giveaway.py b/src/bot/giveaway_entry.py similarity index 85% rename from src/bot/giveaway.py rename to src/bot/giveaway_entry.py index 64a9dbf..46d36e1 100644 --- a/src/bot/giveaway.py +++ b/src/bot/giveaway_entry.py @@ -5,7 +5,7 @@ import time logger = get_logger(__name__) -class Giveaway: +class GiveawayEntry: def __init__(self, soup_item): self.steam_app_id = None @@ -29,27 +29,27 @@ class Giveaway: logger.debug(f"Giveaway html: {soup_item}") icons = soup_item.select('a.giveaway__icon') self.steam_url = icons[0]['href'] - self.steam_app_id = self.get_steam_app_id(self.steam_url) + self.steam_app_id = self._get_steam_app_id(self.steam_url) self.game_name = soup_item.find('a', {'class': 'giveaway__heading__name'}).text self.giveaway_game_id = soup_item.find('a', {'class': 'giveaway__heading__name'})['href'].split('/')[2] self.giveaway_uri = soup_item.select_one('a.giveaway__heading__name')['href'] pin_class = soup_item.parent.parent.get("class") self.pinned = pin_class is not None and len(pin_class) > 0 and pin_class[0].find('pinned') != -1 - self.cost, self.copies = self.determine_cost_and_copies(soup_item, self.game_name, self.giveaway_game_id) + self.cost, self.copies = self._determine_cost_and_copies(soup_item, self.game_name, self.giveaway_game_id) self.game_entries = int(soup_item.select('div.giveaway__links span')[0].text.split(' ')[0].replace(',', '')) contributor_level = soup_item.select_one('div[title="Contributor Level"]') - self.contributor_level = self.determine_contributor_level(contributor_level) + self.contributor_level = self._determine_contributor_level(contributor_level) self.user = soup_item.select_one('a.giveaway__username').text times = soup_item.select('div span[data-timestamp]') self.time_remaining_timestamp = int(times[0]['data-timestamp']) self.time_remaining_string = times[0].text - self.time_remaining_in_minutes = self.determine_time_in_minutes(times[0]['data-timestamp']) + self.time_remaining_in_minutes = self._determine_time_in_minutes(times[0]['data-timestamp']) self.time_created_timestamp = int(times[1]['data-timestamp']) self.time_created_string = times[1].text - self.time_created_in_minutes = self.determine_time_in_minutes(times[1]['data-timestamp']) + self.time_created_in_minutes = self._determine_time_in_minutes(times[1]['data-timestamp']) logger.debug(f"Scraped Giveaway: {self}") - def determine_contributor_level(self, contributor_level): + def _determine_contributor_level(self, contributor_level): if contributor_level is None: return 0 match = re.search('^Level (?P[0-9]+)\\+$', contributor_level.text, re.IGNORECASE) @@ -58,14 +58,14 @@ class Giveaway: else: return None - def get_steam_app_id(self, steam_url): - match = re.search('^.+/[a-z0-9]+/(?P[0-9]+)/$', steam_url, re.IGNORECASE) + def _get_steam_app_id(self, steam_url): + match = re.search('^.+/[a-z0-9]+/(?P[0-9]+)(/|\?)', steam_url, re.IGNORECASE) if match: return match.group('steam_app_id') else: return None - def determine_time_in_minutes(self, timestamp): + def _determine_time_in_minutes(self, timestamp): if not timestamp or not re.search('^[0-9]+$', timestamp): logger.error(f"Could not determine time from string {timestamp}") return None @@ -73,7 +73,7 @@ class Giveaway: giveaway_endtime = time.localtime(int(timestamp)) return int(abs((time.mktime(giveaway_endtime) - time.mktime(now)) / 60)) - def determine_cost_and_copies(self, item, game_name, game_id): + def _determine_cost_and_copies(self, item, game_name, game_id): item_headers = item.find_all('span', {'class': 'giveaway__heading__thin'}) if len(item_headers) == 1: # then no multiple copies game_cost = item_headers[0].getText().replace('(', '').replace(')', '').replace('P', '') diff --git a/src/bot/giveaway_thread.py b/src/bot/giveaway_thread.py index be15ae9..7de6fbd 100644 --- a/src/bot/giveaway_thread.py +++ b/src/bot/giveaway_thread.py @@ -1,14 +1,13 @@ import datetime import threading from datetime import timedelta, datetime -from random import randint from threading import Thread from time import sleep -from dateutil import tz - -from .log import get_logger from .enter_giveaways import EnterGiveaways +from .evaluate_won_giveaways import EvaluateWonGiveaways +from .log import get_logger +from .scheduler import Scheduler logger = get_logger(__name__) @@ -20,52 +19,87 @@ class GiveawayThread(threading.Thread): self.exc = None self.config = config self.notification = notification + logger.debug("Creating scheduler") + self._scheduler = Scheduler() + self.won_giveaway_job_id = 'eval_won_giveaways' + self.evaluate_giveaway_job_id = 'eval_giveaways' + + self._all_page = None + self._wishlist_page = None + self._dlc_page = None - def run_steam_gifts(self, config, notification): cookie = config['DEFAULT'].get('cookie') user_agent = config['DEFAULT'].get('user_agent') - main_page_enabled = config['DEFAULT'].getboolean('enabled') - minimum_points = config['DEFAULT'].getint('minimum_points') - max_entries = config['DEFAULT'].getint('max_entries') - max_time_left = config['DEFAULT'].getint('max_time_left') - minimum_game_points = config['DEFAULT'].getint('minimum_game_points') - blacklist = config['DEFAULT'].get('blacklist_keywords') - all_page = EnterGiveaways(cookie, user_agent, 'All', False, minimum_points, max_entries, - max_time_left, minimum_game_points, blacklist, notification) + if config['ALL'].getboolean('all.enabled'): + all_minimum_points = config['ALL'].getint('all.minimum_points') + all_max_entries = config['ALL'].getint('all.max_entries') + all_max_time_left = config['ALL'].getint('all.max_time_left') + all_max_points = config['ALL'].getint('all.max_points') + all_minimum_game_points = config['DEFAULT'].getint('minimum_game_points') + all_blacklist = config['DEFAULT'].get('blacklist_keywords') - wishlist_page_enabled = config['WISHLIST'].getboolean('wishlist.enabled') - wishlist_minimum_points = config['WISHLIST'].getint('wishlist.minimum_points') - wishlist_max_entries = config['WISHLIST'].getint('wishlist.max_entries') - wishlist_max_time_left = config['WISHLIST'].getint('wishlist.max_time_left') + self._all_page = EnterGiveaways(cookie, user_agent, 'All', False, all_minimum_points, all_max_entries, + all_max_time_left, all_max_points, all_minimum_game_points, all_blacklist, notification) - wishlist_page = EnterGiveaways(cookie, user_agent, 'Wishlist', False, wishlist_minimum_points, - wishlist_max_entries, wishlist_max_time_left, 0, '', notification) + if config['WISHLIST'].getboolean('wishlist.enabled'): + wishlist_minimum_points = config['WISHLIST'].getint('wishlist.minimum_points') + wishlist_max_entries = config['WISHLIST'].getint('wishlist.max_entries') + wishlist_max_time_left = config['WISHLIST'].getint('wishlist.max_time_left') + wishlist_max_points = config['WISHLIST'].getint('wishlist.max_points') - if not main_page_enabled and not wishlist_page_enabled: - logger.error("⁉️ Both 'Default' and 'Wishlist' configurations are disabled. Nothing will run. Exiting...") + self._wishlist_page = EnterGiveaways(cookie, user_agent, 'Wishlist', False, wishlist_minimum_points, + wishlist_max_entries, wishlist_max_time_left, wishlist_max_points, 0, '', notification) + + if config['DLC'].getboolean('dlc.enabled'): + dlc_minimum_points = config['DLC'].getint('dlc.minimum_points') + dlc_max_entries = config['DLC'].getint('dlc.max_entries') + dlc_max_time_left = config['DLC'].getint('dlc.max_time_left') + dlc_max_points = config['DLC'].getint('dlc.max_points') + + self._dlc_page = EnterGiveaways(cookie, user_agent, 'DLC', False, dlc_minimum_points, + dlc_max_entries, dlc_max_time_left, dlc_max_points, 0, '', notification) + + if not self._all_page and not self._wishlist_page and not self._dlc_page : + logger.error("⁉️ 'All', 'Wishlist' and 'DLC' configurations are disabled. Nothing will run. Exiting...") sleep(10) exit(-1) - while True: - logger.info("🟢 Evaluating giveaways.") - if wishlist_page_enabled: - wishlist_page.start() - if main_page_enabled: - all_page.start() + won_giveaway_job = self._scheduler.get_job(job_id=self.won_giveaway_job_id) + if won_giveaway_job: + logger.debug("Previous won giveaway evaluator job exists. Removing.") + won_giveaway_job.remove() + won_runner = GiveawayThread.WonRunner(EvaluateWonGiveaways(cookie, user_agent, notification), + self.won_giveaway_job_id) + self._scheduler.add_job(won_runner.run, + id=self.won_giveaway_job_id, + trigger='interval', + max_instances=1, + replace_existing=True, + hours=12, + next_run_time=datetime.now() + timedelta(minutes=5), + jitter=8000) - logger.info("🔴 All giveaways evaluated.") - random_seconds = randint(1740, 3540) # sometime between 29-59 minutes - when_to_start_again = datetime.now(tz=tz.tzlocal()) + timedelta(seconds=random_seconds) - logger.info(f"🛋 Going to sleep for {random_seconds / 60} minutes. " - f"Will start again at {when_to_start_again}") - sleep(random_seconds) + evaluate_giveaway_job = self._scheduler.get_job(job_id=self.evaluate_giveaway_job_id) + if evaluate_giveaway_job: + logger.debug("Previous giveaway evaluator job exists. Removing.") + evaluate_giveaway_job.remove() + runner = GiveawayThread.GiveawayRunner(self._dlc_page, self._wishlist_page, self._all_page, + self.evaluate_giveaway_job_id) + self._scheduler.add_job(runner.run, + id=self.evaluate_giveaway_job_id, + trigger='interval', + max_instances=1, + replace_existing=True, + minutes=44, + next_run_time=datetime.now(), + jitter=900) def run(self): # Variable that stores the exception, if raised by someFunction self.exc = None try: - self.run_steam_gifts(self.config, self.notification) + self._scheduler.start() except BaseException as e: self.exc = e @@ -76,3 +110,46 @@ class GiveawayThread(threading.Thread): # if any was caught if self.exc: raise self.exc + + class WonRunner: + def __init__(self, won_page, job_id): + self._won_page = won_page + self._job_id = job_id + + def run(self): + logger.info(" 💍 Evaluating won giveaways. 💍") + if self._won_page: + self._won_page.start() + logger.info(" 💍 All won giveaways evaluated. 💍") + scheduler = Scheduler() + evaluate_giveaway_job = scheduler.get_job(job_id=self._job_id) + if evaluate_giveaway_job: + when_to_start_again = evaluate_giveaway_job.next_run_time + logger.info(f"💍 Going to sleep. Will start again at {when_to_start_again}") + else: + logger.info("No set time to evaluate won giveaways again.") + + class GiveawayRunner: + + def __init__(self, dlc_page, wishlist_page, all_page, job_id): + self._dlc_page = dlc_page + self._wishlist_page = wishlist_page + self._all_page = all_page + self._job_id = job_id + + def run(self): + logger.info("🟢 Evaluating giveaways.") + if self._dlc_page: + self._dlc_page.start() + if self._wishlist_page: + self._wishlist_page.start() + if self._all_page: + self._all_page.start() + logger.info("🔴 All giveaways evaluated.") + scheduler = Scheduler() + evaluate_giveaway_job = scheduler.get_job(job_id=self._job_id) + if evaluate_giveaway_job: + when_to_start_again = evaluate_giveaway_job.next_run_time + logger.info(f"🛋 Going to sleep. Will start again at {when_to_start_again}") + else: + logger.info("No set time to evaluate giveaways again.") \ No newline at end of file diff --git a/src/bot/models.py b/src/bot/models.py index f9174f8..419f2c5 100644 --- a/src/bot/models.py +++ b/src/bot/models.py @@ -13,11 +13,15 @@ class TableNotification(Base): message = Column(String(300), nullable=False) medium = Column(String(50), nullable=False) success = Column(Boolean, nullable=False) + games_won = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) __mapper_args__ = {"eager_defaults": True} + def __str__(self): + return str(self.__class__) + ": " + str(self.__dict__) + class TableSteamItem(Base): __tablename__ = 'steam_item' @@ -29,6 +33,9 @@ class TableSteamItem(Base): giveaways = relationship("TableGiveaway", back_populates="steam_item") + def __str__(self): + return str(self.__class__) + ": " + str(self.__dict__) + class TableGiveaway(Base): __tablename__ = 'giveaway' @@ -50,3 +57,6 @@ class TableGiveaway(Base): steam_item = relationship("TableSteamItem", back_populates="giveaways") __mapper_args__ = {"eager_defaults": True} + + def __str__(self): + return str(self.__class__) + ": " + str(self.__dict__) diff --git a/src/bot/notification.py b/src/bot/notification.py index 7a3089b..14ef359 100644 --- a/src/bot/notification.py +++ b/src/bot/notification.py @@ -15,11 +15,11 @@ class Notification: self.pushover_user_key = None self.message_prefix = f"{message_prefix}: " - def send_won(self, message): - self.__send('won', message) + def send_won(self, message, number_won): + self.__send('won', message, number_won) def send_error(self, message): - self.__send('error', message) + self.__send('error', message, number_won=None) def enable_pushover(self, token, user_key): logger.debug("Enabling pushover notifications.") @@ -27,13 +27,13 @@ class Notification: self.pushover_token = token self.pushover_user_key = user_key - def __send(self, type_of_error, message): - logger.debug(f"Attempting to notify: {message}") + def __send(self, type_of_error, message, number_won=None): + logger.debug(f"Attempting to notify: '{message}'. Won: {number_won}") if self.pushover: logger.debug("Pushover enabled. Sending message.") - self.__pushover(type_of_error, message) + self.__pushover(type_of_error, message, number_won) - def __pushover(self, type_of_error, message): + def __pushover(self, type_of_error, message, number_won=None): conn = http.client.HTTPSConnection("api.pushover.net:443") conn.request("POST", "/1/messages.json", urllib.parse.urlencode({ @@ -48,4 +48,4 @@ class Notification: else: logger.error(f"Pushover notification failed. Code {response.getcode()}: {response.read().decode()}") success = False - NotificationHelper.insert(type_of_error, f"{message}", 'pushover', success) + NotificationHelper.insert(type_of_error, f"{message}", 'pushover', success, number_won) diff --git a/src/bot/scheduler.py b/src/bot/scheduler.py new file mode 100644 index 0000000..af6498b --- /dev/null +++ b/src/bot/scheduler.py @@ -0,0 +1,33 @@ +import os + +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.blocking import BlockingScheduler + +from .log import get_logger + +logger = get_logger(__name__) + + +class Scheduler: + class __Scheduler: + def __init__(self): + jobstores = { + 'default': SQLAlchemyJobStore(url=f"{os.getenv('BOT_DB_URL', 'sqlite:///./config/sqlite.db')}") + } + job_defaults = { + 'coalesce': True, + 'max_instances': 3 + } + self.scheduler = BlockingScheduler(jobstores=jobstores, job_defaults=job_defaults) + + def __getattr__(self, name): + return getattr(self.scheduler, name) + + instance = None + + def __init__(self): + if not Scheduler.instance: + Scheduler.instance = Scheduler.__Scheduler() + + def __getattr__(self, name): + return getattr(self.instance, name) diff --git a/src/bot/templates/log.html b/src/bot/templates/log.html deleted file mode 100644 index 9b486de..0000000 --- a/src/bot/templates/log.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - Steamgifts Bot Logs - - - -
-
-

Steamgifts Bot

- -
-
-
-
{{content}}
-
- - \ No newline at end of file diff --git a/src/bot/won_entry.py b/src/bot/won_entry.py new file mode 100644 index 0000000..04a8c15 --- /dev/null +++ b/src/bot/won_entry.py @@ -0,0 +1,22 @@ +import re +from .log import get_logger +import time + +logger = get_logger(__name__) + + +class WonEntry: + + def __init__(self, soup_item): + self.game_name = None + self.giveaway_game_id = None + self.giveaway_uri = None + + logger.debug(f"Won Giveaway html: {soup_item}") + self.game_name = soup_item.find('a', {'class': 'table__column__heading'}).text + self.giveaway_game_id = soup_item.find('a', {'class': 'table__column__heading'})['href'].split('/')[2] + self.giveaway_uri = soup_item.select_one('a.table__column__heading')['href'] + logger.debug(f"Scraped Won Giveaway: {self}") + + def __str__(self): + return str(self.__class__) + ": " + str(self.__dict__) diff --git a/src/bot/run.py b/src/run.py similarity index 86% rename from src/bot/run.py rename to src/run.py index c5993fb..2375878 100644 --- a/src/bot/run.py +++ b/src/run.py @@ -1,13 +1,13 @@ import os from time import sleep -from .log import get_logger -from .config_reader import ConfigReader, ConfigException -from .enter_giveaways import SteamGiftsException -from .giveaway_thread import GiveawayThread -from .notification import Notification -from .database import run_migrations, create_engine -from .webserver_thread import WebServerThread +from src.bot.log import get_logger +from src.bot.config_reader import ConfigReader, ConfigException +from src.bot.enter_giveaways import SteamGiftsException +from src.bot.giveaway_thread import GiveawayThread +from src.bot.notification import Notification +from src.bot.database import run_db_migrations +from src.web.webserver_thread import WebServerThread logger = get_logger(__name__) config_file_name = f"{os.getenv('BOT_CONFIG_DIR', './config')}/config.ini" @@ -73,9 +73,9 @@ def entry(): |___/ ------------------------------------------------------------------------------------- """) - run_migrations(alembic_migration_files, db_url) - create_engine(db_url) + run_db_migrations(alembic_migration_files, db_url) run() + if __name__ == '__main__': entry() diff --git a/src/bot/static/css/main.css b/src/web/static/css/main.css similarity index 65% rename from src/bot/static/css/main.css rename to src/web/static/css/main.css index 9613db1..8936f21 100644 --- a/src/bot/static/css/main.css +++ b/src/web/static/css/main.css @@ -73,4 +73,34 @@ h3 { .menu li a { color: #444; text-decoration: none; +} +.styled-table { + border-collapse: collapse; + margin: 25px 0; + font-size: 0.9em; + font-family: sans-serif; + min-width: 400px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); +} +.styled-table thead tr { + background-color: #009879; + color: #ffffff; + text-align: left; +} +.styled-table th, +.styled-table td { + padding: 12px 15px; +} +.styled-table tbody tr { + border-bottom: 1px solid #dddddd; +} +.styled-table tbody tr:nth-of-type(even) { + background-color: #f3f3f3; +} +.styled-table tbody tr:last-of-type { + border-bottom: 2px solid #009879; +} +.styled-table tbody tr.active-row { + font-weight: bold; + color: #009879; } \ No newline at end of file diff --git a/src/bot/templates/configuration.html b/src/web/templates/base.html similarity index 65% rename from src/bot/templates/configuration.html rename to src/web/templates/base.html index 7268e62..6922545 100644 --- a/src/bot/templates/configuration.html +++ b/src/web/templates/base.html @@ -1,7 +1,7 @@ - Steamgifts Bot Configuration + {% block title %} {% endblock %} @@ -11,14 +11,17 @@
-
{{content}}
+ {% block content %} {% endblock %}
\ No newline at end of file diff --git a/src/web/templates/configuration.html b/src/web/templates/configuration.html new file mode 100644 index 0000000..0e9979f --- /dev/null +++ b/src/web/templates/configuration.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} + +{% block title %} {{name}} Steamgifts Bot Configuration {% endblock %} +{% block content %} +
{{data}}
+{% endblock %} \ No newline at end of file diff --git a/src/web/templates/giveaways.html b/src/web/templates/giveaways.html new file mode 100644 index 0000000..b3e5f64 --- /dev/null +++ b/src/web/templates/giveaways.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} + +{% block title %} {{name}} Steamgifts Bot Giveaways {% endblock %} +{% block content %} +
    + {% if data.previous_page %} +
  • Previous
  • + {% else %} +
  • Previous
  • + {% endif %} + {% if data.next_page %} +
  • Next
  • + {% else %} +
  • Next
  • + {% endif %} +
+ + + + + + + + + + + + + + + + + + + {% for row in data.items %} + + + + + + + + + + + + + + + {% endfor %} + +
Giveaway IDSteam IDGiveaway URINameSteam URLUserGiveaway Created AtGiveaway Ended AtCostCopiesContributor LevelEntered
{{row.giveaway_id}}{{row.steam_id}}{{row.giveaway_uri}}{{row.steam_item.game_name}}{{row.steam_item.steam_url}}{{row.user}}{{row.giveaway_created_at}}{{row.giveaway_ended_at}}{{row.cost}}{{row.copies}}{{row.contributor_level}}{{row.entered}}
+{% endblock %} \ No newline at end of file diff --git a/src/web/templates/log.html b/src/web/templates/log.html new file mode 100644 index 0000000..70960cf --- /dev/null +++ b/src/web/templates/log.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} + +{% block title %} {{name}} Steamgifts Bot {{log_type}} Logs{% endblock %} +{% block content %} +
{{data}}
+{% endblock %} \ No newline at end of file diff --git a/src/web/templates/notifications.html b/src/web/templates/notifications.html new file mode 100644 index 0000000..328ead4 --- /dev/null +++ b/src/web/templates/notifications.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block title %} {{name}} Steamgifts Bot Notifications {% endblock %} +{% block content %} + + + + + + + + + + + + + {% for row in data %} + + + + + + + + + {% endfor %} + +
IDMediumTypeSuccessCreated AtMessage
{{row.id}}{{row.medium}}{{row.type}}{{row.success}}{{row.created_at}}{{row.message}}
+{% endblock %} \ No newline at end of file diff --git a/src/web/templates/stats.html b/src/web/templates/stats.html new file mode 100644 index 0000000..f525a15 --- /dev/null +++ b/src/web/templates/stats.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block title %} {{name}} Steamgifts Bot Configuration {% endblock %} +{% block content %} +
    +
  • Total Giveaways Considered: {{totals}}
  • +
  • Giveaways Entered: {{entered}}
  • +
  • Giveaways Won (with the bot): {{won}}
  • +
+{% endblock %} \ No newline at end of file diff --git a/src/bot/webserver_thread.py b/src/web/webserver_thread.py similarity index 64% rename from src/bot/webserver_thread.py rename to src/web/webserver_thread.py index 6f6aeba..3f01a5b 100644 --- a/src/bot/webserver_thread.py +++ b/src/web/webserver_thread.py @@ -4,7 +4,8 @@ from threading import Thread from flask_basicauth import BasicAuth -from .log import get_logger +from src.bot.database import NotificationHelper, GiveawayHelper +from src.bot.log import get_logger logger = get_logger(__name__) @@ -15,6 +16,7 @@ class WebServerThread(threading.Thread): Thread.__init__(self) self.exc = None self.config = config + self.prefix = config['NOTIFICATIONS'].get('notification.prefix') self.host = config['WEB'].get('web.host') self.port = config['WEB'].getint('web.port') self.ssl = config['WEB'].getboolean('web.ssl') @@ -40,25 +42,41 @@ class WebServerThread(threading.Thread): @app.route(f"{self.app_root}") def config(): with open(f"{os.getenv('BOT_CONFIG_DIR', './config')}/config.ini", 'r') as f: - content = f.read() - return render_template('configuration.html', content=content) + data = f.read() + return render_template('configuration.html', name=self.prefix, data=data) @app.route(f"{self.app_root}log_info") def log_info(): with open(f"{os.getenv('BOT_CONFIG_DIR', './config')}/info.log", 'r') as f: - content = f.read() - return render_template('log.html', content=content) + data = f.read() + return render_template('log.html', name=self.prefix, log_type='info', data=data) @app.route(f"{self.app_root}log_debug") def log_debug(): with open(f"{os.getenv('BOT_CONFIG_DIR', './config')}/debug.log", 'r') as f: - content = f.read() - return render_template('log.html', content=content) + data = f.read() + return render_template('log.html', name=self.prefix, log_type='debug', data=data) @app.route(f"{self.app_root}alive") def alive(): return 'OK' + @app.route(f"{self.app_root}notifications") + def db_notifications(): + return render_template('notifications.html', name=self.prefix, data=NotificationHelper.get()) + + @app.route(f"{self.app_root}giveaways", methods=['GET'], defaults={"page": 1}) + @app.route(f"{self.app_root}giveaways/", methods=['GET']) + def db_giveaways(page): + return render_template('giveaways.html', name=self.prefix, data=GiveawayHelper.paginate(page=page)) + + @app.route(f"{self.app_root}stats") + def stats(): + totals = GiveawayHelper.total_giveaways() + entered = GiveawayHelper.total_entered() + won = GiveawayHelper.total_won() + return render_template('stats.html', name=self.prefix, totals=totals, entered=entered, won=won) + if self.enabled: logger.info("Webserver Enabled. Running") if self.ssl: