├── custom_hooks ├── __init__.py └── easter_egg.py ├── best_buy_bullet_bot ├── __init__.py ├── audio │ ├── __init__.py │ ├── notification.wav │ └── sound_effects.py ├── version.py ├── __main__.py ├── data │ ├── browser_login.py │ ├── user_data.py │ ├── __init__.py │ ├── url_utils.py │ └── setting_utils.py ├── tracker │ ├── progress_bar.py │ └── __init__.py ├── utils.py ├── command_line.py └── browser.py ├── docs ├── source │ ├── overview.rst │ ├── assets │ │ ├── logo.png │ │ └── firefox.png │ ├── index.rst │ ├── installation.rst │ ├── running_the_bot.rst │ ├── explanation.rst │ ├── urls.rst │ ├── conf.py │ ├── prerequisites.rst │ ├── settings.rst │ └── reference.rst ├── requirements.txt ├── Makefile └── make.bat ├── MANIFEST.in ├── requirements.txt ├── pyproject.toml ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── .gitignore ├── LICENSE ├── .pre-commit-config.yaml ├── setup.py └── readme.md /custom_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/audio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.0" 2 | -------------------------------------------------------------------------------- /docs/source/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | .. include:: explanation.rst 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==4.1.2 2 | sphinx-copybutton==0.4.0 3 | sphinx-rtd-theme==0.5.2 4 | -------------------------------------------------------------------------------- /docs/source/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeonShams/BestBuyBulletBot/HEAD/docs/source/assets/logo.png -------------------------------------------------------------------------------- /docs/source/assets/firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeonShams/BestBuyBulletBot/HEAD/docs/source/assets/firefox.png -------------------------------------------------------------------------------- /best_buy_bullet_bot/audio/notification.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeonShams/BestBuyBulletBot/HEAD/best_buy_bullet_bot/audio/notification.wav -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | include setup.py 4 | include pyproject.toml 5 | 6 | recursive-include best_buy_bullet_bot * 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4>=4.6.3 2 | clipboard>=0.0.4 3 | elevate>=0.1.3 4 | keyring>=20.0.0 5 | keyrings.cryptfile>=1.3.8 6 | playsound>=1.2.2 7 | psutil>=5.0.0 8 | requests>=2.15.0 9 | rich>=10.0.1 10 | selenium==3.141.0 11 | webdriver-manager>=3.0.0 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | exclude = ''' 3 | ( 4 | /( 5 | \.eggs 6 | | \.git 7 | | \.venv 8 | | build 9 | | dist 10 | | 3b_bot.egg-info 11 | )/ 12 | ) 13 | ''' 14 | 15 | [tool.isort] 16 | profile = "black" 17 | multi_line_output = 3 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | # Defaults 4 | E121,E123,E126,E226,E24,E704,W503,W504 5 | # Space before ":" 6 | E203 7 | # Space after "," 8 | E231 9 | # Line too long 10 | E501 11 | per-file-ignores = 12 | # Ignore "undefined name '__version__'" 13 | setup.py:F821 14 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from rich.traceback import install 4 | 5 | from best_buy_bullet_bot.command_line import run_command 6 | 7 | 8 | def main(): 9 | # Stylizes errors and shows more info 10 | install() 11 | 12 | try: 13 | run_command() 14 | except KeyboardInterrupt: 15 | # This way we don't get the keyboardinterrupt traceback error 16 | sys.exit(0) 17 | 18 | 19 | # Running things directly can be useful in development 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Best Buy Bullet Bot documentation master file, created by 2 | sphinx-quickstart on Wed Jun 23 00:18:43 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Best Buy Bullet Bot (3B Bot) 7 | ================================ 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | 12 | overview.rst 13 | prerequisites.rst 14 | installation.rst 15 | running_the_bot.rst 16 | urls.rst 17 | settings.rst 18 | reference.rst 19 | 20 | .. include:: explanation.rst 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: '' 5 | labels: '' 6 | assignees: LeonShams 7 | 8 | --- 9 | 10 | ### Describe the feature 11 | 12 | 13 | ... 14 | 15 | ### What problem is solved 16 | 17 | 18 | ... 19 | 20 | ### Who benefits 21 | 22 | 23 | ... 24 | 25 | ### Additional information 26 | 27 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/audio/sound_effects.py: -------------------------------------------------------------------------------- 1 | import os 2 | from threading import Event, Thread 3 | 4 | from playsound import playsound 5 | 6 | path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notification.wav") 7 | playing = Event() 8 | 9 | 10 | def _repeat(): 11 | while True: 12 | playing.wait() 13 | playsound(path) 14 | 15 | 16 | repeat_loop = Thread(target=_repeat, daemon=True) 17 | repeat_loop.start() 18 | 19 | 20 | def play(block=False): 21 | playsound(path, block) 22 | 23 | 24 | def start(): 25 | playing.set() 26 | 27 | 28 | def stop(): 29 | playing.clear() 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Installing 3B Bot is as simple as running the following in your shell (Command Prompt for Windows and Terminal for MacOS) 5 | 6 | For MacOS: 7 | 8 | .. code-block:: bash 9 | 10 | python3 -m pip install --upgrade 3b-bot 11 | 12 | For Windows: 13 | 14 | .. code-block:: bash 15 | 16 | pip install --upgrade 3b-bot 17 | 18 | This same command can be used for updating to the newest version once released. 19 | 20 | If the installation fails please go back and make sure you have correctly completed the second step in the prerequisites. If the error persists please report the issue in the `issue tracker `_. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Sphinx documentation 38 | docs/_build/ 39 | docs/build 40 | docs/source/_build 41 | 42 | # pyenv 43 | .python-version 44 | 45 | # Environments 46 | .env 47 | .venv 48 | env/ 49 | venv/ 50 | ENV/ 51 | env.bak/ 52 | venv.bak/ 53 | 54 | # VSCode 55 | .vscode 56 | 57 | # Firefox 58 | geckodriver.log 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Use this template when reporting a bug you encounter. 4 | title: '' 5 | labels: '' 6 | assignees: LeonShams 7 | 8 | --- 9 | 10 | ### Expected behavior 11 | 12 | 13 | ... 14 | 15 | ### Actual behavior 16 | 17 | 18 | ... 19 | 20 | ### To Reproduce 21 | 22 | 23 | 1. ... 24 | 2. ... 25 | 3. ... 26 | 27 | ### Screenshots 28 | 29 | 30 | ### System information 31 | 32 | - OS Platform (e.g., macOS Mojave): 33 | 34 | 35 | - BestBuyBulletBot version: 36 | 37 | 38 | - Python version: 39 | 40 | ### Additional context 41 | 42 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | 4 | ... 5 | 6 | ### Motivation 7 | 8 | 9 | ... 10 | 11 | 12 | 13 | ### Type of change 14 | 15 | 16 | - [ ] Bug fix 17 | - [ ] New feature 18 | - [ ] General cleanup (e.g., typo correction, code cleanup) 19 | - [ ] Other 20 | 21 | ### Checklist 22 | 23 | 24 | 25 | - [ ] Code has been properly formatted by running pre-commit hooks 26 | - [ ] Code has undergone vigorously testing 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Leon Shams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/running_the_bot.rst: -------------------------------------------------------------------------------- 1 | Running the Bot 2 | =============== 3 | 4 | .. role:: bash(code) 5 | :language: bash 6 | 7 | To start the bot just enter the following in your shell 8 | 9 | .. code-block:: bash 10 | 11 | 3b-bot 12 | 13 | .. note:: 14 | 15 | The bot can be stopped at any time regardless of OS with ``ctrl+c``. 16 | 17 | You will then be prompted to set some URLs to track. These URLs can be later modified with the commands :bash:`3b-bot add-url`, :bash:`3b-bot add-url-group`, :bash:`3b-bot remove-urls`, and :bash:`3b-bot clear-urls`. 18 | 19 | Finally, you will be prompted to set a password for your encrypted keyring. The encrypted keyring is just the location that stores your Best Buy credentials (email, password, CVV). The keyring password is like a master password for all your credentials. 20 | 21 | If you ever forget the password for your encrypted keyring, just clear your credentials with the following command and an opportunity will be granted to reset the password. 22 | 23 | .. code-block:: bash 24 | 25 | 3b-bot clear-creds 26 | 27 | To view the list of commands run 28 | 29 | .. code-block:: bash 30 | 31 | 3b-bot --help 32 | -------------------------------------------------------------------------------- /docs/source/explanation.rst: -------------------------------------------------------------------------------- 1 | Best Buy Bullet Bot, abbreviated to 3B Bot, is a stock checking bot with auto-checkout created to instantly purchase out-of-stock items on Best Buy once restocked. It was designed for speed with ultra-fast auto-checkout, as well as the ability to utilize all cores of your CPU with multiprocessing for optimal performance. 2 | 3 | * Headless item stock tracking 4 | 5 | * Multiprocessing and multithreading for best possible performance 6 | 7 | * One-time login on startup 8 | 9 | * Ultra-fast auto-checkout 10 | 11 | * Encrypted local credentials storage 12 | 13 | * Super easy setup and usage 14 | 15 | Bear in mind that 3B Bot is currently not equipped to handle a queue and/or email verification during the checkout process. If either of these is present, the bot will wait for you to take over and will take control again once you are back on the traditional checkout track. 16 | 17 | Like `fairgame `_, 3B Bot was created to help level the playing field by giving your average gamer a fighting chance against greedy scalpers. Getting started with 3B Bot was designed to be as easy as possible and shouldn't take more than a couple of a minutes. 18 | 19 | .. image:: assets/demo.svg 20 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/data/browser_login.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from json.decoder import JSONDecodeError 4 | 5 | from best_buy_bullet_bot.data import SHARED_DIR 6 | 7 | COOKIES_DIR = os.path.join(SHARED_DIR, "login_cookies.json") 8 | 9 | 10 | def save_cookies(driver): 11 | with open(COOKIES_DIR, "w+") as f: 12 | json.dump(driver.get_cookies(), f) 13 | 14 | 15 | def load_cookies(driver): 16 | try: 17 | with open(COOKIES_DIR) as f: 18 | for cookie in json.load(f): 19 | driver.add_cookie(cookie) 20 | return True 21 | 22 | # An error occurred while adding the cookies 23 | except AssertionError: 24 | # Delete previous cookies 25 | delete_cookies() 26 | return False 27 | 28 | 29 | def cookies_available(): 30 | file_exists = os.path.isfile(COOKIES_DIR) 31 | 32 | if file_exists: 33 | # Make sure file is JSON decodable 34 | try: 35 | with open(COOKIES_DIR) as f: 36 | json.load(f) 37 | return True 38 | 39 | except JSONDecodeError: 40 | _delete_cookies() 41 | 42 | return False 43 | 44 | 45 | def delete_cookies(): 46 | if cookies_available(): 47 | _delete_cookies() 48 | 49 | 50 | def _delete_cookies(): 51 | os.remove(COOKIES_DIR) 52 | -------------------------------------------------------------------------------- /docs/source/urls.rst: -------------------------------------------------------------------------------- 1 | URLs 2 | ==== 3 | 4 | The following commands can be used to view and modify the added URLs. 5 | 6 | * :code:`view-urls` View list of tracked URLs. 7 | * :code:`add-url` Add URL to tracking list. 8 | * :code:`add-url-group` Add multiple URLs and set a quantity for all of them as a whole instead of individually. 9 | * :code:`remove-url` Remove a URL or URL group from the list of tracked URLs. 10 | * :code:`test-urls` Tests all URLs to confirm that they are trackable. This is also run automatically on startup. 11 | * :code:`clear-urls` Remove all tracked URLs. 12 | 13 | Even with a quantity greater than 1, items will be purchased one at a time to prevent one person from buying up all the stock. For example, if you had a quantity of 2, when the item comes back in stock a single unit will be purchased, then if the item is still in stock, it will purchase the second item separately before exiting. 14 | 15 | **What is a URL group?** 16 | 17 | A URL group allows multiple URLs to connect to a single quantity. For example, if you wanted to get **one** RTX 3060 TI but didn't care whether it was manufactured by ASUS or EVGA you could add the URL for each of the cards to a URL group and set a quantity of 1. The bot will purchase whichever one comes back in stock first, and once purchased will stop tracking both of them since you only wanted one graphics card. 18 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | import pkg_resources 21 | import sphinx_rtd_theme 22 | 23 | project = "Best Buy Bullet Bot" 24 | copyright = "2021, Leon Shams-Schaal" 25 | author = "Leon Shams-Schaal" 26 | 27 | release = pkg_resources.get_distribution("3b-bot").version 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | needs_sphinx = "4.0" 33 | 34 | extensions = [ 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.autosectionlabel", 37 | "sphinx.ext.viewcode", 38 | "sphinx.ext.napoleon", 39 | "sphinx_copybutton", 40 | ] 41 | 42 | templates_path = ["_templates"] 43 | source_suffix = ".rst" 44 | exclude_patterns = [] 45 | 46 | html_theme = "sphinx_rtd_theme" 47 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 48 | html_static_path = ["_static"] 49 | 50 | html_logo = "assets/logo.png" 51 | html_theme_options = {"logo_only": True} 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: quarterly 3 | 4 | repos: 5 | - repo: https://github.com/asottile/pyupgrade 6 | rev: v3.8.0 7 | hooks: 8 | - id: pyupgrade 9 | args: [--py37-plus] 10 | 11 | - repo: https://github.com/MarcoGorelli/absolufy-imports 12 | rev: v0.3.1 13 | hooks: 14 | - id: absolufy-imports 15 | files: ^best_buy_bullet_bot/ 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.12.0 19 | hooks: 20 | - id: isort 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 23.3.0 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/asottile/blacken-docs 28 | rev: 1.14.0 29 | hooks: 30 | - id: blacken-docs 31 | 32 | - repo: https://github.com/pre-commit/pre-commit-hooks 33 | rev: v4.4.0 34 | hooks: 35 | - id: check-toml 36 | - id: debug-statements 37 | - id: requirements-txt-fixer 38 | - id: end-of-file-fixer 39 | - id: trailing-whitespace 40 | 41 | - repo: local 42 | hooks: 43 | - id: distribute-easter-egg 44 | name: Add Easter Egg Comments 45 | description: Adds the full easter egg message where if finds any comment that contains the text "EASTER EGG" 46 | language: python 47 | entry: python custom_hooks/easter_egg.py 48 | 49 | - repo: https://github.com/asottile/yesqa 50 | rev: v1.5.0 51 | hooks: 52 | - id: yesqa 53 | 54 | - repo: https://github.com/PyCQA/flake8 55 | rev: 6.0.0 56 | hooks: 57 | - id: flake8 58 | 59 | - repo: https://github.com/codespell-project/codespell 60 | rev: v2.2.5 61 | hooks: 62 | - id: codespell 63 | types_or: [python, rst, markdown] 64 | files: ^(best_buy_bullet_bot|custom_hooks|docs)/ 65 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/tracker/progress_bar.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from collections import deque 4 | from datetime import timedelta 5 | 6 | from rich import get_console 7 | from rich.progress import BarColumn, Progress, ProgressColumn, SpinnerColumn, TextColumn 8 | 9 | 10 | class TimeRemainingColumn(ProgressColumn): 11 | """Renders estimated time remaining.""" 12 | 13 | # Only refresh twice a second to prevent jitter 14 | max_refresh = 0.5 15 | 16 | def __init__(self, *args, **kwargs): 17 | self.start_time = time.time() 18 | super().__init__(*args, **kwargs) 19 | 20 | def render(self, *args, **kwargs): 21 | delta = timedelta(seconds=int(time.time() - self.start_time)) 22 | return str(delta) 23 | 24 | 25 | class IterationsPerSecond: 26 | def format(self, task): 27 | if "times" in dir(task) and len(task.times): 28 | speed = len(task.times) / task.times[-1] 29 | return f"{speed:.2f}it/s" 30 | return "0.00it/s" 31 | 32 | 33 | class IndefeniteProgressBar: 34 | def __init__(self): 35 | with get_console() as console: 36 | self.pbar = Progress( 37 | SpinnerColumn(style=""), 38 | TextColumn("{task.completed}it"), 39 | BarColumn(console.width), 40 | TextColumn(IterationsPerSecond()), 41 | TimeRemainingColumn(), 42 | console=console, 43 | expand=True, 44 | ) 45 | 46 | self.pbar.start() 47 | self.pbar.add_task(None, start=False) 48 | self.pbar.tasks[0].times = deque(maxlen=100) 49 | self.start_time = time.time() 50 | 51 | def print(self, *args, sep=" ", end="\n"): 52 | msg = sep.join(map(str, args)) 53 | sys.stdout.writelines(msg + end) 54 | 55 | def update(self): 56 | task = self.pbar.tasks[0] 57 | task.completed += 1 58 | task.times.append(time.time() - self.start_time) 59 | 60 | def close(self): 61 | self.pbar.stop() 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | # Get __version__ from best_buy_bullet_bot/version.py 6 | exec( 7 | compile( 8 | open("best_buy_bullet_bot/version.py").read(), 9 | "best_buy_bullet_bot/version.py", 10 | "exec", 11 | ) 12 | ) 13 | 14 | setup( 15 | name="3b-bot", 16 | version=__version__, 17 | author="Leon Shams-Schaal", 18 | description="Quickly purchase items from Best Buy the moment they restock.", 19 | long_description=Path("readme.md").read_text(encoding="utf-8"), 20 | long_description_content_type="text/markdown", 21 | url="https://github.com/LeonShams/BestBuyBulletBot", 22 | project_urls={ 23 | "Documentation": "https://github.com/LeonShams/BestBuyBulletBot/wiki", 24 | "Bug Tracker": "https://github.com/LeonShams/BestBuyBulletBot/issues", 25 | }, 26 | packages=find_packages(), 27 | python_requires=">=3.6", 28 | install_requires=Path("requirements.txt").read_text().splitlines(), 29 | entry_points={"console_scripts": ["3b-bot=best_buy_bullet_bot.__main__:main"]}, 30 | include_package_data=True, 31 | zip_safe=False, 32 | license="MIT", 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "Intended Audience :: End Users/Desktop", 36 | "Operating System :: OS Independent", 37 | "License :: OSI Approved :: MIT License", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3 :: Only", 40 | "Environment :: Console", 41 | ], 42 | keywords="bestbuy bot bestbuybot bestbuybulletbot 3bbot 3b-bot bestbuystock \ 43 | bestbuyrestock bestbuytracker stocktracker bestbuystocktracker \ 44 | autocheckout bestbuyautocheckout nvidiabot gpubot nvidiagpubot \ 45 | 3060bot 3060tibot 3070bot 3070tibot 3080bot 3080tibot 3090bot \ 46 | ps5bot nvidiatracker gputracker nvidiagputracker 3060tracker \ 47 | 3060titracker 3070tracker 3070titracker 3080tracker 3080titracker \ 48 | 3090tracker ps5tracker", 49 | ) 50 | -------------------------------------------------------------------------------- /docs/source/prerequisites.rst: -------------------------------------------------------------------------------- 1 | Prerequisites 2 | ============= 3 | 4 | .. role:: bash(code) 5 | :language: bash 6 | 7 | #. A Best Buy account with your location and payment information already set in advance. 8 | 9 | The only information the bot will fill out during checkout is your login credentials (email and password) and the CVV of the card used when setting up your payment information on Best Buy (PayPal is currently not supported). All other information that may be required during checkout must be filled out beforehand. 10 | 11 | * A shipping address can be set at https://www.bestbuy.com/profile/c/address/shipping/add. 12 | 13 | * Your payment methods can be viewed and modified at https://www.bestbuy.com/profile/c/billinginfo/cc. 14 | 15 | #. Python 3.6 or newer 16 | 17 | 3B Bot is written in Python so if it is not already installed on your computer please install it from https://www.python.org/downloads/. 18 | 19 | .. note:: 20 | 21 | **On Windows make sure to tick the "Add Python to PATH" checkbox during the installation process.** On MacOS this is done automatically. 22 | 23 | .. image:: https://lh6.googleusercontent.com/Xirse0uDfZCQaHnIAa7UCd1IRr5_hnFgv8qDUEkT98ENyQ7E5I8R8nLbWmYMl3g1blhUCooAhJsZnKDmjQqeqfyUZnbVaHDOZY7qX7sW6Ui8ZdTjm0fzkwoZwV0xbfjaW3i9bVeg 24 | 25 | | 26 | 27 | Feel free to install whichever version of Python you would like so long as it is 3.6 or greater. To check your version of the currently installed Python run the following in your shell. 28 | 29 | For MacOS: 30 | 31 | .. code-block:: bash 32 | 33 | python3 --version 34 | 35 | For Windows: 36 | 37 | .. code-block:: bash 38 | 39 | python --version 40 | 41 | If your version is less than 3.6 or you get the message :bash:`python is not recognized as an internal or external command` then install Python from the link above. 42 | 43 | #. A supported browser 44 | 45 | 3B Bot currently only supports `Chrome `_ and `Firefox `_. We recommend using the Firefox browser for it's superior performance during tracking. 46 | 47 | .. note:: 48 | 49 | Only the regular edition of each browser is supported, so if you have Firefox Developer Edition or Chrome Dev you would need to install the regular edition on top of your current installation. 50 | 51 | .. image:: assets/firefox.png 52 | -------------------------------------------------------------------------------- /docs/source/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | **Funds** 5 | 6 | Funds signifiy the maximum amount of money the bot is permitted to spend and can be set using the :code:`set-funds` command. Make sure that your funds never exceed the amount of money on your card to prevent your card from getting declined during checkout. By default funds are set to $1,000. 7 | 8 | **Tax** 9 | 10 | Your state's tax rate is used to predict the price of an item so that we don't track items that exceed our funds. The tax rate can be set with the :code:`set-tax` command. 11 | 12 | **Auto Checkout** 13 | 14 | If auto checkout is enabled the bot will attempt to automatically complete the checkout process for you as fast as possible. If it gets stuck during checkout it will wait for up to 20 minutes for you to take over. If you take over within that timeframe the bot will take back control once it sees something that it knows how to handle. 15 | 16 | Bear in mind that disabling auto checkout comes with some benefits as well: faster startup, no personal data needs to be stored, and most importantly MUCH faster tracking. Without auto checkout we can make get requests instead of having to refresh the page constantly. This is orders of magnitude faster and if you intend to do the checkout process yourself it is the way to go. 17 | 18 | **Browser** 19 | 20 | Choose which browser to use for tracking and auto checkout. This only applies if auto chekcout is enabled. Auto checkout can be toggled with the :code:`toggle-auto-checkout` command. 21 | 22 | **Sound Mode** 23 | 24 | When items come back in stock a sound will start playing to alert you of their availability. This parameter will specify whether you would like the sound to be completely disabled, to play once on restock, or play repeatedly until the item is no longer in stock. 25 | 26 | **Threads** 27 | 28 | Threads specify the number of trackers that should be started for each URL. Because it takes a while to get a response from Best Buy servers after making a get request multiple threads can be started so another thread can make a request while the other is waiting for a response. 29 | 30 | Threads can be set with the :code:`set-threads` command, but be cautious as to not set this value too high or you might overwhelm you CPU and actually hurt your performance. 31 | 32 | .. list-table:: 33 | :widths: 50, 50 34 | :header-rows: 1 35 | 36 | * - 1 Thread 37 | - 3 Threads 38 | * - .. image:: https://files.realpython.com/media/IOBound.4810a888b457.png 39 | - .. image:: https://files.realpython.com/media/Threading.3eef48da829e.png 40 | 41 | Image credit: https://realpython.com/python-concurrency/ 42 | -------------------------------------------------------------------------------- /docs/source/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | A list of all commands and flags can be viewied in the shell with the following command. 5 | 6 | .. code-block:: bash 7 | 8 | 3b-bot --help 9 | 10 | Commands 11 | -------- 12 | 13 | :code:`start` Start tracking the currently set URLs. This is the default command. 14 | 15 | :code:`view-urls` View list of tracked URLs. 16 | 17 | :code:`add-url` Add URL to tracking list. 18 | 19 | :code:`add-url-group` Add multiple URLs and set a quantity for all of them as a whole instead of individually. 20 | 21 | :code:`remove-url` Remove a URL from the list of tracked URLs. 22 | 23 | :code:`test-urls` Tests to make sure all URLs can be tracked. This is also run on startup. 24 | 25 | :code:`clear-urls` Remove all tracked URLs. 26 | 27 | :code:`view-settings` View current settings. 28 | 29 | :code:`set-funds` Set how much money the bot is allowed to spend. Defaults to $1000. 30 | 31 | :code:`set-tax` Set the sales tax rate for your state. Defaults to 9.5%. 32 | 33 | :code:`toggle-auto-checkout` Enable/disable auto checkout. 34 | 35 | :code:`change-browser` Pick the browser to be used during tracking and auto-checkout (only applies if auto-checkout is enabled). Firefox is the default and 36 | recommended browser. 37 | 38 | :code:`test-sound` Play sound sample. 39 | 40 | :code:`set-sound-mode` Choose whether you want sound to be completely disabled, play once on item restock, or play repeatedly on item restock. 41 | 42 | :code:`set-threads` Select the number of threads to allocate to tracking each URL. 43 | 44 | :code:`count-cores` Print how many CPU cores you have and how many threads each core has. 45 | 46 | :code:`reset-settings` Reset setting to the defaults. 47 | 48 | :code:`view-creds` View your Best Buy login credentials (email, password, cvv). 49 | 50 | :code:`set-creds` Set your Best Buy login credentials (email, password, cvv). 51 | 52 | :code:`clear-creds` Reset your Best Buy login credentials (email, password, cvv). Also offers the option to reset your access password. 53 | 54 | Flags 55 | ----- 56 | 57 | Flags are options that can be passed alongside a command,with the exception of :code:`--help` and :code:`--version` which shouldn't be passed with any commands. 58 | 59 | :code:`--version` Show 3B Bot version number. 60 | 61 | :code:`--suppress-warnings` Suppress warnings. 62 | 63 | :code:`--headless` Hide the browser during auto checkout. 64 | 65 | :code:`--verify-account` Confirm that the account is setup properly (automatically performed on first run). 66 | 67 | :code:`--skip-verification` Skip checks on first run that make sure account is setup properly. 68 | 69 | :code:`--force-login` Force browser to go through traditional login process as opposed to using cookies to skip steps. 70 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/data/user_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from getpass import getpass 4 | 5 | from keyring.errors import PasswordDeleteError 6 | from keyring.util import properties 7 | from keyrings.cryptfile.cryptfile import CryptFileKeyring 8 | from keyrings.cryptfile.file_base import FileBacked 9 | 10 | from best_buy_bullet_bot.data import SHARED_DIR 11 | from best_buy_bullet_bot.utils import Colors, yes_or_no 12 | 13 | EMAIL_VAR = "BB_EMAIL" 14 | PASS_VAR = "BB_PASS" 15 | CVV_VAR = "BB_CVV" 16 | 17 | 18 | @properties.NonDataProperty 19 | def file_path(self): 20 | return os.path.join(SHARED_DIR, self.filename) 21 | 22 | 23 | FileBacked.file_path = file_path 24 | 25 | SERVICE_ID = "3B_BOT" 26 | KR = CryptFileKeyring() 27 | 28 | 29 | def set_access_pass(access_pass): 30 | KR._keyring_key = access_pass 31 | 32 | 33 | def authenticate(): 34 | attempts = 0 35 | while True: 36 | try: 37 | KR.keyring_key 38 | return 39 | except ValueError: 40 | if str(KR._keyring_key).strip() == "": 41 | sys.exit() 42 | 43 | attempts += 1 44 | if attempts >= 3: 45 | print("Too many attempts, please try again later.") 46 | sys.exit() 47 | 48 | print("Sorry, try again.") 49 | KR._keyring_key = None 50 | 51 | 52 | def _get_cred(name, default_value): 53 | cred = KR.get_password(SERVICE_ID, name) 54 | return cred if cred is not None else default_value 55 | 56 | 57 | def get_creds(default_value=""): 58 | authenticate() 59 | return [_get_cred(var, default_value) for var in [EMAIL_VAR, PASS_VAR, CVV_VAR]] 60 | 61 | 62 | def _get_input(prompt): 63 | while True: 64 | value = input(prompt) 65 | 66 | if yes_or_no("Continue (y/n): "): 67 | return value 68 | 69 | 70 | def set_creds(): 71 | authenticate() 72 | KR.set_password(SERVICE_ID, EMAIL_VAR, _get_input("Email: ")) 73 | 74 | print() 75 | while True: 76 | password = getpass("Best Buy password: ") 77 | confirm_pass = getpass("Confirm password: ") 78 | if password == confirm_pass: 79 | break 80 | print("Passwords didn't match! Try again.") 81 | KR.set_password(SERVICE_ID, PASS_VAR, password) 82 | 83 | print() 84 | KR.set_password(SERVICE_ID, CVV_VAR, _get_input("CVV: ")) 85 | Colors.print("Successfully updated credentials!", properties=["success"]) 86 | 87 | 88 | def print_creds(): 89 | email, password, cvv = get_creds(Colors.str("EMPTY", ["fail"])) 90 | print("Email:", email) 91 | print("Password:", password) 92 | print("CVV:", cvv) 93 | 94 | 95 | def clear_creds(): 96 | for name in [EMAIL_VAR, PASS_VAR, CVV_VAR]: 97 | try: 98 | KR.delete_password(SERVICE_ID, name) 99 | except PasswordDeleteError: 100 | pass 101 | Colors.print("Credentials cleared!\n", properties=["success", "bold"]) 102 | 103 | # Check if user wants to reset their password 104 | if yes_or_no("Would you like to reset your password (y/n): "): 105 | os.remove(KR.file_path) 106 | KR.keyring_key 107 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import psutil 4 | from rich import get_console 5 | from rich.columns import Columns 6 | from rich.live import Live 7 | from rich.spinner import Spinner 8 | from rich.table import Table 9 | 10 | 11 | def _pretty_warning(msg, *args, **kwargs): 12 | return Colors.str(f"WARNING: {msg}\n", properties=["warning"]) 13 | 14 | 15 | warnings.formatwarning = _pretty_warning 16 | 17 | 18 | class Colors: 19 | SUCCESS = "\033[92m" 20 | WARNING = "\033[93m" 21 | FAIL = "\033[91m" 22 | BLUE = "\033[94m" 23 | BOLD = "\033[1m" 24 | _ENDC = "\033[0m" 25 | 26 | @staticmethod 27 | def _props2str(props): 28 | return "".join([getattr(Colors, prop.upper()) for prop in props]) 29 | 30 | @staticmethod 31 | def str(string, properties=[]): 32 | return Colors._props2str(properties) + string + Colors._ENDC 33 | 34 | @staticmethod 35 | def print(*args, properties=[], **kwargs): 36 | print(Colors._props2str(properties), end="") 37 | print_end = kwargs.pop("end", "\n") 38 | print(*args, **kwargs, end=Colors._ENDC + print_end) 39 | 40 | @staticmethod 41 | def warn(*args, **kwargs): 42 | warnings.warn(*args, **kwargs) 43 | 44 | 45 | def print_table(columns, rows, justifications=["left", "center"]): 46 | table = Table(show_lines=True) 47 | for i, column in enumerate(columns): 48 | table.add_column(column, justify=justifications[i]) 49 | 50 | for row in rows: 51 | row = list(map(str, row)) 52 | max_lines = max(string.count("\n") for string in row) 53 | vert_align_row = [ 54 | "\n" * int((max_lines - string.count("\n")) / 2) + string for string in row 55 | ] 56 | table.add_row(*vert_align_row) 57 | 58 | with get_console() as console: 59 | console.print(table) 60 | 61 | 62 | def count_cores(): 63 | cores = psutil.cpu_count(logical=False) 64 | print("Cores:", cores) 65 | threads = psutil.cpu_count(logical=True) / cores 66 | int_threads = int(threads) 67 | if int_threads == threads: 68 | print("Threads per core:", int_threads) 69 | 70 | 71 | def warnings_suppressed(): 72 | return any( 73 | [filter[0] == "ignore" and filter[2] is Warning for filter in warnings.filters] 74 | ) 75 | 76 | 77 | def loading(msg): 78 | loading_text = Columns([msg, Spinner("simpleDotsScrolling")]) 79 | return Live(loading_text, refresh_per_second=5, transient=True) 80 | 81 | 82 | def yes_or_no(prompt): 83 | while True: 84 | response = input(prompt).lower().strip() 85 | 86 | if response == "": 87 | continue 88 | 89 | responded_yes = response == "yes"[: len(response)] 90 | responded_no = response == "no"[: len(response)] 91 | 92 | if responded_yes != responded_no: # responeded_yes xor responded_no 93 | return responded_yes 94 | else: 95 | Colors.print( 96 | 'Invalid response. Please enter either "y" or "n"', properties=["fail"] 97 | ) 98 | 99 | 100 | def validate_num(val, dtype): 101 | try: 102 | cast_val = dtype(val) 103 | except ValueError: 104 | return 105 | 106 | if dtype is int and cast_val != float(val): 107 | return 108 | return cast_val 109 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/data/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import tempfile 5 | from glob import glob 6 | 7 | from best_buy_bullet_bot.utils import Colors 8 | 9 | 10 | def _read_file(): 11 | FP.seek(0) 12 | return json.load(FP) 13 | 14 | 15 | def _write_file(content): 16 | FP.seek(0) 17 | FP.write(json.dumps(content)) 18 | FP.truncate() 19 | 20 | 21 | # This function is called before killing all processes to 22 | # make sure the temp file is deleted 23 | def close_data(): 24 | FP.close() 25 | if num_temp_files == 1 and os.path.isfile(FP.name): 26 | os.remove(FP.name) 27 | 28 | 29 | # Windows doesn't allow for temporary files to be opened by a subprocess 30 | # by default so we have to pass a flag to do so 31 | def temp_opener(name, flag, mode=0o777): 32 | return os.open(name, flag | os.O_TEMPORARY, mode) 33 | 34 | 35 | # TODO: Prevent directory from changing based on whether or not we are in root 36 | temp_dir = os.path.dirname(tempfile.mkdtemp()) 37 | 38 | prefix = "best_buy_bullet_bot_global_vars" 39 | suffix = ".json" 40 | available_temp_files = glob(os.path.join(temp_dir, f"{prefix}*{suffix}")) 41 | num_temp_files = len(available_temp_files) 42 | 43 | 44 | if num_temp_files == 1: 45 | # Open the existing temp file 46 | FP = open( 47 | os.path.join(temp_dir, available_temp_files[0]), 48 | "r+", 49 | opener=temp_opener if sys.platform == "win32" else None, 50 | ) 51 | else: 52 | if num_temp_files > 1: 53 | # Too many temp files 54 | Colors.warn( 55 | f"Too many temporary files detected: {available_temp_files}. Deleting all temporary files." 56 | ) 57 | for filename in available_temp_files: 58 | os.remove(os.path.join(temp_dir, filename)) 59 | 60 | # Create a new temp file since we don't have any 61 | FP = tempfile.NamedTemporaryFile("r+", prefix=prefix, suffix=suffix, dir=temp_dir) 62 | _write_file({}) 63 | 64 | 65 | class ReferenceVar: 66 | """Points to a specific variable in the temp file. 67 | 68 | If a variale in changed by one process all other processes 69 | with that variable will receive that change when trying to 70 | access the variable. 71 | """ 72 | 73 | def __init__(self, var_name): 74 | self.var_name = var_name 75 | 76 | def __new__(cls, var_name): 77 | # Return the value of the variable if it is a constant 78 | # else return this object 79 | self = super().__new__(cls) 80 | self.__init__(var_name) 81 | return self() if var_name.endswith("constant") else self 82 | 83 | def __call__(self): 84 | return _read_file()[self.var_name] 85 | 86 | def update(self, new_value, constant=False): 87 | # Update the value of the variable in the temp file 88 | updated_dict = _read_file() 89 | 90 | new_name = self.var_name 91 | new_name += "_constant" if constant else "" 92 | updated_dict.update({new_name: new_value}) 93 | _write_file(updated_dict) 94 | 95 | return new_value if constant else self 96 | 97 | 98 | if num_temp_files != 1: 99 | # We are in the main process. This is where variables are created. 100 | 101 | from keyring.util import platform_ 102 | 103 | # We store data here so it doesn't get overwritten during an update 104 | shared_dir = os.path.join( 105 | os.path.dirname(platform_.data_root()), "best_buy_bullet_bot" 106 | ) 107 | if not os.path.isdir(shared_dir): 108 | os.makedirs(shared_dir) 109 | 110 | # Save to temporary file 111 | HEADLESS_WARNED = ReferenceVar("HEADLESS_WARNED").update(False) 112 | SHARED_DIR = ReferenceVar("SHARED_DIR").update(shared_dir, constant=True) 113 | else: 114 | # We are in a separate process. This is where variables are copied over from the main process. 115 | 116 | # Copy over all variables in the temp file to the locals dict so they can be imported 117 | for var_name in _read_file(): 118 | locals()[var_name.replace("_constant", "")] = ReferenceVar(var_name) 119 | -------------------------------------------------------------------------------- /custom_hooks/easter_egg.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os.path 3 | from pathlib import Path 4 | 5 | 6 | def calculate_indentation(string): 7 | return string[: string.index(string.strip())] 8 | 9 | 10 | def starts_with(string, *substrings, begin=0, end=None): 11 | starts_with_substring = list(map(string[begin:end].startswith, substrings)) 12 | if any(starts_with_substring): 13 | return substrings[starts_with_substring.index(True)] 14 | return False 15 | 16 | 17 | MSG = '''""" 18 | EASTER EGG: Thank you for reading the source code! 19 | To run the bot with a higher priority level and achieve better performance complete the following. 20 | 21 | If using Firefox, complete the following before moving on to the next step: 22 | WINDOWS: Open a Command Prompt window with "Run as administrator" https://www.educative.io/edpresso/how-to-run-cmd-as-an-administrator 23 | MAC: Enter the command `su` in your terminal to gain root privileges. Beware your settings may be different in the root session, but you can always return to a normal session with the `exit` command. 24 | 25 | Then regardless of your browser: 26 | Run `3b-bot --fast` in your shell. 27 | """''' 28 | 29 | split_msg = MSG.split("\n") 30 | 31 | 32 | def indent_msg(indent): 33 | return "\n".join( 34 | [indent + msg_line if msg_line.strip() else "" for msg_line in split_msg] 35 | ) 36 | 37 | 38 | if __name__ == "__main__": 39 | # Get path of best_buy_bullet_bot directory 40 | parent_dir = Path(__file__).parents[1] 41 | search_dir = os.path.join(parent_dir, "best_buy_bullet_bot") 42 | 43 | for filename in glob.iglob(os.path.join(search_dir, "**"), recursive=True): 44 | # Skip if not a python file 45 | if not filename.endswith(".py"): 46 | continue 47 | 48 | with open(filename, "r+") as f: 49 | code = f.read() 50 | lowercase_code = code.lower() 51 | 52 | # Skip file if no easter egg comments need to be added 53 | if "easter egg" not in lowercase_code: 54 | continue 55 | 56 | lines = code.split("\n") 57 | lower_lines = lowercase_code.split("\n") 58 | 59 | for idx, line in enumerate(lines): 60 | line = line.lower().strip() 61 | 62 | # Skip line if the text "easter egg" is not in it 63 | if "easter egg" not in line: 64 | continue 65 | 66 | # This variable means we will delete the following lines until we find a line that ends in the variable 67 | clear_multiline_string = starts_with(line, "'''", '"""') 68 | 69 | # If the multiline comment starts on the previous line 70 | if not clear_multiline_string and not starts_with(line, "'", '"', "#"): 71 | previous_line = lines[idx - 1] 72 | indent = calculate_indentation(previous_line) 73 | 74 | previous_line = previous_line.strip() 75 | clear_multiline_string = starts_with(previous_line, "'''", '"""') 76 | 77 | if clear_multiline_string: 78 | # Delete the previous line 79 | lines.pop(idx - 1) 80 | idx -= 1 81 | else: 82 | # Its not a comment, just the text "easter egg" laying around somewhere 83 | continue 84 | else: 85 | indent = calculate_indentation(lines[idx]) 86 | 87 | if clear_multiline_string: 88 | # Delete all subsequent lines until the comment ends 89 | while not lines[idx + 1].strip().endswith(clear_multiline_string): 90 | lines.pop(idx + 1) 91 | lines.pop(idx + 1) 92 | 93 | # Replace the current line with the correct message 94 | lines.pop(idx) 95 | lines.insert(idx, indent_msg(indent)) 96 | 97 | easter_egg_code = "\n".join(lines) 98 | 99 | # Update the file with the new code 100 | if easter_egg_code != code: 101 | f.seek(0) 102 | f.write(easter_egg_code) 103 | f.truncate() 104 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Best Buy Bullet Bot (3B Bot) - DEPRECATED 2 | 3 | Best Buy Bullet Bot, abbreviated to 3B Bot, is a stock checking bot with auto-checkout created to instantly purchase out-of-stock items on Best Buy once restocked. It was designed for speed with ultra-fast auto-checkout, as well as the ability to utilize all cores of your CPU with multiprocessing for optimal performance. 4 | 5 | * Headless item stock tracking 6 | 7 | * Multiprocessing and multithreading for best possible performance 8 | 9 | * One-time login on startup 10 | 11 | * Ultra-fast auto-checkout 12 | 13 | * Encrypted local credentials storage 14 | 15 | * Super easy setup and usage 16 | 17 | Bear in mind that 3B Bot is currently not equipped to handle a queue and/or email verification during the checkout process. If either of these is present, the bot will wait for you to take over and will take control again once you are back on the traditional checkout track. 18 | 19 | ![3B Bot](https://raw.githubusercontent.com/LeonShams/BestBuyBulletBot/main/docs/source/assets/demo.svg) 20 |
21 | 22 | ## Prerequisites 23 | 24 | 1. **A Best Buy account with your location and payment information already set in advance.** 25 | 26 | The only information the bot will fill out during checkout is your login credentials (email and password) and the CVV of the card used when setting up your payment information on Best Buy (PayPal is currently not supported). All other information that may be required during checkout must be filled out beforehand. 27 | 28 | 2. **Python 3.6 or newer** 29 | 30 | 3B Bot is written in Python so if it is not already installed on your computer please install it from . 31 | 32 | **On Windows make sure to tick the “Add Python to PATH” checkbox during the installation process.** On MacOS this is done automatically. 33 | 34 | Once installed, checking your Python version can be done with the following. 35 | 36 | For MacOS: 37 | 38 | ```bash 39 | python3 --version 40 | ``` 41 | 42 | For Windows: 43 | 44 | ```bash 45 | python --version 46 | ``` 47 | 48 | If your version is less than 3.6 or you get the message `python is not recognized as an internal or external command` then install python from the link above. 49 | 50 | 3. **A supported browser** 51 | 52 | 3B Bot currently only supports [Chrome](https://www.google.com/chrome/) and [Firefox](https://www.mozilla.org/en-US/firefox/new/). We recommend using the Firefox browser for it's superior performance during tracking. 53 | 54 | ## Installation 55 | 56 | Installing 3B Bot is as simple as running the following in your shell (Command Prompt for Windows and Terminal for MacOS) 57 | 58 | For MacOS: 59 | 60 | ```bash 61 | python3 -m pip install --upgrade 3b-bot 62 | ``` 63 | 64 | For Windows: 65 | 66 | ```bash 67 | pip install --upgrade 3b-bot 68 | ``` 69 | 70 | ## Usage 71 | 72 | To start the bot just enter the following in your shell 73 | 74 | ```bash 75 | 3b-bot 76 | ``` 77 | 78 | **For more usage information check out our [documentation](https://bestbuybulletbot.readthedocs.io/en/latest/).** 79 | 80 | ## How does it work? 81 | 82 | This is what 3B Bot does step by step at a high level 83 | 84 | 1. Get currently set URLs to track or prompt if none are set. 85 | 86 | 2. Using the requests library validate all URLs and get item names. 87 | 88 | 3. Open up a Google Chrome browser with selenium and perform the following. 89 | 90 | a. Navigate to the login page. 91 | 92 | b. If we have logged in previously we can use the saved cookies from the previous session to skip the log-in process. If not automatically fill out the username and password fields to log in. 93 | 94 | c. Make a get request to the Best Buy API to confirm that there are no items in the cart. 95 | 96 | d. If this is the first time using the bot check that a mailing address and payment information has been set. 97 | 98 | e. Go to each URL and collect the page cookies. This is done so that during checkout we can just apply the cookies for that URL instead of going through the entire login process. 99 | 100 | 4. Assign each URL to a core on the CPU. 101 | 102 | 5. Each core will start a specified number of threads. 103 | 104 | 6. Each thread will repeatedly check whether the "add to cart button" is available for its item. 105 | 106 | 7. When a thread notices that an item has come back in stock it will unlock its parent core and lock all other threads on every core to conserve CPU resources and WIFI. 107 | 108 | 8. The unlocked parent will print to the terminal that the item has come back in stock, play a sound, and attempt to automatically checkout the item with the following steps. 109 | 110 | a. With the driver that was used to track the item, click the add-to-cart button. 111 | 112 | b. Open up another browser window (this one is visible) and navigate to the item URL to set some cookies to login. 113 | 114 | c. Redirect to the checkout page. 115 | 116 | d. Enter the CVV for the card. 117 | 118 | e. Click "place order". 119 | 120 | 9. Once finished the parent will update its funds, the item quantity, and unlock all threads to resume stock tracking. 121 | 122 | 10. Sound will stop playing when the item is no longer in stock. 123 | 124 | ## Performance tips 125 | 126 | The following are tips to achieve the best possible performance with 3B Bot. 127 | 128 | * Use the same amount of URLs as cores on your CPU. You can create a URL group with the same URL repeated multiple times to increase the number of URLs you have and `3b-bot count-cores` can be used to see how many cores your CPU has. 129 | 130 | * Use ethernet as opposed to WIFI for a stronger more stable connection. 131 | 132 | * Adequately cool your computer to prevent thermal throttling. 133 | 134 | * Tweak the number of threads per URL. This can be changed with the `3b-bot set-threads` command. 135 | 136 | * If you plan to complete the checkout process yourself, disable auto-checkout in the settings for a significant performance improvement. 137 | 138 | Overall, item stock tracking is a CPU and internet bound task, so at the end of the day the better your CPU and the stronger your internet the faster your tracking. 139 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/data/url_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | 4 | from bs4 import BeautifulSoup 5 | from requests import Session 6 | from requests.adapters import HTTPAdapter 7 | from requests.exceptions import RequestException 8 | from requests.packages.urllib3.util.retry import Retry 9 | 10 | from best_buy_bullet_bot.browser import get_user_agent 11 | from best_buy_bullet_bot.data import SHARED_DIR 12 | from best_buy_bullet_bot.utils import ( 13 | Colors, 14 | loading, 15 | print_table, 16 | validate_num, 17 | yes_or_no, 18 | ) 19 | 20 | URL_DIR = os.path.join(SHARED_DIR, "urls.json") 21 | 22 | 23 | def _read(): 24 | with open(URL_DIR) as f: 25 | return json.load(f) 26 | 27 | 28 | def _save(data): 29 | with open(URL_DIR, "w+") as f: 30 | json.dump(data, f) 31 | 32 | 33 | if not os.path.isfile(URL_DIR): 34 | _save({}) 35 | 36 | 37 | def get_url_data(): 38 | items = _read().items() 39 | return [[item.split("\n"), qty] for item, qty in items] 40 | 41 | 42 | def view_urls(show_qty=True): 43 | data = _read().items() 44 | 45 | columns = ["URL"] + (["Quantity"] if show_qty else []) 46 | rows = [[url, qty] for url, qty in data] if show_qty else zip(list(zip(*data))[0]) 47 | print_table(columns, rows) 48 | 49 | 50 | def get_qty(): 51 | while True: 52 | qty = input("Quantity (optional): ") 53 | 54 | if qty.strip() == "" or qty == "inf": 55 | return "inf" 56 | 57 | qty = validate_num(qty, int) 58 | if qty is None or qty < 1: 59 | Colors.print( 60 | "Invalid input for quantity. Please enter an integer greater than or equal to 1.", 61 | properties=["fail"], 62 | ) 63 | else: 64 | return qty 65 | 66 | 67 | class QtyManager: 68 | def __init__(self, qty): 69 | self.qty = qty 70 | 71 | def get(self): 72 | return self.qty 73 | 74 | def decrement(self): 75 | self.qty -= 1 76 | 77 | 78 | def add_url(): 79 | new_url = input("URL to add: ") 80 | if new_url.strip() == "": 81 | print("Aborted.") 82 | return 83 | 84 | urls = _read() 85 | qty = get_qty() 86 | urls[new_url] = qty 87 | _save(urls) 88 | Colors.print( 89 | f"Successfully added {new_url}{'' if qty == 'inf' else f' with a quantity of {qty}'}!", 90 | properties=["success"], 91 | ) 92 | 93 | 94 | def add_url_group(): 95 | url_group = [] 96 | i = 1 97 | 98 | while True: 99 | new_url = input("URL to add: ") 100 | 101 | if new_url.strip() == "": 102 | if i == 1: 103 | print("Aborted.") 104 | break 105 | else: 106 | continue 107 | 108 | url_group.append(new_url) 109 | 110 | if i >= 2 and not yes_or_no("Would you like to add another URL (y/n): "): 111 | break 112 | 113 | i += 1 114 | 115 | urls = _read() 116 | qty = get_qty() 117 | urls["\n".join(url_group)] = qty 118 | _save(urls) 119 | Colors.print( 120 | f"Successfully added a URL group with {len(url_group)} URLs{'' if qty == 'inf' else f' and a quantity of {qty} for the group'}!", 121 | properties=["success"], 122 | ) 123 | 124 | 125 | def remove_url(): 126 | urls = _read() 127 | ids = range(1, len(urls) + 1) 128 | rows = list(zip(ids, urls.keys())) 129 | print_table(["ID", "Active URLs"], rows, justifications=["center", "left"]) 130 | 131 | while True: 132 | url_id = input("URL ID to remove: ").strip() 133 | 134 | if url_id == "": 135 | continue 136 | 137 | url_id = validate_num(url_id, int) 138 | if url_id is None or url_id < ids[0] or url_id > ids[-1]: 139 | Colors.print( 140 | f"Please enter valid URL ID between {ids[0]}-{ids[-1]}. Do not enter the URL itself.", 141 | properties=["fail"], 142 | ) 143 | else: 144 | break 145 | 146 | selected_url = list(urls.keys())[url_id - 1] 147 | del urls[selected_url] 148 | _save(urls) 149 | 150 | comma_separated_selection = selected_url.replace("\n", ", ") 151 | Colors.print( 152 | f"Successfully removed: {comma_separated_selection}", properties=["success"] 153 | ) 154 | 155 | 156 | def get_url_titles(): 157 | flattened_urls = [ 158 | url 159 | for url_group, _ in get_url_data() 160 | for url in (url_group if type(url_group) is list else [url_group]) 161 | ] 162 | 163 | session = Session() 164 | session.headers.update({"user-agent": get_user_agent()}) 165 | retry = Retry( 166 | connect=3, 167 | backoff_factor=1, 168 | status_forcelist=[429, 500, 502, 503, 504], 169 | method_whitelist=["HEAD", "GET", "OPTIONS"], 170 | ) 171 | adapter = HTTPAdapter(max_retries=retry) 172 | session.mount("https://", adapter) 173 | session.mount("http://", adapter) 174 | 175 | for url in flattened_urls: 176 | try: 177 | response = session.get(url, timeout=10) 178 | except RequestException as e: 179 | Colors.print(e, properties=["fail", "bold"]) 180 | continue 181 | 182 | soup = BeautifulSoup(response.text, "html.parser") 183 | 184 | raw_title = soup.find("div", class_="sku-title") 185 | if raw_title is None: 186 | Colors.print( 187 | f"Unable to find title for {url}.", properties=["fail", "bold"] 188 | ) 189 | continue 190 | title = raw_title.get_text().strip() 191 | 192 | in_stock = soup.find( 193 | "button", 194 | {"data-button-state": "ADD_TO_CART"}, 195 | ) 196 | if in_stock: 197 | Colors.warn(f"{title} is already in stock.") 198 | 199 | yield title 200 | 201 | session.close() 202 | 203 | 204 | def test_urls(): 205 | with loading("Testing URLs"): 206 | for title in get_url_titles(): 207 | Colors.print(f"Confirmed {title}!", properties=["success"]) 208 | 209 | 210 | def clear_urls(): 211 | _save({}) 212 | Colors.print("Successfully removed all URLs!", properties=["success"]) 213 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/command_line.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import warnings 3 | 4 | from best_buy_bullet_bot.utils import count_cores 5 | from best_buy_bullet_bot.version import __version__ 6 | 7 | 8 | class NoAction(argparse.Action): 9 | """Makes argument do nothing. 10 | 11 | This is useful if we want an argument to show up in the 12 | help menu, but remain uncallable. 13 | """ 14 | 15 | def __init__(self, **kwargs): 16 | kwargs.setdefault("default", argparse.SUPPRESS) 17 | kwargs.setdefault("nargs", 0) 18 | super().__init__(**kwargs) 19 | 20 | def __call__(self, *args): 21 | pass 22 | 23 | 24 | class FuncKwargs(dict): 25 | """Only passes flags to a specified function.""" 26 | 27 | def __init__(self, args): 28 | self.args = args 29 | super().__init__() 30 | 31 | def add_flag(self, flag_name, cmd_name): 32 | flag = getattr(self.args, flag_name) 33 | flag and self.args.cmd == cmd_name and self.update({flag_name: flag}) 34 | 35 | 36 | class ImportWrapper: 37 | """Only imports the function that the user selects.""" 38 | 39 | def __init__(self, file): 40 | self.file = file 41 | 42 | def __getattribute__(self, name): 43 | if name == "file": 44 | return super().__getattribute__("file") 45 | 46 | def call_func(*args, **kwargs): 47 | imported_file = __import__(self.file, fromlist=[""]) 48 | return getattr(imported_file, name)(*args, **kwargs) 49 | 50 | return call_func 51 | 52 | 53 | # This is done to prevent unnecessary imports and more importantly 54 | # prevent a bunch of warnings from setting_utils when imported 55 | tracker = ImportWrapper("best_buy_bullet_bot.tracker") 56 | setting_utils = ImportWrapper("best_buy_bullet_bot.data.setting_utils") 57 | url_utils = ImportWrapper("best_buy_bullet_bot.data.url_utils") 58 | user_data = ImportWrapper("best_buy_bullet_bot.data.user_data") 59 | browser_login = ImportWrapper("best_buy_bullet_bot.data.browser_login") 60 | 61 | OPS = { 62 | "start": [tracker.start, "Start tracking the currently set URLs."], 63 | "view-urls": [url_utils.view_urls, "View list of tracked URLs."], 64 | "add-url": [url_utils.add_url, "Add URL to tracking list."], 65 | "add-url-group": [ 66 | url_utils.add_url_group, 67 | "Add multiple URLs and set a quantity for all of them as a whole instead of individually.", 68 | ], 69 | "remove-url": [ 70 | url_utils.remove_url, 71 | "Remove a URL from the list of tracked URLs.", 72 | ], 73 | "test-urls": [ 74 | url_utils.test_urls, 75 | "Tests to make sure all URLs can be tracked. This is also run on startup.", 76 | ], 77 | "clear-urls": [url_utils.clear_urls, "Remove all tracked URLs."], 78 | "view-settings": [setting_utils.view_settings, "View current settings."], 79 | "set-funds": [ 80 | setting_utils.set_funds, 81 | "Set how much money the bot is allowed to spend.", 82 | ], 83 | "set-tax": [setting_utils.set_tax, "Set the sales tax rate for your state."], 84 | "toggle-auto-checkout": [ 85 | setting_utils.toggle_auto_checkout, 86 | "Enable/disable auto checkout.", 87 | ], 88 | "change-browser": [ 89 | setting_utils.change_browser, 90 | "Pick the browser to be used during tracking and auto-checkout (only applies if auto-checkout is enabled). \ 91 | Firefox is the default and recommended browser.", 92 | ], 93 | "test-sound": [setting_utils.test_sound, "Play sound sample."], 94 | "set-sound-mode": [ 95 | setting_utils.set_sound_mode, 96 | "Choose whether you want sound to be completely disabled, play once on item restock, or play repeatedly on item restock.", 97 | ], 98 | "set-threads": [ 99 | setting_utils.set_threads, 100 | "Select the number of threads to allocate to tracking each URL.", 101 | ], 102 | "count-cores": [ 103 | count_cores, 104 | "Print how many CPU cores you have and how many threads each core has.", 105 | ], 106 | "reset-settings": [ 107 | setting_utils.reset_settings, 108 | "Reset setting to the defaults.", 109 | ], 110 | "view-creds": [ 111 | user_data.print_creds, 112 | "View your Best Buy login credentials (email, password, cvv).", 113 | ], 114 | "set-creds": [ 115 | user_data.set_creds, 116 | "Set your Best Buy login credentials (email, password, cvv).", 117 | ], 118 | "clear-creds": [ 119 | user_data.clear_creds, 120 | "Reset your Best Buy login credentials (email, password, cvv). Also offers the option to reset your access password.", 121 | ], 122 | } 123 | 124 | 125 | def run_command(): 126 | parser = argparse.ArgumentParser( 127 | prog="3b-bot", 128 | description="Setup and control your Best Buy bot.", 129 | epilog="Good luck :)", 130 | ) 131 | 132 | parser.add_argument( 133 | "-v", 134 | "--version", 135 | action="version", 136 | version="%(prog)s " + __version__, 137 | help="show 3B Bot version number", 138 | ) 139 | 140 | parser.add_argument( 141 | "cmd", 142 | default="start", 143 | const="start", 144 | nargs="?", 145 | choices=OPS.keys(), 146 | help="Performs a specified operation.", 147 | metavar="command", 148 | type=str.lower, 149 | ) 150 | group = parser.add_argument_group(title="Available commands") 151 | 152 | for name, [func, help_msg] in OPS.items(): 153 | group.add_argument(name, help=help_msg, action=NoAction) 154 | 155 | parser.add_argument( 156 | "-w", "--suppress-warnings", action="store_true", help="suppress warnings" 157 | ) 158 | 159 | """ 160 | EASTER EGG: Thank you for reading the source code! 161 | To run the bot with a higher priority level and achieve better performance complete the following. 162 | 163 | If using Firefox, complete the following before moving on to the next step: 164 | WINDOWS: Open a Command Prompt window with "Run as administrator" https://www.educative.io/edpresso/how-to-run-cmd-as-an-administrator 165 | MAC: Enter the command `su` in your terminal to gain root privileges. Beware your settings may be different in the root session, but you can always return to a normal session with the `exit` command. 166 | 167 | Then regardless of your browser: 168 | Run `3b-bot --fast` in your shell. 169 | """ 170 | parser.add_argument("--fast", action="store_true", help=argparse.SUPPRESS) 171 | parser.add_argument( 172 | "--headless", action="store_true", help="hide the browser during auto checkout" 173 | ) 174 | parser.add_argument( 175 | "--verify-account", 176 | action="store_true", 177 | help="confirm that the account is setup properly (automatically performed on first run)", 178 | ) 179 | parser.add_argument( 180 | "--skip-verification", 181 | action="store_true", 182 | help="skip checks on first run that make sure account is setup properly.", 183 | ) 184 | parser.add_argument( 185 | "--force-login", 186 | action="store_true", 187 | help="force browser to go through traditional login process as opposed to using cookies to skip steps", 188 | ) 189 | 190 | args = parser.parse_args() 191 | func_kwargs = FuncKwargs(args) 192 | 193 | func_kwargs.add_flag("fast", "start") 194 | func_kwargs.add_flag("headless", "start") 195 | func_kwargs.add_flag("verify_account", "start") 196 | func_kwargs.add_flag("skip_verification", "start") 197 | 198 | if args.suppress_warnings: 199 | warnings.filterwarnings("ignore") 200 | else: 201 | # Just ignore the depreciation warnings 202 | warnings.filterwarnings("ignore", category=DeprecationWarning) 203 | 204 | if args.force_login: 205 | browser_login.delete_cookies() 206 | 207 | # Run command 208 | OPS[args.cmd][0](**func_kwargs) 209 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/data/setting_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os.path 4 | import shutil 5 | import sys 6 | from time import sleep 7 | 8 | from selenium.common.exceptions import WebDriverException 9 | from selenium.webdriver import Chrome, Firefox 10 | from selenium.webdriver.chrome.options import Options as ChromeOptions 11 | from selenium.webdriver.firefox.options import Options as FirefoxOptions 12 | from webdriver_manager.chrome import ChromeDriverManager 13 | from webdriver_manager.firefox import GeckoDriverManager 14 | 15 | from best_buy_bullet_bot.audio import sound_effects 16 | from best_buy_bullet_bot.data import HEADLESS_WARNED, SHARED_DIR 17 | from best_buy_bullet_bot.utils import ( 18 | Colors, 19 | loading, 20 | print_table, 21 | validate_num, 22 | warnings_suppressed, 23 | yes_or_no, 24 | ) 25 | 26 | SETTINGS_DIR = os.path.join(SHARED_DIR, "settings.json") 27 | SOUND_MODES = ["disabled", "single", "repeat"] 28 | DEFAULT_SETTINGS = { 29 | "funds": 1000, 30 | "tax": 0.095, 31 | "auto checkout": True, 32 | "account verification": True, 33 | "browser": "firefox", 34 | "sound mode": SOUND_MODES[2], 35 | "threads": 1, 36 | } 37 | 38 | 39 | def _save(data): 40 | with open(SETTINGS_DIR, "w+") as f: 41 | json.dump(data, f) 42 | 43 | 44 | # Get the current settings 45 | CURRENT_SETTINGS = DEFAULT_SETTINGS.copy() 46 | if os.path.isfile(SETTINGS_DIR): 47 | save = False 48 | 49 | with open(SETTINGS_DIR) as f: 50 | for key, value in json.load(f).items(): 51 | if key in CURRENT_SETTINGS: 52 | CURRENT_SETTINGS[key] = value 53 | elif not HEADLESS_WARNED(): 54 | Colors.warn( 55 | f"{key} is no longer supported and will be removed from your settings." 56 | ) 57 | save = True 58 | 59 | if save and not HEADLESS_WARNED(): 60 | if not warnings_suppressed() and yes_or_no( 61 | "Delete unsupported settings from your settings file (y/n): " 62 | ): 63 | _save(CURRENT_SETTINGS) 64 | HEADLESS_WARNED.update(True) 65 | else: 66 | _save(DEFAULT_SETTINGS) 67 | 68 | 69 | def get_settings(): 70 | # A copy is returned so the settings can be safely manipulated 71 | return CURRENT_SETTINGS.copy() 72 | 73 | 74 | def update_setting(setting, new_val): 75 | CURRENT_SETTINGS[setting] = new_val 76 | _save(CURRENT_SETTINGS) 77 | 78 | 79 | def view_settings(show_default=False): 80 | settings = (DEFAULT_SETTINGS if show_default else CURRENT_SETTINGS).copy() 81 | 82 | settings["funds"] = f"${settings['funds']:,.2f}" 83 | settings["tax"] = f"{settings['tax'] * 100:.2f}%" 84 | settings["browser"] = settings["browser"].title() 85 | 86 | # Hidden property 87 | del settings["account verification"] 88 | 89 | rows = [[k.title(), v] for k, v in settings.items()] 90 | print_table(["Property", "Value"], rows) 91 | 92 | 93 | def set_funds(): 94 | while True: 95 | funds = input("Allotted money: $") 96 | funds = validate_num(funds.replace("$", ""), float) 97 | if funds is None or funds < 0: 98 | Colors.print( 99 | "Invalid input for funds. Please enter a positive number.", 100 | properties=["fail"], 101 | ) 102 | else: 103 | break 104 | 105 | update_setting("funds", funds) 106 | Colors.print(f"Successfully set funds to ${funds:,.2f}!", properties=["success"]) 107 | 108 | 109 | def set_tax(): 110 | while True: 111 | tax = input("Sales tax rate (%): ") 112 | tax = validate_num(tax.replace("%", ""), float) 113 | if tax is None or tax < 0: 114 | Colors.print( 115 | "Invalid input. Please enter a positive percentage for tax.", 116 | properties=["fail"], 117 | ) 118 | else: 119 | break 120 | 121 | update_setting("tax", tax / 100) 122 | Colors.print( 123 | f"Successfully set the state sales tax rate to {tax:,.2f}%", 124 | properties=["success"], 125 | ) 126 | 127 | 128 | class MoneyManager: 129 | def get_funds(self): 130 | return CURRENT_SETTINGS["funds"] 131 | 132 | def check_funds(self, cost): 133 | return CURRENT_SETTINGS["funds"] - cost >= 0 134 | 135 | def make_purchase(self, cost): 136 | update_setting("funds", CURRENT_SETTINGS["funds"] - cost) 137 | 138 | 139 | def _toggle_setting(setting_name): 140 | update_setting(setting_name, not CURRENT_SETTINGS[setting_name]) 141 | Colors.print( 142 | f"Successfully {'enabled' if CURRENT_SETTINGS[setting_name] else 'disabled'} {setting_name}!", 143 | properties=["success"], 144 | ) 145 | 146 | 147 | def toggle_auto_checkout(): 148 | _toggle_setting("auto checkout") 149 | 150 | 151 | class DriverClassWrapper: 152 | def __init__(self, driver, manager, options): 153 | self.driver = driver 154 | self.manager = manager 155 | self.options = options 156 | 157 | 158 | logging.disable(logging.WARNING) 159 | DRIVER_NAMES = { 160 | "chrome": DriverClassWrapper(Chrome, ChromeDriverManager, ChromeOptions), 161 | "firefox": DriverClassWrapper(Firefox, GeckoDriverManager, FirefoxOptions), 162 | } 163 | 164 | 165 | def is_installed(browser_name): 166 | """Check if browser is installed 167 | 168 | Done by installing the drivers and trying to open the browser with selenium. 169 | If we can successfully open the browser then it is installed. 170 | """ 171 | browser_name = browser_name.lower() 172 | 173 | if browser_name in DRIVER_NAMES: 174 | wrap = DRIVER_NAMES[browser_name] 175 | else: 176 | raise ValueError( 177 | f"3B Bot does not support {browser_name.title()}. Please pick either Chrome or Firefox." 178 | ) 179 | 180 | # Install the drivers 181 | try: 182 | manager = wrap.manager() 183 | path = manager.install() 184 | except ValueError: 185 | return False 186 | 187 | options = wrap.options() 188 | options.add_argument("--headless") 189 | 190 | if CURRENT_SETTINGS["browser"] == "chrome": 191 | options.add_experimental_option("excludeSwitches", ["enable-logging"]) 192 | 193 | try: 194 | # Try to open a browser window 195 | driver = wrap.driver(executable_path=path, options=options) 196 | driver.quit() 197 | return True 198 | 199 | except (WebDriverException, ValueError): 200 | # Delete the drivers we just installed 201 | name = manager.driver.get_name() 202 | driver_dir = path.split(name)[0] + name 203 | if os.path.isdir(driver_dir): 204 | shutil.rmtree(driver_dir) 205 | 206 | return False 207 | 208 | 209 | def change_browser(): 210 | with loading("Detecting browsers"): 211 | available_browsers = [ 212 | browser.title() for browser in DRIVER_NAMES.keys() if is_installed(browser) 213 | ] 214 | 215 | if not len(available_browsers): 216 | Colors.print( 217 | "No available browsers. Please install either Chrome or Firefox and try again.", 218 | "Chrome can be installed from https://www.google.com/chrome/.", 219 | "Firefox can ben installed from https://www.mozilla.org/en-US/firefox/new/.", 220 | sep="\n", 221 | properties=["fail", "bold"], 222 | ) 223 | sys.exit() 224 | 225 | # Print available browsers 226 | print("\n • ".join(["Available Browsers:"] + available_browsers), "\n") 227 | 228 | while True: 229 | new_browser = input("Select a browser from the list above: ").strip().title() 230 | 231 | if new_browser == "": 232 | continue 233 | 234 | if new_browser not in available_browsers: 235 | Colors.print("Invalid selection. Try again.", properties=["fail"]) 236 | else: 237 | break 238 | 239 | update_setting("browser", new_browser.lower()) 240 | Colors.print( 241 | f"Successfully changed browser to {new_browser}!", properties=["success"] 242 | ) 243 | 244 | 245 | def test_sound(repetitions=3, print_info=True): 246 | if print_info: 247 | print("Playing sound...") 248 | sleep(0.15) 249 | 250 | for i in range(repetitions): 251 | sound_effects.play(block=True) 252 | 253 | 254 | def set_sound_mode(): 255 | while True: 256 | sound_mode = input( 257 | f"Select a sound mode ({SOUND_MODES[0]}/{SOUND_MODES[1]}/{SOUND_MODES[2]}): " 258 | ) 259 | if sound_mode not in SOUND_MODES: 260 | Colors.print( 261 | f'Invalid input for sound mode. Please enter either "{SOUND_MODES[0]}" (no sound),' 262 | f' "{SOUND_MODES[1]}" (plays sound once after coming back in stock), or "{SOUND_MODES[2]}"' 263 | " (plays sound repeatedly until item is no longer in stock).", 264 | properties=["fail"], 265 | ) 266 | else: 267 | break 268 | 269 | update_setting("sound mode", sound_mode) 270 | Colors.print( 271 | f"Successfully set sound mode to {sound_mode}!", properties=["success"] 272 | ) 273 | 274 | sleep(1) 275 | print("\nThat sounds like...") 276 | sleep(0.75) 277 | 278 | # No sound 279 | if sound_mode == SOUND_MODES[0]: 280 | sleep(0.5) 281 | print("Nothing. Crazy, right?") 282 | 283 | # Play sound once 284 | elif sound_mode == SOUND_MODES[1]: 285 | test_sound(repetitions=1, print_info=False) 286 | 287 | # Play sound on repeat 288 | elif sound_mode == SOUND_MODES[2]: 289 | test_sound(print_info=False) 290 | 291 | 292 | def set_threads(): 293 | while True: 294 | threads = input("Threads (per URL): ") 295 | threads = validate_num(threads, int) 296 | if threads is None or threads < 1: 297 | Colors.print( 298 | "Invalid number of threads. Please enter an integer greater than or equal to 1.", 299 | properties=["fail"], 300 | ) 301 | else: 302 | break 303 | 304 | update_setting("threads", threads) 305 | Colors.print( 306 | f"Now using {threads} threads to track each URL!", properties=["success"] 307 | ) 308 | 309 | 310 | def reset_settings(): 311 | print("Default settings:") 312 | view_settings(show_default=True) 313 | print() 314 | 315 | if yes_or_no("Reset (y/n): "): 316 | _save(DEFAULT_SETTINGS) 317 | Colors.print("Successfully reset settings!", properties=["success"]) 318 | else: 319 | print("Settings reset aborted.") 320 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/browser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import clipboard 5 | import requests 6 | from selenium.common.exceptions import NoSuchWindowException, TimeoutException 7 | from selenium.webdriver.common.by import By 8 | from selenium.webdriver.common.keys import Keys 9 | from selenium.webdriver.support import expected_conditions as EC 10 | from selenium.webdriver.support.ui import WebDriverWait 11 | 12 | from best_buy_bullet_bot.data.browser_login import ( 13 | cookies_available, 14 | load_cookies, 15 | save_cookies, 16 | ) 17 | from best_buy_bullet_bot.data.setting_utils import ( 18 | DRIVER_NAMES, 19 | change_browser, 20 | get_settings, 21 | is_installed, 22 | update_setting, 23 | ) 24 | from best_buy_bullet_bot.utils import Colors, loading 25 | 26 | SETTINGS = get_settings() 27 | TAX = SETTINGS["tax"] 28 | BROWSER_NAME = SETTINGS["browser"] 29 | DRIVER_WRAPPER = DRIVER_NAMES[BROWSER_NAME] 30 | MAC = sys.platform == "darwin" 31 | USER_TAKEOVER = 20 * 60 # 20 min for user to takeover if the bot gets stuck 32 | 33 | logging.disable(logging.WARNING) 34 | try: 35 | DRIVER_PATH = DRIVER_WRAPPER.manager().install() 36 | except ValueError: 37 | if not is_installed(SETTINGS["browser"]): 38 | Colors.print( 39 | f"{SETTINGS['browser'].title()} is not installed on your computer.", 40 | properties=["fail"], 41 | ) 42 | change_browser() 43 | 44 | 45 | def _get_options(headless): 46 | options = DRIVER_WRAPPER.options() 47 | options.page_load_strategy = "none" 48 | options.add_argument("--proxy-server='direct://'") 49 | options.add_argument("--proxy-bypass-list=*") 50 | 51 | if headless: 52 | options.add_argument("--headless") 53 | 54 | # Suppress "DevTools listening on ws:..." message 55 | if BROWSER_NAME == "chrome": 56 | options.add_experimental_option("excludeSwitches", ["enable-logging"]) 57 | 58 | return options 59 | 60 | 61 | PREBUILT_OPTIONS = [_get_options(False), _get_options(True)] 62 | 63 | 64 | def get_user_agent(): 65 | driver = DRIVER_WRAPPER.driver( 66 | executable_path=DRIVER_PATH, options=PREBUILT_OPTIONS[True] 67 | ) 68 | user_agent = driver.execute_script("return navigator.userAgent") 69 | driver.quit() 70 | return user_agent 71 | 72 | 73 | def money2float(money): 74 | return float(money[1:].replace(",", "")) 75 | 76 | 77 | def fast_text(text): 78 | clipboard.copy(text) 79 | return (Keys.COMMAND if MAC else Keys.CONTROL) + "v" 80 | 81 | 82 | account_page_url = "https://www.bestbuy.com/site/customer/myaccount" 83 | billing_url = "https://www.bestbuy.com/profile/c/billinginfo/cc" 84 | 85 | 86 | def terminate(driver): 87 | driver.quit() 88 | sys.exit(1) 89 | 90 | 91 | def _login(driver, wait, headless, email, password, cookies_set): 92 | branch = wait.until( 93 | EC.presence_of_element_located( 94 | (By.CSS_SELECTOR, "#ca-remember-me, .shop-search-bar") 95 | ) 96 | ) 97 | 98 | if branch.get_attribute("class") == "shop-search-bar": 99 | # We are already logged in 100 | return 101 | 102 | # Click "Keep me signed in" button 103 | branch.click() 104 | 105 | if not cookies_set: 106 | # Fill in email box 107 | driver.find_element_by_id("fld-e").send_keys(fast_text(email)) 108 | 109 | # Fill in password box 110 | driver.find_element_by_id("fld-p1").send_keys(fast_text(password)) 111 | 112 | # Click the submit button 113 | driver.find_element_by_css_selector( 114 | ".btn.btn-secondary.btn-lg.btn-block.c-button-icon.c-button-icon-leading.cia-form__controls__submit" 115 | ).click() 116 | 117 | # Check for error or redirect 118 | branch = wait.until( 119 | EC.presence_of_element_located( 120 | ( 121 | By.CSS_SELECTOR, 122 | ".shop-search-bar, " # We got redirected to the account page 123 | ".cia-cancel, " # Skippable verification page 124 | ".c-alert.c-alert-level-error, " # Error popup message 125 | "#fld-e-text, " # Invalid email address 126 | "#fld-p1-text", # Invalid password 127 | ) 128 | ) 129 | ) 130 | 131 | # If we hit an error 132 | if branch.get_attribute( 133 | "class" 134 | ) == "c-alert c-alert-level-error" or branch.get_attribute("id") in [ 135 | "fld-e-text", 136 | "fld-p1-text", 137 | ]: 138 | if headless: 139 | # If headless raise error 140 | Colors.print( 141 | "Incorrect login info. Please correct the username or password.", 142 | properties=["fail", "bold"], 143 | ) 144 | terminate(driver) 145 | else: 146 | # If headful ask the user to take over 147 | Colors.print( 148 | "Unable to login automatically. Please correct your credentials or enter the information manually.", 149 | properties=["fail"], 150 | ) 151 | branch = wait.until( 152 | EC.presence_of_element_located( 153 | (By.CSS_SELECTOR, ".shop-search-bar, .cia-cancel") 154 | ) 155 | ) 156 | 157 | # If we hit a skippable verification page 158 | if "cia-cancel" in branch.get_attribute("class"): 159 | # Redirect to the "my account" page 160 | driver.get(account_page_url) 161 | wait.until(EC.presence_of_element_located((By.CLASS_NAME, "shop-search-bar"))) 162 | 163 | save_cookies(driver) 164 | 165 | 166 | def check_cart(driver): 167 | with loading("Confirming cart is empty"): 168 | # Confirm that the cart is empty 169 | headers = { 170 | "Host": "www.bestbuy.com", 171 | "User-Agent": driver.execute_script("return navigator.userAgent"), 172 | "Accept": "*/*", 173 | "Accept-Language": "en-US,en;q=0.5", 174 | "Accept-Encoding": "gzip, deflate, br", 175 | "Referer": "https://www.bestbuy.com/site/customer/myaccount", 176 | "X-CLIENT-ID": "browse", 177 | "X-REQUEST-ID": "global-header-cart-count", 178 | "Connection": "keep-alive", 179 | "Cookie": driver.execute_script("return document.cookie"), 180 | "Sec-Fetch-Dest": "empty", 181 | "Sec-Fetch-Mode": "cors", 182 | "Sec-Fetch-Site": "same-origin", 183 | "Cache-Control": "max-age=0", 184 | } 185 | 186 | # Make a get request to the Best Buy API to get the number of items in the cart 187 | response = requests.get( 188 | "https://www.bestbuy.com/basket/v1/basketCount", headers=headers 189 | ) 190 | items = response.json()["count"] 191 | 192 | if items != 0: 193 | Colors.print( 194 | "Too many items in the cart. Please empty your cart before starting the bot.", 195 | properties=["fail", "bold"], 196 | ) 197 | terminate(driver) 198 | 199 | 200 | def perform_account_verification(driver, wait): 201 | with loading("Verifying account setup"): 202 | # Check that a shipping address has been set 203 | shipping_address = wait.until( 204 | EC.presence_of_element_located( 205 | ( 206 | By.CSS_SELECTOR, 207 | "div.account-setting-block-container:nth-child(1) > div:nth-child(2) > a:last-child", 208 | ) 209 | ) 210 | ) 211 | if shipping_address.get_attribute("class") == "": 212 | Colors.print( 213 | "Shipping address has not been set. You can add a shipping address to \ 214 | your account at https://www.bestbuy.com/profile/c/address/shipping/add.", 215 | properties=["fail", "bold"], 216 | ) 217 | terminate(driver) 218 | 219 | # Confirm that a default payment method has been created 220 | driver.get(billing_url) 221 | payment_method_list = wait.until( 222 | EC.presence_of_element_located( 223 | ( 224 | By.CSS_SELECTOR, 225 | ".pf-credit-card-list__content-spacer > ul.pf-credit-card-list__credit-card-list", 226 | ) 227 | ) 228 | ) 229 | 230 | if payment_method_list.size["height"] == 0: 231 | Colors.print( 232 | f"A default payment method has not been created. Please create one at {billing_url}.", 233 | properties=["fail", "bold"], 234 | ) 235 | terminate(driver) 236 | 237 | Colors.print("Account has passed all checks!", properties=["success"]) 238 | 239 | 240 | def collect_item_cookies(driver, wait, urls): 241 | login_cookies_list = [] 242 | predicted_prices = [] 243 | price_element = None 244 | 245 | with loading("Collecting cookies for each URL"): 246 | for url in urls: 247 | driver.get(url) 248 | 249 | if price_element is not None: 250 | wait.until(EC.staleness_of(price_element)) 251 | 252 | price_element = wait.until( 253 | EC.presence_of_element_located( 254 | ( 255 | By.CSS_SELECTOR, 256 | ".pricing-price > div > div > div > .priceView-hero-price.priceView-customer-price, " 257 | ".pricing-price > div > div > div > div > section > div > div > .priceView-hero-price.priceView-customer-price", 258 | ) 259 | ) 260 | ) 261 | item_price = price_element.text.split("\n")[0] 262 | 263 | predicted_prices.append(money2float(item_price) * (1 + TAX)) 264 | login_cookies_list.append(driver.get_cookies()) 265 | 266 | return login_cookies_list, predicted_prices 267 | 268 | 269 | def _browser_startup( 270 | driver, headless, email, password, urls, verify_account, skip_verification 271 | ): 272 | wait = WebDriverWait(driver, USER_TAKEOVER) 273 | 274 | with loading("Logging in"): 275 | driver.get(account_page_url) 276 | # We will then get redirected to the sign in page 277 | 278 | # If we have logged in previously we can use the cookies from that session 279 | # to skip steps in the login process and prevent the system from detecting 280 | # a bunch of logins from the same account 281 | cookies_exist = cookies_available() 282 | if cookies_exist: 283 | if load_cookies(driver): 284 | driver.refresh() 285 | else: 286 | # An error occurred while adding the login cookies 287 | cookies_exist = False 288 | 289 | _login(driver, wait, headless, email, password, cookies_exist) 290 | 291 | check_cart(driver) 292 | 293 | if not skip_verification: 294 | if SETTINGS["account verification"] or verify_account: 295 | perform_account_verification(driver, wait) 296 | if not verify_account: 297 | print("This was a one time test and will not be performed again.\n") 298 | update_setting("account verification", False) 299 | 300 | item_cookies = collect_item_cookies(driver, wait, urls) 301 | 302 | driver.quit() 303 | return item_cookies 304 | 305 | 306 | def browser_startup(headless, *args, **kwargs): 307 | driver = DRIVER_WRAPPER.driver( 308 | executable_path=DRIVER_PATH, options=PREBUILT_OPTIONS[headless] 309 | ) 310 | 311 | try: 312 | return _browser_startup(driver, headless, *args, **kwargs) 313 | 314 | # Timed out while trying to locate element 315 | except TimeoutException: 316 | Colors.print( 317 | "Browser window has timed out. Closing bot.", properties=["fail", "bold"] 318 | ) 319 | terminate(driver) 320 | 321 | # User has closed the browser window 322 | except NoSuchWindowException: 323 | terminate(driver) 324 | 325 | 326 | def _purchase( 327 | driver, 328 | title, 329 | password, 330 | cvv, 331 | money_manager, 332 | ): 333 | # Go to the checkout page 334 | driver.get("https://www.bestbuy.com/checkout/r/fast-track") 335 | wait = WebDriverWait(driver, USER_TAKEOVER) 336 | 337 | # Get to the CVV page 338 | while True: 339 | branch = wait.until( 340 | EC.element_to_be_clickable( 341 | ( 342 | By.CSS_SELECTOR, 343 | "#credit-card-cvv, " # Place order page 344 | ".button--continue > button.btn.btn-lg.btn-block.btn-secondary, " # Continue to payment info page 345 | ".checkout-buttons__checkout > .btn.btn-lg.btn-block.btn-primary", # We got redirected to the cart 346 | ) 347 | ) 348 | ) 349 | 350 | # If we got redirected to the cart 351 | if branch.get_attribute("class") == "btn btn-lg btn-block btn-primary": 352 | # Click "proceed to checkout" button 353 | branch.click() 354 | branch = wait.until( 355 | EC.element_to_be_clickable( 356 | ( 357 | By.CSS_SELECTOR, 358 | "#credit-card-cvv, " # Place order page 359 | "#cvv, " # Review and place order page 360 | ".button--continue > button.btn.btn-lg.btn-block.btn-secondary, " # Continue to place order page (page before place order page) 361 | "#fld-p1", # Sign in (only requires password) 362 | ) 363 | ) 364 | ) 365 | 366 | # If it wants to confirm our password 367 | if branch.get_attribute("class").strip() == "tb-input": 368 | branch.send_keys(fast_text(password)) 369 | driver.find_element_by_css_selector( 370 | ".btn.btn-secondary.btn-lg.btn-block.c-button-icon.c-button-icon-leading.cia-form__controls__submit" 371 | ).click() # Click sign in button 372 | 373 | # We will loop back around and handle what comes next 374 | else: 375 | break 376 | else: 377 | break 378 | 379 | # Select the CVV text box 380 | if branch.get_attribute("class") == "btn btn-lg btn-block btn-secondary": 381 | branch.click() 382 | cvv_box = wait.until( 383 | EC.element_to_be_clickable( 384 | ( 385 | By.CSS_SELECTOR, 386 | "#credit-card-cvv, #cvv", 387 | ) 388 | ) 389 | ) 390 | else: 391 | cvv_box = branch 392 | cvv_box.send_keys(fast_text(cvv)) 393 | 394 | # Locate and parse the grand total text 395 | grand_total = money2float( 396 | driver.find_element_by_css_selector( 397 | ".order-summary__total > .order-summary__price > .cash-money" 398 | ).text 399 | ) 400 | 401 | # Make sure we have sufficient funds for the purchase 402 | if money_manager.check_funds(grand_total): 403 | # Click place order button 404 | driver.find_element_by_css_selector( 405 | ".btn.btn-lg.btn-block.btn-primary, .btn.btn-lg.btn-block.btn-primary.button__fast-track" 406 | ).click() 407 | 408 | # Deduct grand total from available funds 409 | money_manager.make_purchase(grand_total) 410 | 411 | Colors.print( 412 | f"Successfully purchased {title}. The item was a grand total of ${grand_total:,.2f} leaving you with ${money_manager.get_funds():,.2f} of available funds.", 413 | properties=["success", "bold"], 414 | ) 415 | return True 416 | else: 417 | Colors.print( 418 | f"Insufficient funds to purchase {title} which costs a grand total of ${grand_total:,.2f} while you only have ${money_manager.get_funds():,.2f} of available funds.", 419 | properties=["fail"], 420 | ) 421 | return False 422 | 423 | 424 | def purchase( 425 | url, login_cookies, headless, headless_driver, headless_wait, *args, **kwargs 426 | ): 427 | if not headless: 428 | # Create a new visible driver for the checkout process 429 | driver = DRIVER_WRAPPER.driver( 430 | executable_path=DRIVER_PATH, options=PREBUILT_OPTIONS[False] 431 | ) 432 | driver.get(url) 433 | for cookie in login_cookies: 434 | driver.add_cookie(cookie) 435 | else: 436 | # Use the old headless driver so we don't have to create a new one 437 | driver = headless_driver 438 | 439 | # Have the existing headless tracker driver click the add-to-cart button 440 | headless_wait.until( 441 | EC.element_to_be_clickable( 442 | ( 443 | By.CSS_SELECTOR, 444 | ".fulfillment-add-to-cart-button > div > div > button", 445 | ) 446 | ) 447 | ).click() 448 | 449 | try: 450 | return _purchase(driver, *args, **kwargs) 451 | except TimeoutException: 452 | Colors.print( 453 | "3B Bot got stuck and nobody took over. Tracking will resume.", 454 | properties=["fail"], 455 | ) 456 | except NoSuchWindowException: 457 | driver.quit() 458 | 459 | return False 460 | -------------------------------------------------------------------------------- /best_buy_bullet_bot/tracker/__init__.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import os 3 | import signal 4 | import sys 5 | import time 6 | from multiprocessing import Pool 7 | from multiprocessing.managers import BaseManager 8 | from multiprocessing.pool import ThreadPool 9 | from threading import Event, Lock 10 | 11 | import psutil 12 | from bs4 import BeautifulSoup 13 | from requests import Session 14 | from requests.adapters import HTTPAdapter 15 | from requests.exceptions import RequestException 16 | from requests.packages.urllib3.util.retry import Retry 17 | from selenium.common.exceptions import TimeoutException 18 | from selenium.webdriver.common.by import By 19 | from selenium.webdriver.support import expected_conditions as EC 20 | from selenium.webdriver.support.ui import WebDriverWait 21 | 22 | from best_buy_bullet_bot.audio import sound_effects 23 | from best_buy_bullet_bot.browser import purchase 24 | from best_buy_bullet_bot.data import user_data 25 | from best_buy_bullet_bot.data.setting_utils import ( 26 | DRIVER_NAMES, 27 | SOUND_MODES, 28 | MoneyManager, 29 | get_settings, 30 | ) 31 | from best_buy_bullet_bot.data.url_utils import QtyManager 32 | from best_buy_bullet_bot.tracker.progress_bar import IndefeniteProgressBar 33 | from best_buy_bullet_bot.utils import Colors 34 | 35 | WINDOWS = sys.platform == "win32" 36 | SETTINGS = get_settings() 37 | SOUND_MODE = SETTINGS["sound mode"] 38 | AUTO_CHECKOUT = SETTINGS["auto checkout"] 39 | BROWSER_NAME = SETTINGS["browser"] 40 | DRIVER_WRAPPER = DRIVER_NAMES[BROWSER_NAME] 41 | NUM_THREADS = SETTINGS["threads"] 42 | 43 | 44 | class TwoWayPause: 45 | def __init__(self): 46 | self.play = Event() 47 | self.play.set() 48 | self.pause = Event() 49 | 50 | def is_set(self): 51 | return self.pause.is_set() 52 | 53 | def set(self): 54 | self.play.clear() 55 | self.pause.set() 56 | 57 | def clear(self): 58 | self.pause.clear() 59 | self.play.set() 60 | 61 | def wait(self): 62 | self.pause.wait() 63 | 64 | def wait_inverse(self): 65 | self.play.wait() 66 | 67 | 68 | # The following two classes are needed to use magic methods with `BaseManager` 69 | class NoMagicLock: 70 | def __init__(self): 71 | self.lock = Lock() 72 | 73 | def enter(self, *args, **kwargs): 74 | self.lock.__enter__(*args, **kwargs) 75 | 76 | def exit(self, *args, **kwargs): 77 | self.lock.__exit__(*args, **kwargs) 78 | 79 | 80 | class NormalLock: 81 | def __init__(self, no_magic_lock): 82 | self.lock = no_magic_lock 83 | 84 | def __enter__(self, *args, **kwargs): 85 | self.lock.enter(*args, **kwargs) 86 | 87 | def __exit__(self, *args, **kwargs): 88 | self.lock.exit(*args, **kwargs) 89 | 90 | 91 | BaseManager.register("ThreadLock", NoMagicLock) 92 | BaseManager.register("QtyManager", QtyManager) 93 | BaseManager.register("IndefeniteProgressBar", IndefeniteProgressBar) 94 | BaseManager.register("PauseEvent", TwoWayPause) 95 | BaseManager.register("MoneyManager", MoneyManager) 96 | 97 | 98 | STOCK = False 99 | 100 | 101 | def track( 102 | title, 103 | url, 104 | qty, 105 | headless, 106 | login_cookies, 107 | password, 108 | cvv, 109 | thread_lock, 110 | paused, 111 | pbar, 112 | pred_price, 113 | money_manager, 114 | headers, 115 | ): 116 | builtins.print = pbar.print 117 | if not AUTO_CHECKOUT: 118 | headers["referer"] = url 119 | 120 | thread_lock = NormalLock(thread_lock) 121 | 122 | with ThreadPool(NUM_THREADS) as pool: 123 | pool.starmap_async( 124 | run, 125 | [ 126 | [ 127 | title, 128 | url, 129 | qty, 130 | login_cookies, 131 | thread_lock, 132 | paused, 133 | pbar, 134 | pred_price, 135 | money_manager, 136 | headers, 137 | ] 138 | for _ in range(NUM_THREADS) 139 | ], 140 | ) 141 | 142 | await_checkout( 143 | title, 144 | url, 145 | qty, 146 | headless, 147 | login_cookies, 148 | password, 149 | cvv, 150 | paused, 151 | pred_price, 152 | money_manager, 153 | ) 154 | 155 | 156 | def await_checkout( 157 | title, 158 | url, 159 | qty, 160 | headless, 161 | login_cookies, 162 | password, 163 | cvv, 164 | paused, 165 | pred_price, 166 | money_manager, 167 | ): 168 | global STOCK 169 | 170 | while True: 171 | paused.wait() 172 | 173 | if STOCK: 174 | if not money_manager.check_funds(pred_price) or not qty.get(): 175 | paused.clear() 176 | 177 | if SOUND_MODE == SOUND_MODES[2]: 178 | sound_effects.stop() 179 | 180 | Colors.print( 181 | f'All requested "{title}" were purchased.' 182 | if money_manager.check_funds(pred_price) 183 | else f"With only ${money_manager.get_funds():,.2f} you cannot afford {title}.", 184 | "It will no longer be tracked to conserve resources.\n", 185 | properties=["warning"], 186 | ) 187 | return 188 | 189 | current_time = time.strftime("%H:%M:%S", time.localtime()) 190 | Colors.print( 191 | f'\n{current_time} - "{title}" - {url}\n', 192 | properties=["bold"], 193 | ) 194 | 195 | # Plays a sound 196 | if SOUND_MODE == SOUND_MODES[1]: 197 | sound_effects.play() 198 | elif SOUND_MODE == SOUND_MODES[2]: 199 | sound_effects.start() 200 | 201 | if AUTO_CHECKOUT: 202 | try: 203 | while money_manager.check_funds(pred_price) and qty.get(): 204 | if purchase( 205 | url, 206 | login_cookies, 207 | headless, 208 | *STOCK, # `headless_driver` and `headless_wait` 209 | title, 210 | password, 211 | cvv, 212 | money_manager, 213 | ): 214 | qty.decrement() 215 | else: 216 | break 217 | except Exception as e: 218 | Colors.print( 219 | f"CHECKOUT ERROR: {e}", 220 | ) 221 | 222 | STOCK = False 223 | paused.clear() 224 | else: 225 | paused.wait_inverse() 226 | 227 | 228 | def run( 229 | title, 230 | url, 231 | qty, 232 | login_cookies, 233 | thread_lock, 234 | paused, 235 | pbar, 236 | pred_price, 237 | money_manager, 238 | headers, 239 | ): 240 | global STOCK 241 | 242 | stop_tracker = False 243 | 244 | if AUTO_CHECKOUT: 245 | options = DRIVER_WRAPPER.options() 246 | options.page_load_strategy = "none" 247 | options.add_argument("--proxy-server='direct://'") 248 | options.add_argument("--proxy-bypass-list=*") 249 | options.add_argument("--headless") 250 | 251 | # Suppress "DevTools listening on ws:..." message 252 | if BROWSER_NAME == "chrome": 253 | options.add_experimental_option("excludeSwitches", ["enable-logging"]) 254 | 255 | # Create the browser window 256 | driver = DRIVER_WRAPPER.driver( 257 | executable_path=DRIVER_WRAPPER.manager().install(), options=options 258 | ) 259 | 260 | # Login to the browser by setting the cookies 261 | driver.get(url) 262 | for cookie in login_cookies: 263 | driver.add_cookie(cookie) 264 | 265 | wait = WebDriverWait(driver, 120) 266 | button_locator = EC.presence_of_element_located( 267 | ( 268 | By.CSS_SELECTOR, 269 | ".fulfillment-add-to-cart-button > div > div > button", 270 | ) 271 | ) 272 | 273 | # Confirm that we have a stable connection and that Best Buy hasn't made any 274 | # changes to their website that would break out locator 275 | try: 276 | btn = wait.until(button_locator) 277 | except TimeoutException: 278 | Colors.print( 279 | f"Unable to connect to {title}. Closing tracker.", 280 | properties=["fail"], 281 | ) 282 | stop_tracker = True 283 | else: 284 | session = Session() 285 | session.headers.update(headers) 286 | retry = Retry( 287 | connect=3, 288 | backoff_factor=0.25, 289 | status_forcelist=[429, 500, 502, 503, 504], 290 | method_whitelist=["HEAD", "GET", "OPTIONS"], 291 | ) 292 | adapter = HTTPAdapter(max_retries=retry) 293 | session.mount("https://", adapter) 294 | session.mount("http://", adapter) 295 | 296 | connection_status = True 297 | available = False 298 | prev_available = False 299 | 300 | # Track item so long as we have sufficient funds and haven't bought the item too many times 301 | while not stop_tracker and ( 302 | not AUTO_CHECKOUT or (money_manager.check_funds(pred_price) and qty.get()) 303 | ): 304 | # Stop trackers to conserve resources during the auto checkout process 305 | if paused.is_set(): 306 | paused.wait_inverse() 307 | continue 308 | 309 | if AUTO_CHECKOUT: 310 | driver.get(url) 311 | 312 | try: 313 | # Wait until old page has unloaded 314 | wait.until(EC.staleness_of(btn)) 315 | 316 | if paused.is_set(): 317 | # Stop page load (page will reload when tracker restarts) 318 | driver.execute_script("window.stop();") 319 | continue 320 | 321 | # Wait until the add-to-cart button is present on the new page 322 | btn = wait.until(button_locator) 323 | 324 | # Inform the user if an error occurs while trying to locate the add-to-cart button 325 | except TimeoutException: 326 | if connection_status: 327 | start_time = time.time() 328 | Colors.print( 329 | f"{title} tracker has lost connection.\n", 330 | properties=["fail"], 331 | ) 332 | connection_status = False 333 | continue 334 | 335 | # Check if it is an add-to-cart button 336 | available = btn.get_attribute("data-button-state") == "ADD_TO_CART" 337 | 338 | else: 339 | try: 340 | # Make a get request 341 | response = session.get(url, timeout=10) 342 | response.raise_for_status() 343 | 344 | except RequestException as e: 345 | # Inform the user if an error occurs while trying to make a get request 346 | if connection_status: 347 | start_time = time.time() 348 | Colors.print( 349 | f"Unable to establish a connection to {title} remote endpoint.", 350 | properties=["fail"], 351 | ) 352 | print(e, "\n") 353 | connection_status = False 354 | continue 355 | 356 | if paused.is_set(): 357 | continue 358 | 359 | # Look for add-to-cart button 360 | soup = BeautifulSoup(response.text, "html.parser") 361 | available = ( 362 | soup.find( 363 | "button", 364 | {"data-button-state": "ADD_TO_CART"}, 365 | ) 366 | is not None 367 | ) 368 | 369 | pbar.update() 370 | 371 | # If we reconnected, inform the user 372 | if not connection_status: 373 | Colors.print( 374 | f"{title} tracker has successfully reconnected!", 375 | properties=["success"], 376 | ) 377 | print(f"Downtime: {time.time()-start_time:.2f} seconds \n") 378 | connection_status = True 379 | 380 | # If the item is in stock 381 | if available: 382 | # Unlock the checkout process if it hasn't been already 383 | with thread_lock: 384 | if paused.is_set(): 385 | continue 386 | 387 | if AUTO_CHECKOUT: 388 | if not STOCK: 389 | STOCK = (driver, wait) 390 | else: 391 | STOCK = True 392 | paused.set() 393 | 394 | # If item went back to being out of stock 395 | elif prev_available != available: 396 | if SOUND_MODE == SOUND_MODES[2]: 397 | sound_effects.stop() 398 | 399 | prev_available = available 400 | 401 | # Stop the auto checkout function 402 | if STOCK is not True: 403 | STOCK = True 404 | paused.set() 405 | 406 | if AUTO_CHECKOUT: 407 | driver.close() 408 | else: 409 | session.close() 410 | 411 | 412 | def set_priority(high_priority): 413 | p = psutil.Process(os.getpid()) 414 | 415 | """ 416 | EASTER EGG: Thank you for reading the source code! 417 | To run the bot with a higher priority level and achieve better performance complete the following. 418 | 419 | If using Firefox, complete the following before moving on to the next step: 420 | WINDOWS: Open a Command Prompt window with "Run as administrator" https://www.educative.io/edpresso/how-to-run-cmd-as-an-administrator 421 | MAC: Enter the command `su` in your terminal to gain root privileges. Beware your settings may be different in the root session, but you can always return to a normal session with the `exit` command. 422 | 423 | Then regardless of your browser: 424 | Run `3b-bot --fast` in your shell. 425 | """ 426 | # Windows: REALTIME_PRIORITY_CLASS, HIGH_PRIORITY_CLASS, ABOVE_NORMAL_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, IDLE_PRIORITY_CLASS 427 | # MacOS: -20 is highest priority while 20 is lowest priority 428 | # Lower priorities are used here so other things can still be done on the computer while the bot is running 429 | priority = ( 430 | (psutil.HIGH_PRIORITY_CLASS if WINDOWS else -10) 431 | if high_priority 432 | else (psutil.BELOW_NORMAL_PRIORITY_CLASS if WINDOWS else 10) 433 | ) 434 | p.nice(priority) 435 | 436 | 437 | def start(fast=False, headless=False, verify_account=False, skip_verification=False): 438 | from elevate import elevate 439 | 440 | from best_buy_bullet_bot.browser import browser_startup, get_user_agent 441 | from best_buy_bullet_bot.data import close_data, url_utils 442 | from best_buy_bullet_bot.utils import loading, warnings_suppressed, yes_or_no 443 | 444 | """ 445 | EASTER EGG: Thank you for reading the source code! 446 | To run the bot with a higher priority level and achieve better performance complete the following. 447 | 448 | If using Firefox, complete the following before moving on to the next step: 449 | WINDOWS: Open a Command Prompt window with "Run as administrator" https://www.educative.io/edpresso/how-to-run-cmd-as-an-administrator 450 | MAC: Enter the command `su` in your terminal to gain root privileges. Beware your settings may be different in the root session, but you can always return to a normal session with the `exit` command. 451 | 452 | Then regardless of your browser: 453 | Run `3b-bot --fast` in your shell. 454 | """ 455 | 456 | # If we don't have admin privileges try to elevate permissions 457 | if fast and hasattr(os, "getuid") and os.getuid() != 0: 458 | print("Elevating permissions to run in fast mode.") 459 | elevate(graphical=False) 460 | 461 | def kill_all(*args, **kwargs): 462 | if not WINDOWS: 463 | # Delete the temp file 464 | close_data() 465 | 466 | # Forcefully close everything 467 | os.system( 468 | f"taskkill /F /im {psutil.Process(os.getpid()).name()}" 469 | ) if WINDOWS else os.killpg(os.getpgid(os.getpid()), signal.SIGKILL) 470 | 471 | def clean_kill(*args, **kwargs): 472 | # Suppress error messages created as a result of termination 473 | # Selenium will often print long error messages during termination 474 | sys.stderr = open(os.devnull, "w") 475 | 476 | # Close the pbar and kill everything if the process pool has been created 477 | if "pbar" in locals(): 478 | pbar.close() 479 | print() 480 | kill_all() # Inelegant but fast 481 | 482 | # Otherwise we can exit the traditional way 483 | else: 484 | print() 485 | sys.exit(0) 486 | 487 | # Use custom functions to exit properly 488 | for sig in [signal.SIGINT, signal.SIGBREAK if WINDOWS else signal.SIGQUIT]: 489 | signal.signal(sig, clean_kill) 490 | signal.signal(signal.SIGTERM, kill_all) 491 | 492 | print( 493 | """ 494 | .d8888b. 888888b. 888888b. 888 495 | d88P Y88b 888 "88b 888 "88b 888 496 | .d88P 888 .88P 888 .88P 888 497 | 8888" 8888888K. 8888888K. .d88b. 888888 498 | "Y8b. 888 "Y88b 888 "Y88b d88""88b 888 499 | 888 888 888 888 888 888 888 888 888 500 | Y88b d88P 888 d88P 888 d88P Y88..88P Y88b. 501 | "Y8888P" 8888888P" 8888888P" "Y88P" "Y888 502 | 503 | """ 504 | ) 505 | 506 | # Check if a flag has been passed to suppress warnings 507 | suppress_warnings = warnings_suppressed() 508 | 509 | raw_urls = url_utils.get_url_data() 510 | if not len(raw_urls): 511 | print() 512 | Colors.warn("No URLs have been set to be tracked.") 513 | if not suppress_warnings: 514 | if yes_or_no("Would you like to set some URLs for tracking (y/n): "): 515 | while True: 516 | url_utils.add_url() 517 | if not yes_or_no("Do you want to add another url (y/n): "): 518 | break 519 | raw_urls = url_utils.get_url_data() 520 | 521 | if not len(raw_urls): 522 | Colors.print( 523 | "Not enough URLs for tracking.", 524 | "Please add at least 1 URL.", 525 | "URLs can be added with `3b-bot add-url`.", 526 | properties=["fail", "bold"], 527 | ) 528 | sys.exit(1) 529 | 530 | print("Tracking the following URLs.") 531 | url_utils.view_urls(AUTO_CHECKOUT) 532 | 533 | manager = BaseManager() 534 | manager.start() 535 | 536 | money_manager = manager.MoneyManager() 537 | if AUTO_CHECKOUT: 538 | print(f"Current funds: ${money_manager.get_funds():,.2f}") 539 | print() 540 | 541 | # Get URLs and a quantity object for each URL 542 | urls, qtys = [], [] 543 | for url_group, raw_qty in raw_urls: 544 | int_qty = -1 if raw_qty == "inf" else raw_qty 545 | 546 | # Create a shared qty manager between URLs for URL groups 547 | qty = manager.QtyManager(int_qty) if len(url_group) > 1 else QtyManager(int_qty) 548 | 549 | urls += url_group 550 | qtys += [qty] * len(url_group) 551 | 552 | with loading("Checking URLs"): 553 | titles = list(url_utils.get_url_titles()) 554 | 555 | if len(titles) < len(urls): 556 | sys.exit(1) 557 | elif len(titles) > len(urls): 558 | Colors.print( 559 | "Something went wrong!", 560 | "Please report the issue to https://github.com/LeonShams/BestBuyBulletBot/issues.", 561 | "Feel free to copy and paste the following when opening an issue.", 562 | properties=["fail", "bold"], 563 | ) 564 | print( 565 | "ERROR ENCOUNTERED DURING EXECUTION: More titles than URLs!", 566 | f"Raw URLs: {raw_urls}", 567 | f"URLs: {urls}", 568 | f"Titles: {titles}", 569 | sep="\n", 570 | ) 571 | sys.exit(1) 572 | 573 | if AUTO_CHECKOUT: 574 | email, password, cvv = user_data.get_creds() 575 | if not (email or password or cvv): 576 | Colors.warn( 577 | "\nCheckout credentials have not been set. Run `3b-bot set-creds` to add the necessary information." 578 | ) 579 | 580 | if not suppress_warnings: 581 | if yes_or_no("Would you like to set your checkout credentials (y/n): "): 582 | user_data.set_creds() 583 | email, password, cvv = user_data.get_creds() 584 | 585 | print() 586 | login_cookies_list, predicted_prices = browser_startup( 587 | headless, email, password, urls, verify_account, skip_verification 588 | ) 589 | headers = {} 590 | else: 591 | email, password, cvv = "", "", "" 592 | login_cookies_list, predicted_prices = ((None for _ in urls) for i in range(2)) 593 | 594 | headers = { 595 | "accept": "*/*", 596 | "accept-encoding": "gzip, deflate, br", 597 | "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", 598 | "sec-ch-ua-mobile": "?0", 599 | "sec-fetch-dest": "script", 600 | "sec-fetch-mode": "no-cors", 601 | "sec-fetch-site": "same-origin", 602 | "user-agent": get_user_agent(), 603 | } 604 | 605 | Colors.print("Availability tracking has started!", properties=["success"]) 606 | if fast: 607 | Colors.print("Fast tracking enabled!", properties=["blue"]) 608 | print() 609 | 610 | # Create remaining shared objects 611 | thread_lock = manager.ThreadLock() 612 | paused = manager.PauseEvent() 613 | pbar = manager.IndefeniteProgressBar() 614 | 615 | # Start process for each URL 616 | with Pool(len(urls), set_priority, [fast]) as p: 617 | p.starmap( 618 | track, 619 | [ 620 | [ 621 | title, 622 | url, 623 | qty, 624 | headless, 625 | login_cookies, 626 | password, 627 | cvv, 628 | thread_lock, 629 | paused, 630 | pbar, 631 | pred_price, 632 | money_manager, 633 | headers, 634 | ] 635 | for title, url, qty, login_cookies, pred_price in zip( 636 | titles, urls, qtys, login_cookies_list, predicted_prices 637 | ) 638 | ], 639 | ) 640 | 641 | pbar.close() 642 | print("\nAll processes have finished.") 643 | --------------------------------------------------------------------------------