diff --git a/Pipfile b/Pipfile index 3179fbb6..931e2fcb 100644 --- a/Pipfile +++ b/Pipfile @@ -10,7 +10,7 @@ pyinstaller = "*" requests = "==2.24.0" click = "*" selenium = "*" -chromedriver-py = "==87.0.4280.88" +chromedriver-py = "==88.0.4324.96" furl = "*" twilio = "*" discord-webhook = "*" @@ -21,7 +21,7 @@ slackclient = "*" playsound = "*" prompt_toolkit = "*" aiohttp = "*" -pyobjc = {version = "*", sys_platform = "== 'darwin'"} +pyobjc = { version = "*", sys_platform = "== 'darwin'" } async-timeout = "*" amazoncaptcha = "==0.4.4" browser-cookie3 = "*" @@ -29,9 +29,12 @@ coloredlogs = "*" apprise = "*" price-parser = "*" pypresence = "==4.0.0" -pywin32 = {version = "*", sys_platform = "== 'win32'"} +pywin32 = { version = "*", sys_platform = "== 'win32'" } psutil = "*" stdiomask = "*" +packaging = "*" +config = "*" +lxml = "*" [requires] python_version = "3.8" diff --git a/README.md b/README.md index 8ee68def..4c388c13 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,72 @@ # Fairgame -[Installation](#Installation) | [Usage](#Usage) | [Discord](https://discord.gg/qDY2QBtAW6) | [Troubleshooting](#Troubleshooting) +[Installation](#Installation) | [Usage](#Usage) | [Discord](https://discord.gg/4rfbNKrmnC) | [Troubleshooting](#Troubleshooting) ## Why??? -We built this in response to the severe tech scalping situation that's happening right now. Almost every tech product that's coming -out right now is being instantly brought out by scalping groups and then resold at at insane prices. $699 GPUs are being listed -for $1700 on eBay, and these scalpers are buying 40 cards while normal consumers can't get a single one. Preorders for the PS5 are -being resold for nearly $1000. Our take on this is that if we release a bot that anyone can use, for free, then the number of items -that scalpers can buy goes down and normal consumers can buy items for MSRP. +We built this in response to the severe tech scalping situation that's happening right now. Almost every tech product +that's coming out right now is being instantly brought out by scalping groups and then resold at at insane prices. $699 +GPUs are being listed for $1700 on eBay, and these scalpers are buying 40 cards while normal consumers can't get a +single one. Preorders for the PS5 are being resold for nearly $1000. Our take on this is that if we release a bot that +anyone can use, for free, then the number of items that scalpers can buy goes down and normal consumers can buy items +for MSRP. **If everyone is botting, then no one is botting.** +## Current Functionality + +| **Website** | **Auto Checkout** | **Open Cart Link** | **Test flag** | +|:---:|:---:|:---:|:---:| +| amazon.com |`✔`| | | +| ~~bestbuy.com~~ | |`✔`| | + +Best Buy has been deprecated, see [details](#best-buy) below. + ## Got a question? -Read through this document and the cheat sheet linked in the next sections. See the [FAQs](#frequently-asked-questions) if that does not answer your questions. +Read through this document and the cheat sheet linked in the next sections. See the [FAQs](#frequently-asked-questions) +if that does not answer your questions. ## Installation -Easy_XII has created a great cheat sheet for getting started, [please follow this guide](https://docs.google.com/document/d/14kZ0SNC97DFVRStnrdsJ8xbQO1m42v7svy93kUdtX48/). -**Note:** that we do not control the contents of this document, so use some common sense when configuring the bot. Do not ask us -why the bot does not purchase an $8.49 item when the minimum purchase price is set to $10 in the configuration file that YOU are supposed to update +Community user Easy_XII has created a great cheat sheet for getting started. It includes specific and additional steps +for Windows users as well as useful product and configuration information. Please start +with [this guide](https://docs.google.com/document/d/14kZ0SNC97DFVRStnrdsJ8xbQO1m42v7svy93kUdtX48) to get you started +and to answer any initial questions you may have about setup. + +**Note:** The above document is community maintained and managed. The authors of Fairgame do not control the contents, +so use some common sense when configuring the bot as both the bot and the sites we interact with change over time. For +example, do not ask us why the bot does not purchase an item whose price has changed to $8.49 when the _minimum_ +purchase price is set to $10 in the configuration file that YOU are supposed to update + +### General + +This project uses [Pipenv](https://pypi.org/project/pipenv/) to manage dependencies. Hop in +my [Discord](https://discord.gg/4rfbNKrmnC) if you have ideas, need help or just want to tell us about how you got your +new toys. + +To get started, there are two options: + +#### Releases + +To get the latest release as a convenient package, download it directly from +the [Releases](https://github.com/Hari-Nagarajan/fairgame/releases) +page on GitHub. The "Source code" zip or tar file are what you'll want. This can be downloaded and extracted into a +directory of your choice (e.g. C:\fairgame). -This project uses [Pipenv](https://pypi.org/project/pipenv/) to manage dependencies. Hop in my [Discord](https://discord.gg/qDY2QBtAW6) if you have ideas, need help or just want to tell us about how you got your new toys. +#### Git -To get started you'll first need to clone this repository. If you are unfamiliar with Git, follow the [guide on how to do that on our Wiki](https://github.com/Hari-Nagarajan/fairgame/wiki/How-to-use-GitHub-Desktop-App). You *can* use the "Download Zip" button on the GitHub repository's homepage but this makes receieving updates more difficult. If you can get setup with the GitHub Desktop app, updating to the latest version of the bot takes 1 click. +If you want to manage the code via Git, you'll first need to clone this repository. If you are unfamiliar with Git, +follow the [guide](https://github.com/Hari-Nagarajan/fairgame/wiki/How-to-use-GitHub-Desktop-App) on how to do that on +our Wiki . You *can* use the "Download Zip" button on the GitHub repository's homepage but this makes receiving updates +more difficult. If you can get setup with the GitHub Desktop app, updating to the latest version of the bot takes 1 +click. !!! YOU WILL NEED TO USE THE 3.8 BRANCH OF PYTHON, 3.9.0 BREAKS DEPENDENCIES !!! -``` + +It is best if you use the newest version (3.8.7) but 3.8.5 and 3.8.6 should also work. 3.8.0 does not. + +```shell pip install pipenv pipenv shell pipenv install @@ -36,7 +75,8 @@ pipenv install NOTE: YOU SHOULD RUN `pipenv shell` and `pipenv install` ANY TIME YOU UPDATE, IN CASE THE DEPENDENCIES HAVE CHANGED! Run it -``` + +```shell python app.py Usage: app.py [OPTIONS] COMMAND [ARGS]... @@ -48,34 +88,128 @@ Commands: amazon ``` -## Current Functionality +### Platform Specific -| **Website** | **Auto Checkout** | **Open Cart Link** | **Test flag** | -|:---:|:---:|:---:|:---:| -| amazon.com |`✔`| | | -| ~~bestbuy.com~~ | |`✔`| | -Best Buy has been deprecated, see details below. +These instructions are supplied by community members and any adjustments, corrections, improvements or clarifications +are welcome. These are typically created during installation in a single environment, so there may be caveats or changes +necessary for your environment. This isn't intended to be a definitive guide, but a starting point as validation that a +platform can/does work. Please report back any suggestions to our [Discord](https://discord.gg/qDY2QBtAW6) feedback +channel. + +### Installation Ubuntu 20.10 (and probably other distros) + +Based off Ubuntu 20.10 with a fresh installation. + +Open terminal. Either right click desktop and go to Open In Terminal, or search for Terminal under Show Applications + +Install Google Chrome: +`wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && sudo dpkg -i google-chrome-stable_current_amd64.deb` + +Install Pip: +`sudo apt install python3-pip` + +Install pipenv: +`pip3 install pipenv` + +Add /home/$USER/.local/bin to PATH: +`export PATH="/home/$USER/.local/bin:$PATH"` + +Install git: +`sudo apt install git` + +Clone git repository: +`git clone https://github.com/Hari-Nagarajan/fairgame` + +Change into the fairgame folder: +`cd ./fairgame/` + +Prepare your config files within ./config/ + +```shell +cp ./config/amazon_config.template_json ./config/amazon_config.json +cp ./config/apprise.conf_template ./config/apprise.conf +``` + +Make a pipshell environment: +`pipenv shell` + +Install dependencies: +`pipenv install` + +Edit the newly created files with your settings based on your [configuration](#configuration) + +### Installation Raspberry Pi 4 (2 GB+) + +This is an abridged version of the community created document by UnidentifiedWarlock and Judarius. It can be +found [here](https://docs.google.com/document/d/1VUxXhATZ8sZOJxdh3AIY6OGqwLRmrAcPikKZAwphIE8/edit). If the steps here +don't work on your Pi 4, look there for additional options. This hasn't been tested on a Pi 3, but given enough RAM to +run Chrome, it may very well work. Let us know. + +```shell +sudo apt update +sudo apt upgrade +sudo apt-get install -y build-essential tk-dev libncurses5-dev libncursesw5-dev libreadline6-dev libdb5.3-dev libgdbm-dev libsqlite3-dev libssl-dev libbz2-dev libexpat1-dev liblzma-dev zlib1g-dev libffi-dev libzbar-dev clang + +version=3.8.7 + +wget https://www.python.org/ftp/python/$version/Python-$version.tgz + +tar zxf Python-$version.tgz +cd Python-$version +./configure --enable-optimizations +make -j4 +sudo make altinstall + +sudo python3 -m pip install --upgrade pip + +sudo apt install chromium-chromedriver +cp /usr/bin/chromedriver /home/fairgame/.local/share/virtualenvs/fairgame-/lib/python3.8/site-packages/chromedriver_py/chromedriver_linux64 +git clone https://github.com/Hari-Nagarajan/fairgame +cd fairgame/ +pip3 install pipenv +export PATH=$PATH:/home/$USER/.local/bin +pipenv shell +pipenv install +``` + +Leave this Terminal window open. + +Open the following file in a text editor: + +`/home/$USER/.local/share/virtualenvs/fairgame-/lib/python3.8/site-packages/selenium/webdriver/common/service.py` + +Edit line 38 from + +`self.path = executable` + +to + +`self.path = "chromedriver"` + +Then save and close the file. ## Usage -### Amazon -The following flags are specific to the Amazon scripts. They the `[OPTIONS]` to be passed on the command-line to control -the behavior of Amazon scanning and purchasing. These can be added at the command line or added to a batch file/shell - script (see `_Amazon.bat` in the root folder of the project). **NOTE:** `--test` flag has been added to `_Amazon.bat` file by -default. This should be deleted after you've verified that the bot works correctly for you. If you don't want your `_Amazon.bat` +### Amazon + +The following flags are specific to the Amazon scripts. They the `[OPTIONS]` to be passed on the command-line to control +the behavior of Amazon scanning and purchasing. These can be added at the command line or added to a batch file/shell +script (see `_Amazon.bat` in the root folder of the project). **NOTE:** `--test` flag has been added to `_Amazon.bat` +file by default. This should be deleted after you've verified that the bot works correctly for you. If you don't want +your `_Amazon.bat` to be deleted when you update, you should rename it to something else. -**Amazon flags** +#### Amazon flags #### -``` +```shell python app.py amazon --help -Usage: app.py amazon [OPTIONS] +Usage: app.py amazon option Options: --no-image Do not load images - --headless Unsupported headless mode. GLHF - --test Run the checkout flow, but do not actually purchase the + --headless Runs Chrome in headless mode. + --test Run the checkout flow but do not actually purchase the item[s] --delay FLOAT Time to wait between checks for item[s] @@ -109,31 +243,38 @@ Options: --help Show this message and exit. ``` -**Configuration** +#### Configuration -Make a copy of `amazon_config.template_json` and rename to `amazon_config.json`. Edit it according to the ASINs you -are interested in purchasing. [*What's an ASIN?*](https://www.datafeedwatch.com/blog/amazon-asin-number-what-is-it-and-how-do-you-get-it#how-to-find-asin) +Make a copy of `amazon_config.template_json` and rename to `amazon_config.json`. Edit it according to the ASINs you are +interested in purchasing. [*What's an +ASIN?*](https://www.datafeedwatch.com/blog/amazon-asin-number-what-is-it-and-how-do-you-get-it#how-to-find-asin) * `asin_groups` indicates the number of ASIN groups you want to use. -* `asin_list_x` list of ASINs for products you want to purchase. You must locate these (see Discord or lookup the ASIN on product pages). - * The first time an item from list "x" is in stock and under its associated reserve, it will purchase it. +* `asin_list_x` list of ASINs for products you want to purchase. You must locate these (see Discord or lookup the ASIN + on product pages). + * The first time an item from list "x" is in stock and under its associated reserve, it will purchase it. * If the purchase is successful, the bot will not buy anything else from list "x". * Use sequential numbers for x, starting from 1. x can be any integer from 1 to 18,446,744,073,709,551,616 -* `reserve_min_x` set a minimum limit to consider for purchasing an item. If a seller has a listing for a 700 dollar item a 1 dollar, it's likely fake. -* `reserve_max_x` is the most amount you want to spend for a single item (i.e., ASIN) in `asin_list_x`. Does not include tax. If --checkshipping flag is active, this includes shipping listed on offer page. -* `amazon_website` amazon domain you want to use. smile subdomain appears to work better, if available in your country. +* `reserve_min_x` set a minimum limit to consider for purchasing an item. If a seller has a listing for a 700 dollar + item a 1 dollar, it's likely fake. +* `reserve_max_x` is the most amount you want to spend for a single item (i.e., ASIN) in `asin_list_x`. Does not include + tax. If --checkshipping flag is active, this includes shipping listed on offer page. +* `amazon_website` amazon domain you want to use. smile subdomain appears to work better, if available in your + country. [*What is Smile?*](https://org.amazon.com/) -**Examples** +##### Examples One unique product with one ASIN (e.g., Segway Ninebot S and GoKart Drift Kit Bundle) : ```json { - "asin_groups": 1, - "asin_list_1": ["B07K7NLDGT"], - "reserve_min_1": 450, - "reserve_max_1": 500, - "amazon_website": "smile.amazon.com" + "asin_groups": 1, + "asin_list_1": [ + "B07K7NLDGT" + ], + "reserve_min_1": 450, + "reserve_max_1": 500, + "amazon_website": "smile.amazon.com" } ``` @@ -141,44 +282,57 @@ One general product with multiple ASINS (e.g 16 GB USB drive 2 pack) ```json { - "asin_groups": 1, - "asin_list_1": ["B07JH53M4T", "B085M1SQ9S", "B00E9W1ULS"], - "reserve_min_1": 15, - "reserve_max_1": 20, - "amazon_website": "smile.amazon.com" + "asin_groups": 1, + "asin_list_1": [ + "B07JH53M4T", + "B085M1SQ9S", + "B00E9W1ULS" + ], + "reserve_min_1": 15, + "reserve_max_1": 20, + "amazon_website": "smile.amazon.com" } ``` -Two general products with multiple ASINS and different price points (e.g. 16 GB USB drive 2 pack and a statue of The Thinker) +Two general products with multiple ASINS and different price points (e.g. 16 GB USB drive 2 pack and a statue of The +Thinker) ```json { - "asin_groups": 2, - "asin_list_1": ["B07JH53M4T", "B085M1SQ9S", "B00E9W1ULS"], - "reserve_min_1": 15, - "reserve_max_1": 20, - "asin_list_2": ["B006HPI2A2", "B00N54S1WW"], - "reserve_min_2": 50, - "reserve_max_2": 75, - "amazon_website": "smile.amazon.com" + "asin_groups": 2, + "asin_list_1": [ + "B07JH53M4T", + "B085M1SQ9S", + "B00E9W1ULS" + ], + "reserve_min_1": 15, + "reserve_max_1": 20, + "asin_list_2": [ + "B006HPI2A2", + "B00N54S1WW" + ], + "reserve_min_2": 50, + "reserve_max_2": 75, + "amazon_website": "smile.amazon.com" } ``` If you wanted to watch another product, you'd add a third list (e.g. `asin_list_3`) and associated min/max pricing and -increase the `asin_groups` to 3. Add as many lists as are needed, keeping in mind that the main distinction between lists -is the min/max price boundaries. Once any ASIN is purchased from an ASIN list, that list is remove from the hunt +increase the `asin_groups` to 3. Add as many lists as are needed, keeping in mind that the main distinction between +lists is the min/max price boundaries. Once any ASIN is purchased from an ASIN list, that list is remove from the hunt until FairGame is restarted. To verify that your JSON is well formatted, paste and validate it at https://jsonlint.com/ -**Start Up** +#### Start Up -Previously your username and password were entered into the config file, this is no longer the case. On first launch the bot will prompt -you for your credentials. You will then be asked for a password to encrypt them. Once done, your encrypted credentials will be stored in -`amazon_credentials.json`. If you ever forget your encryption password, just delete this file and the next launch of the bot will recreate -it. An example of this will look like the following: +Previously your username and password were entered into the config file, this is no longer the case. On first launch the +bot will prompt you for your credentials. You will then be asked for a password to encrypt them. Once done, your +encrypted credentials will be stored in +`amazon_credentials.json`. If you ever forget your encryption password, just delete this file and the next launch of the +bot will recreate it. An example of this will look like the following: -``` +```shell python app.py amazon INFO Initializing Apprise handler INFO Initializing other notification handlers @@ -194,7 +348,7 @@ INFO Credentials safely stored. Starting the bot when you have created an encrypted file: -``` +```shell python app.py amazon --test INFO Initializing Apprise handler INFO Initializing other notification handlers @@ -202,14 +356,15 @@ INFO Enabled Handlers: ['Audio'] Reading credentials from: amazon_credentials.json Credential file password: ``` + Example usage: -``` +```commandline python app.py amazon --test ... 2020-12-23 13:07:38 INFO Initializing Apprise handler using: config/apprise.conf 2020-12-23 13:07:38 INFO Found Discord configuration -2020-12-23 13:07:38 INFO FairGame v0.5.0 +2020-12-23 13:07:38 INFO FairGame v0.5.4 2020-12-23 13:07:38 INFO Reading credentials from: config/amazon_credentials.json 2020-12-23 13:07:43 INFO ================================================== 2020-12-23 13:07:43 INFO Starting Amazon ASIN Hunt for 2 Products with: @@ -237,47 +392,50 @@ python app.py amazon --test ``` - ## ~~Best Buy~~ -Best Buy is currently deprecated because we don't yet have an effective way to determine item availability -without scraping and processing the product pages individually. Future updates may see this functionality -return, but the current code isn't reliable for high demand items and checkout automation has become -increasingly hard due to anti-bot measures taken by Best Buy. +Best Buy is currently deprecated because we don't yet have an effective way to determine item availability without +scraping and processing the product pages individually. Future updates may see this functionality return, but the +current code isn't reliable for high demand items and checkout automation has become increasingly hard due to anti-bot +measures taken by Best Buy. -Original code still exists, but provides very little utility. A 3rd party stock notification service would -probably serve as a better solution at Best Buy. +Original code still exists, but provides very little utility. A 3rd party stock notification service would probably +serve as a better solution at Best Buy. -~~This is fairly basic right now. Just login to the best buy website in your default browser and then run the command as follows:~~ +~~This is fairly basic right now. Just login to the best buy website in your default browser and then run the command as +follows:~~ ``` python app.py bestbuy --sku [SKU] ``` ~~Example:~~ -```python -python app.py bestbuy --sku 6429440 -``` +``` +python python app.py bestbuy - -sku 6429440 +``` ## Notifications ### Sounds -Local sounds are provided as a means to give you audible cues to what is happening. The notification sound -plays for notable events (e.g., start up, product found for purchase) during the scans. An alarm notification -will play when user interaction is necessary. This is typically when all automated options have been exhausted. -Lastly, a purchase notification sound will play if the bot if successful. These local sounds can be disabled -via the command-line and [tested](#testing-notifications) along with other notification methods + +Local sounds are provided as a means to give you audible cues to what is happening. The notification sound plays for +notable events (e.g., start up, product found for purchase) during the scans. An alarm notification will play when user +interaction is necessary. This is typically when all automated options have been exhausted. Lastly, a purchase +notification sound will play if the bot if successful. These local sounds can be disabled via the command-line +and [tested](#testing-notifications) along with other notification methods ### Apprise -Notifications are now handled by Apprise. Apprise lets you send notifications to a large number of supported notification services. -Check https://github.com/caronc/apprise/wiki for a detailed list. -To enable Apprise notifications, make a copy of `apprise.conf_template` in the `config` directory and name it -`apprise.conf`. Then add apprise formatted urls for your desired notification services as simple text entries -in the config file. Any recognized notification services will be reported on app start. +Notifications are now handled by Apprise. Apprise lets you send notifications to a large number of supported +notification services. Check https://github.com/caronc/apprise/wiki for a detailed list. + +To enable Apprise notifications, make a copy of `apprise.conf_template` in the `config` directory and name it +`apprise.conf`. Then add apprise formatted urls for your desired notification services as simple text entries in the +config file. Any recognized notification services will be reported on app start. + +##### Apprise Example Config: -**Apprise Example Config:** ``` # Hash Tags denote comment lines and blank lines are allowed # Discord (https://github.com/caronc/apprise/wiki/Notify_discord) @@ -293,78 +451,74 @@ https://hooks.slack.com/services/{tokenA}/{tokenB}/{tokenC} ``` -#### Pavlok -To enable shock notifications to your [Pavlok Shockwatch](https://www.amazon.com/Pavlok-PAV2-PERIMETER-BLACK-2/dp/B01N8VJX8P?), -store the url from the pavlok app in the ```pavlok_config.json``` file, you can copy the template from ```pavlok_config.template_json```. +### Pavlok + +To enable shock notifications to +your [Pavlok Shockwatch](https://www.amazon.com/Pavlok-PAV2-PERIMETER-BLACK-2/dp/B01N8VJX8P?), store the url from the +pavlok app in the ```pavlok_config.json``` file, you can copy the template from ```pavlok_config.template_json```. **WARNING:** This feature does not currently support adjusting the intensity, it will always be max (255). + ```json { "base_url": "url goes here" } ``` +### Testing notifications -#### Testing notifications - -Once you have setup your `apprise_config.json ` you can test it by running `python app.py test-notifications` from within your pipenv shell. This will send a test notification to all configured notification services. +Once you have setup your `apprise_config.json ` you can test it by running `python app.py test-notifications` from +within your pipenv shell. This will send a test notification to all configured notification services. ## Troubleshooting -Re-read this documentation. Verify your JSON. ++ Re-read this documentation. -I suggest joining the #tech-support channel in [Discord](https://discord.gg/qDY2QBtAW6) for help from the community if these common fixes don't help. ++ Verify your JSON. -**Error: ```selenium.common.exceptions.WebDriverException: Message: unknown error: cannot find Chrome binary```** -The issue is that chrome is not installed in the expected location. See [Selenium Wiki](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver#requirements) and the section on [overriding the Chrome binary location .](https://sites.google.com/a/chromium.org/chromedriver/capabilities#TOC-Using-a-Chrome-executable-in-a-non-standard-location) ++ Consider joining the #tech-support channel in [Discord](https://discord.gg/5tw6UY7g44) for help from the community if + these common fixes don't help. -The easy fix for this is to add an option where selenium is used (`selenium_utils.py``) -```python -chrome_options.binary_location="C:\Users\%USERNAME%\AppData\Local\Google\Chrome\Application\chrome.exe" -``` ++ **Error: ```selenium.common.exceptions.WebDriverException: Message: unknown error: cannot find Chrome binary```** + The issue is that chrome is not installed in the expected location. + See [Selenium Wiki](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver#requirements) and the section + on [overriding the Chrome binary location .](https://sites.google.com/a/chromium.org/chromedriver/capabilities#TOC-Using-a-Chrome-executable-in-a-non-standard-location) -**Error: ```selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 87```** + The easy fix for this is to add an option where selenium is used (`selenium_utils.py`) -You are not running the proper version of Chrome this requires. As of this update, the current version is Chrome 87. Check your version by going to ```chrome://version/``` in your browser. We are going to be targeting the current stable build of chrome. If you are behind, please update, if you are on a beta or canary branch, you'll have to build your own version of chromedriver-py. + ``` + python chrome_options.binary_location = "C:\Users\%USERNAME%\AppData\Local\Google\Chrome\Application\chrome.exe" + ``` -## Raspberry-Pi-Setup -Maybe this works? ++ **Error: ```selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 87```** -1. Prereqs and Setup -```shell -sudo apt update -sudo apt upgrade -sudo apt install chromium-chromedriver -git clone https://github.com/Hari-Nagarajan/fairgame -cd fairgame/ -pip3 install pipenv -export PATH=$PATH:/home//.local/bin -pipenv shell -pipenv install -``` -2. Leave this Terminal window open. + You are not running the proper version of Chrome this requires. As of this update, the current version is Chrome 87. + Check your version by going to ```chrome://version/``` in your browser. We are going to be targeting the current stable + build of chrome. If you are behind, please update, if you are on a beta or canary branch, you'll have to build your own + version of chromedriver-py. -3. Open the following file in a text editor: -``` -/home//.local/share/virtualenvs/fairgame-/lib/python3.7/site-packages/selenium/webdriver/common/service.py -``` -4. Edit line 38 from `self.path = executable` to `self.path = "chromedriver"`, then save and close the file. +## Frequently Asked Questions +To keep up with questions, the Discord channel [#FAQ](https://discord.gg/GEsarYKMAw) is where you'll find the latest +answers. If you don't find it there, ask in #tech-support. -5. Back in Terminal... -```shell -python app.py -``` +1. **Can I run multiple instances of the bot?** -6. Follow [Usage](#Usage) to configure the bot as needed. + Yes. For example you can run one instance to check stock on Best Buy and a separate instance to check stock on + Amazon. Bear in mind that if you do this you may end up with multiple purchases going through at the same time. -## Frequently Asked Questions +2. **Does Fairgame automatically bypass CAPTCHA's on the store sites?** + For Amazon, yes. The bot will try and auto-solve CAPTCHA's during the checkout process. -### 1. Can I run multiple instances of the bot? -Yes. For example you can run one instance to check stock on Best Buy and a separate instance to check stock on Amazon. Bear in mind that if you do this you may end up with multiple purchases going through at the same time. +3. **Does `--headless` work?** + Yes! A community user identified the issue with the headless option while running on a Raspberry Pi. This allowed + the developers to update the codebase to consistently work correctly on headless server environments. Give it a try + and let us know if you have any issues. -### 2. Does Fairgame automatically bypass CAPTCHA's on the store sites? -* For Amazon, yes. The bot will try and auto-solve CAPTCHA's during the checkout process. +4. **Does Fairgame run on a Raspberry Pi?** + Yes, with caveats. Most people seem to have success with Raspberry Pi 4. The 2 GB model may need to run the headless + option due to the smaller memory footprint. Still awaiting community feedback on running on a Pi 3. CPU and memory + capacity seem to be the limiting factor for older Pi models. ## Attribution diff --git a/app.py b/app.py index f981c6b2..8dc8ea53 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,53 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame +import os +import hashlib +from common.license_hash import license_hash + + +def sha256sum(filename): + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(filename, "rb", buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + +if os.path.exists("LICENSE") and sha256sum("LICENSE") in license_hash: + s = """ + FairGame Copyright (C) 2021 Hari Nagarajan + This program comes with ABSOLUTELY NO WARRANTY; for details + start the program with the `show --w' option. + + This is free software, and you are welcome to redistribute it + under certain conditions; for details start the program with + the `show --c' option.\n + """ + + print(s) +else: + print("License File Changed or Missing. Quitting Program.") + exit(0) + + from cli import cli diff --git a/cli/__init__.py b/cli/__init__.py index e69de29b..c5e3c3e3 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -0,0 +1,18 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame diff --git a/cli/cli.py b/cli/cli.py index e4e17ac1..8c0e1818 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1,32 +1,62 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + +import os +import shutil from datetime import datetime from functools import wraps +from pathlib import Path from signal import signal, SIGINT +LICENSE_PATH = os.path.join( + "cli", + "license", +) + + try: import click -except ModuleNotFoundError: - print( - "You should try running pipenv shell and pipenv install per the install instructions" - ) - print("Or you should only use Python 3.8.X per the instructions.") - print("If you are attempting to run multiple bots, this is not supported.") - print("You are on your own to figure this out.") +except ModuleNotFoundError as e: + print(e) + print("Install the missing module noted above.") exit(0) import time + from notifications.notifications import NotificationHandler, TIME_FORMAT +from utils.logger import log +from common.globalconfig import GlobalConfig, AMAZON_CREDENTIAL_FILE +from utils.version import is_latest, version from stores.amazon import Amazon from stores.bestbuy import BestBuyHandler -from utils import selenium_utils -from utils.logger import log -from utils.version import check_version -notification_handler = NotificationHandler() -try: - check_version() -except Exception as e: - log.error(e) +def get_folder_size(folder): + return sizeof_fmt(sum(file.stat().st_size for file in Path(folder).rglob("*"))) + + +def sizeof_fmt(num, suffix="B"): + for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: + if abs(num) < 1024.0: + return "%3.1f%s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f%s%s" % (num, "Yi", suffix) def handler(signal, frame): @@ -50,6 +80,7 @@ def decorator(*args, **kwargs): @click.group() def main(): + pass @@ -82,7 +113,7 @@ def main(): @click.command() @click.option("--no-image", is_flag=True, help="Do not load images") -@click.option("--headless", is_flag=True, help="Unsupported headless mode. GLHF") +@click.option("--headless", is_flag=True, help="Headless mode.") @click.option( "--test", is_flag=True, @@ -147,6 +178,24 @@ def main(): default=False, help="Will attempt to click ship to address button. USE AT YOUR OWN RISK!", ) +@click.option( + "--clean-profile", + is_flag=True, + default=False, + help="Purge the user profile that Fairgame uses for browsing", +) +@click.option( + "--clean-credentials", + is_flag=True, + default=False, + help="Purge Amazon credentials and prompt for new credentials", +) +@click.option( + "--alt-offers", + is_flag=True, + default=False, + help="Directly hit the offers page. Preferred, but deprecated by Amazon.", +) @notify_on_crash def amazon( no_image, @@ -164,12 +213,26 @@ def amazon( p, log_stock_check, shipping_bypass, + clean_profile, + clean_credentials, + alt_offers, ): - notification_handler.sound_enabled = not disable_sound if not notification_handler.sound_enabled: log.info("Local sounds have been disabled.") + if clean_profile and os.path.exists(global_config.get_browser_profile_path()): + log.info( + f"Removing existing profile at '{global_config.get_browser_profile_path()}'" + ) + profile_size = get_folder_size(global_config.get_browser_profile_path()) + shutil.rmtree(global_config.get_browser_profile_path()) + log.info(f"Freed {profile_size}") + + if clean_credentials and os.path.exists(AMAZON_CREDENTIAL_FILE): + log.info(f"Removing existing Amazon credentials from {AMAZON_CREDENTIAL_FILE}") + os.remove(AMAZON_CREDENTIAL_FILE) + amzn_obj = Amazon( headless=headless, notification_handler=notification_handler, @@ -180,10 +243,11 @@ def amazon( no_screenshots=no_screenshots, disable_presence=disable_presence, slow_mode=slow_mode, - encryption_pass=p, no_image=no_image, + encryption_pass=p, log_stock_check=log_stock_check, shipping_bypass=shipping_bypass, + alt_offers=alt_offers, ) try: amzn_obj.run(delay=delay, test=test) @@ -234,8 +298,53 @@ def test_notifications(disable_sound): time.sleep(5) +@click.command() +@click.option("--w", is_flag=True) +@click.option("--c", is_flag=True) +def show(w, c): + if w and c: + print("Choose one option. Program Quitting") + exit(0) + elif w: + show_file = "show_w.txt" + elif c: + show_file = "show_c.txt" + else: + print( + "Option missing, you must include w or c with show argument. Program Quitting" + ) + exit(0) + + if os.path.exists(LICENSE_PATH): + + with open(os.path.join(LICENSE_PATH, show_file)) as file: + try: + print(file.read()) + except FileNotFoundError: + log.error("License File Missing. Quitting Program") + exit(0) + else: + log.error("License File Missing. Quitting Program.") + exit(0) + + signal(SIGINT, handler) main.add_command(amazon) main.add_command(bestbuy) main.add_command(test_notifications) +main.add_command(show) + +# Global scope stuff here +if is_latest(): + log.info(f"FairGame v{version}") +elif version.is_prerelease: + log.warning(f"FairGame PRE-RELEASE v{version}") +else: + log.warning( + f"You are running FairGame v{version.release}, but the most recent version is v{version.get_latest_version()}. " + f"Consider upgrading " + ) + +global_config = GlobalConfig() +notification_handler = NotificationHandler() diff --git a/cli/license/show_c.txt b/cli/license/show_c.txt new file mode 100644 index 00000000..642368ec --- /dev/null +++ b/cli/license/show_c.txt @@ -0,0 +1,148 @@ + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + diff --git a/cli/license/show_w.txt b/cli/license/show_w.txt new file mode 100644 index 00000000..a4e541ef --- /dev/null +++ b/cli/license/show_w.txt @@ -0,0 +1,32 @@ + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + diff --git a/cli/utils.py b/cli/utils.py index 4cbaca48..1b474cc2 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import click import questionary diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 00000000..c5e3c3e3 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1,18 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame diff --git a/common/globalconfig.py b/common/globalconfig.py new file mode 100644 index 00000000..5b021988 --- /dev/null +++ b/common/globalconfig.py @@ -0,0 +1,78 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + +import os +from config import Config as Cfg +import stdiomask + +from utils.encryption import load_encrypted_config, create_encrypted_config +from utils.logger import log + +GLOBAL_CONFIG_FILE = "config/fairgame.conf" +AMAZON_CREDENTIAL_FILE = "config/amazon_credentials.json" + + +def await_credential_input(): + username = input("Amazon login ID: ") + password = stdiomask.getpass(prompt="Amazon Password: ") + return { + "username": username, + "password": password, + } + + +def get_credentials(credentials_file, encrypted_pass=None): + if os.path.exists(credentials_file): + credential = load_encrypted_config(credentials_file, encrypted_pass) + return credential["username"], credential["password"] + else: + log.info("No credential file found, let's make one") + log.info("NOTE: DO NOT SAVE YOUR CREDENTIALS IN CHROME, CLICK NEVER!") + credential = await_credential_input() + create_encrypted_config(credential, credentials_file) + return credential["username"], credential["password"] + + +class GlobalConfig: + def __init__(self) -> None: + super().__init__() + log.info("Initializing Global configuration...") + # Load up the global configuration + # See http://docs.red-dove.com/cfg/python.html#getting-started-with-cfg-in-python for how to use Config + self.global_config = Cfg(GLOBAL_CONFIG_FILE) + self.fairgame_config = self.global_config.get("FAIRGAME") + self.profile_path = None + self.get_browser_profile_path() + + def get_amazon_config(self, encryption_pass=None): + log.info("Initializing Amazon configuration...") + # Load up all things Amazon + amazon_config = self.global_config["AMAZON"] + amazon_config["username"], amazon_config["password"] = get_credentials( + AMAZON_CREDENTIAL_FILE, encryption_pass + ) + return amazon_config + + def get_browser_profile_path(self): + if not self.profile_path: + self.profile_path = os.path.join( + os.path.dirname(os.path.abspath("__file__")), + self.global_config["FAIRGAME"].get("profile_name", ".profile-amz"), + ) + return self.profile_path diff --git a/common/license_hash.py b/common/license_hash.py new file mode 100644 index 00000000..4d84b097 --- /dev/null +++ b/common/license_hash.py @@ -0,0 +1,4 @@ +license_hash = [ + "81cbae84a29ce7e770bf2bc7b178e50bda0ce8de6067aba661b0bc7b05b562f8", + "8b1ba204bb69a0ade2bfcf65ef294a920f6bb361b317dba43c7ef29d96332b9b", +] diff --git a/config/fairgame.conf b/config/fairgame.conf new file mode 100644 index 00000000..6bfd9af1 --- /dev/null +++ b/config/fairgame.conf @@ -0,0 +1,134 @@ +{ + "FAIRGAME": { + "profile_name": ".profile-amz" + }, + "AMAZON": { + "SIGN_IN_TEXT": [ + "Hello, Sign in", + "Sign in", + "Hola, Identifícate", + "Bonjour, Identifiez-vous", + "Ciao, Accedi", + "Hallo, Anmelden", + "Hallo, Inloggen" + ], + "SIGN_IN_TITLES": [ + "Amazon Sign In", + "Amazon Sign-In", + "Amazon Anmelden", + "Iniciar sesión en Amazon", + "Connexion Amazon", + "Amazon Accedi", + "Inloggen bij Amazon" + ], + "CAPTCHA_PAGE_TITLES": [ + "Robot Check", + "Server Busy" + ], + "HOME_PAGE_TITLES": [ + "Amazon.com: Online Shopping for Electronics, Apparel, Computers, Books, DVDs & more", + "AmazonSmile: You shop. Amazon gives.", + "Amazon.ca: Low Prices – Fast Shipping – Millions of Items", + "Amazon.co.uk: Low Prices in Electronics, Books, Sports Equipment & more", + "Amazon.de: Low Prices in Electronics, Books, Sports Equipment & more", + "Amazon.de: Günstige Preise für Elektronik & Foto, Filme, Musik, Bücher, Games, Spielzeug & mehr", + "Amazon.es: compra online de electrónica, libros, deporte, hogar, moda y mucho más.", + "Amazon.de: Günstige Preise für Elektronik & Foto, Filme, Musik, Bücher, Games, Spielzeug & mehr", + "Amazon.fr : livres, DVD, jeux vidéo, musique, high-tech, informatique, jouets, vêtements, chaussures, sport, bricolage, maison, beauté, puériculture, épicerie et plus encore !", + "Amazon.it: elettronica, libri, musica, fashion, videogiochi, DVD e tanto altro", + "Amazon.nl: Groot aanbod, kleine prijzen in o.a. Elektronica, boeken, sport en meer" + ], + "SHOPPING_CART_TITLES": [ + "Amazon.com Shopping Cart", + "Amazon.ca Shopping Cart", + "Amazon.co.uk Shopping Basket", + "Amazon.de Basket", + "Amazon.de Einkaufswagen", + "AmazonSmile Einkaufswagen", + "Cesta de compra Amazon.es", + "Amazon.fr Panier", + "Carrello Amazon.it", + "AmazonSmile Shopping Cart", + "AmazonSmile Shopping Basket", + "Amazon.nl-winkelwagen" + ], + "CHECKOUT_TITLES": [ + "Amazon.com Checkout", + "Amazon.co.uk Checkout", + "Place Your Order - Amazon.ca Checkout", + "Place Your Order - Amazon.co.uk Checkout", + "Amazon.de Checkout", + "Place Your Order - Amazon.de Checkout", + "Amazon.de - Bezahlvorgang", + "Bestellung aufgeben - Amazon.de-Bezahlvorgang", + "Place Your Order - Amazon.com Checkout", + "Place Your Order - Amazon.com", + "Tramitar pedido en Amazon.es", + "Processus de paiement Amazon.com", + "Confirmar pedido - Compra Amazon.es", + "Passez votre commande - Processus de paiement Amazon.fr", + "Ordina - Cassa Amazon.it", + "AmazonSmile Checkout", + "Plaats je bestelling - Amazon.nl-kassa", + "Place Your Order - AmazonSmile Checkout", + "Preparing your order", + "Ihre Bestellung wird vorbereitet", + "Pagamento Amazon.it", + "Ordine in preparazione" + ], + "ORDER_COMPLETE_TITLES": [ + "Amazon.com Thanks You", + "Amazon.ca Thanks You", + "AmazonSmile Thanks You", + "Thank you", + "Amazon.fr Merci", + "Merci", + "Amazon.es te da las gracias", + "Amazon.fr vous remercie.", + "Grazie da Amazon.it", + "Hartelijk dank", + "Thank You", + "Amazon.de Vielen Dank" + ], + "BUSINESS_PO_TITLES": [ + "Business order information" + ], + "DOGGO_TITLES": [ + "Sorry! Something went wrong!" + ], + "SHIPPING_ONLY_IF": "FREE Shipping on orders over", + "TWOFA_TITLES": [ + "Two-Step Verification", + "Verifica in due fasi" + ], + "PRIME_TITLES": [ + "Complete your Amazon Prime sign up" + ], + "OUT_OF_STOCK": [ + "Out of Stock - AmazonSmile Checkout" + ], + "NO_SELLERS": [ + "Currently, there are no sellers that can deliver this item to your location.", + "There are currently no listings for this search. Try a different refinement.", + "There are currently no listings for this search. Try a different refinement.", + "There are currently no listings for this product in . Try changing the condition type." + ], + "FREE_SHIPPING": [ + "FREE SHIPPING", + "GRATIS BEZORGING", + "FRETE GRÁTIS", + "LIVRAISON GRATUITE", + "FREE DELIVERY.", + "FREE DELIVERY", + "ENVÍO GRATIS.", + "FRI FRAKT", + "GRATIS-LIEFERUNG", + "PRIME FREE DELIVERY" + ], + "ADDRESS_SELECT": [ + "Select a delivery address", + "Ordine in preparazione", + "Select a shipping address" + ] + } +} diff --git a/notifications/__init__.py b/notifications/__init__.py index e69de29b..c5e3c3e3 100644 --- a/notifications/__init__.py +++ b/notifications/__init__.py @@ -0,0 +1,18 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame diff --git a/notifications/notifications.py b/notifications/notifications.py index cec7245a..eae7540a 100644 --- a/notifications/notifications.py +++ b/notifications/notifications.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import queue import threading from os import path @@ -73,4 +92,4 @@ def play(self, audio_file=None, **kwargs): log.warn( "Error playing notification sound. Disabling local audio notifications." ) - self.enabled = False + self.sound_enabled = False diff --git a/stores/__init__.py b/stores/__init__.py index e69de29b..c5e3c3e3 100644 --- a/stores/__init__.py +++ b/stores/__init__.py @@ -0,0 +1,18 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame diff --git a/stores/amazon.py b/stores/amazon.py index 84ee1bad..29371d0c 100644 --- a/stores/amazon.py +++ b/stores/amazon.py @@ -1,165 +1,66 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + +import fileinput import json import math import os import platform -import random import time +from contextlib import contextmanager from datetime import datetime -import fileinput +from enum import Enum import psutil -import stdiomask from amazoncaptcha import AmazonCaptcha from chromedriver_py import binary_path # this will get you the path variable from furl import furl -from price_parser import parse_price +from lxml import html +from price_parser import parse_price, Price +from pypresence import exceptions as pyexceptions from selenium import webdriver from selenium.common import exceptions as sel_exceptions +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait from utils import discord_presence as presence from utils.debugger import debug -from utils.encryption import create_encrypted_config, load_encrypted_config from utils.logger import log from utils.selenium_utils import options, enable_headless +# Optional OFFER_URL is: "OFFER_URL": "https://{domain}/dp/", AMAZON_URLS = { "BASE_URL": "https://{domain}/", - "OFFER_URL": "https://{domain}/gp/offer-listing/", + "ALT_OFFER_URL": "https://{domain}/gp/offer-listing/", + "OFFER_URL": "https://{domain}/dp/", "CART_URL": "https://{domain}/gp/cart/view.html", + "ATC_URL": "https://{domain}/gp/aws/cart/add.html", } CHECKOUT_URL = "https://{domain}/gp/cart/desktop/go-to-checkout.html/ref=ox_sc_proceed?partialCheckoutCart=1&isToBeGiftWrappedBefore=0&proceedToRetailCheckout=Proceed+to+checkout&proceedToCheckout=1&cartInitiateId={cart_id}" AUTOBUY_CONFIG_PATH = "config/amazon_config.json" -CREDENTIAL_FILE = "config/amazon_credentials.json" - -SIGN_IN_TEXT = [ - "Hello, Sign in", - "Sign in", - "Hola, Identifícate", - "Bonjour, Identifiez-vous", - "Ciao, Accedi", - "Hallo, Anmelden", - "Hallo, Inloggen", -] -SIGN_IN_TITLES = [ - "Amazon Sign In", - "Amazon Sign-In", - "Amazon Anmelden", - "Iniciar sesión en Amazon", - "Connexion Amazon", - "Amazon Accedi", - "Inloggen bij Amazon", -] -CAPTCHA_PAGE_TITLES = ["Robot Check"] -HOME_PAGE_TITLES = [ - "Amazon.com: Online Shopping for Electronics, Apparel, Computers, Books, DVDs & more", - "AmazonSmile: You shop. Amazon gives.", - "Amazon.ca: Low Prices – Fast Shipping – Millions of Items", - "Amazon.co.uk: Low Prices in Electronics, Books, Sports Equipment & more", - "Amazon.de: Low Prices in Electronics, Books, Sports Equipment & more", - "Amazon.de: Günstige Preise für Elektronik & Foto, Filme, Musik, Bücher, Games, Spielzeug & mehr", - "Amazon.es: compra online de electrónica, libros, deporte, hogar, moda y mucho más.", - "Amazon.de: Günstige Preise für Elektronik & Foto, Filme, Musik, Bücher, Games, Spielzeug & mehr", - "Amazon.fr : livres, DVD, jeux vidéo, musique, high-tech, informatique, jouets, vêtements, chaussures, sport, bricolage, maison, beauté, puériculture, épicerie et plus encore !", - "Amazon.it: elettronica, libri, musica, fashion, videogiochi, DVD e tanto altro", - "Amazon.nl: Groot aanbod, kleine prijzen in o.a. Elektronica, boeken, sport en meer", # this site doesn't work anymore - "Amazon.se: Låga priser på Elektronik, Böcker, Sportutrustning & mer", # this site doesn't work anymore -] -SHOPING_CART_TITLES = [ - "Amazon.com Shopping Cart", - "Amazon.ca Shopping Cart", - "Amazon.co.uk Shopping Basket", - "Amazon.de Basket", - "Amazon.de Einkaufswagen", - "AmazonSmile Einkaufswagen", - "Cesta de compra Amazon.es", - "Amazon.fr Panier", - "Carrello Amazon.it", - "AmazonSmile Shopping Cart", - "AmazonSmile Shopping Basket", - "Amazon.nl-winkelwagen", -] -CHECKOUT_TITLES = [ - "Amazon.com Checkout", - "Amazon.co.uk Checkout", - "Place Your Order - Amazon.ca Checkout", - "Place Your Order - Amazon.co.uk Checkout", - "Amazon.de Checkout", - "Place Your Order - Amazon.de Checkout", - "Amazon.de - Bezahlvorgang", - "Bestellung aufgeben - Amazon.de-Bezahlvorgang", - "Place Your Order - Amazon.com Checkout", - "Place Your Order - Amazon.com", - "Tramitar pedido en Amazon.es", - "Processus de paiement Amazon.com", - "Confirmar pedido - Compra Amazon.es", - "Passez votre commande - Processus de paiement Amazon.fr", - "Ordina - Cassa Amazon.it", - "AmazonSmile Checkout", - "Plaats je bestelling - Amazon.nl-kassa", - "Place Your Order - AmazonSmile Checkout", - "Preparing your order", - "Ihre Bestellung wird vorbereitet", -] -ORDER_COMPLETE_TITLES = [ - "Amazon.com Thanks You", - "Amazon.ca Thanks You", - "AmazonSmile Thanks You", - "Thank you", - "Amazon.fr Merci", - "Merci", - "Amazon.es te da las gracias", - "Amazon.fr vous remercie.", - "Grazie da Amazon.it", - "Hartelijk dank", - "Thank You", - "Amazon.de Vielen Dank", -] -ADD_TO_CART_TITLES = [ - "Amazon.com: Please Confirm Your Action", - "Amazon.de: Bitte bestätigen Sie Ihre Aktion", - "Amazon.de: Please Confirm Your Action", - "Amazon.es: confirma tu acción", - "Amazon.com : Veuillez confirmer votre action", # Careful, required non-breaking space after .com ( ) - "Amazon.it: confermare l'operazione", - "AmazonSmile: Please Confirm Your Action", - "", # Amazon.nl has en empty title, sigh. -] -BUSINESS_PO_TITLES = [ - "Business order information", -] - -DOGGO_TITLES = ["Sorry! Something went wrong!"] - -# this is not non-US friendly -SHIPPING_ONLY_IF = "FREE Shipping on orders over" - -TWOFA_TITLES = ["Two-Step Verification"] - -PRIME_TITLES = ["Complete your Amazon Prime sign up"] - -OUT_OF_STOCK = ["Out of Stock - AmazonSmile Checkout"] - -NO_SELLERS = [ - "Currently, there are no sellers that can deliver this item to your location.", - "There are currently no listings for this search. Try a different refinement.", - "There are currently no listings for this product in . Try changing the condition type.", - "Actualmente, no hay listas para este producto en . Intenta cambiar el tipo de condición.", - "Derzeit gibt es keine Verkäufer, die diesen Artikel an Ihren Standort liefern können.", - "Actualmente, no hay vendedores que puedan entregar este producto en tu ubicación.", - "Il n’y a actuellement aucun vendeur en mesure de livrer ce produit sur votre zone géographique.", - "Il n'y a actuellement pas de produits répondant à ces critères. Essayez de changer les filtres.", - "No existen listados para esta búsqueda. Probar con otro filtro.", - "In gibt es derzeit keine Listungen für dieses Produkt. Versuchen Sie, den Zustandstyp zu ändern.", - "Al momento, non ci sono seller in grado di spedire questo articolo alla tua sede.", - "Al momento non ci sono offerte per questo prodotto in . Prova a modificare il tipo di condizione.", -] - -# OFFER_PAGE_TITLES = ["Amazon.com: Buying Choices:"] BUTTON_XPATHS = [ + '//input[@name="placeYourOrder1"]', '//*[@id="submitOrderButtonId"]/span/input', '//*[@id="bottomSubmitOrderButtonId"]/span/input', '//*[@id="placeYourOrder"]/span/input', @@ -173,7 +74,7 @@ # Prime popup # //*[@id="primeAutomaticPopoverAdContent"]/div/div/div[1]/a # //*[@id="primeAutomaticPopoverAdContent"]/div/div/div[1]/a - +FREE_SHIPPING_PRICE = parse_price("0.00") DEFAULT_MAX_CHECKOUT_LOOPS = 20 DEFAULT_MAX_PTC_TRIES = 3 @@ -187,6 +88,8 @@ DEFAULT_MAX_TIMEOUT = 10 DEFAULT_MAX_URL_FAIL = 5 +amazon_config = None + class Amazon: def __init__( @@ -200,10 +103,11 @@ def __init__( no_screenshots=False, disable_presence=False, slow_mode=False, - encryption_pass=None, no_image=False, + encryption_pass=None, log_stock_check=False, shipping_bypass=False, + alt_offers=False, ): self.notification_handler = notification_handler self.asin_list = [] @@ -213,6 +117,10 @@ def __init__( self.button_xpaths = BUTTON_XPATHS self.detailed = detailed self.used = used + if used: + self.condition = AmazonItemCondition.UsedAcceptable + else: + self.condition = AmazonItemCondition.New self.single_shot = single_shot self.take_screenshots = not no_screenshots self.start_time = time.time() @@ -227,9 +135,22 @@ def __init__( self.no_image = no_image self.log_stock_check = log_stock_check self.shipping_bypass = shipping_bypass + self.unknown_title_notification_sent = False + self.alt_offers = alt_offers presence.enabled = not disable_presence - presence.start_presence() + + global amazon_config + from cli.cli import global_config + + amazon_config = global_config.get_amazon_config(encryption_pass) + self.profile_path = global_config.get_browser_profile_path() + + try: + presence.start_presence() + except Exception in pyexceptions: + log.error("Discord presence failed to load") + presence.enabled = False # Create necessary sub-directories if they don't exist if not os.path.exists("screenshots"): @@ -244,18 +165,6 @@ def __init__( except: raise - if os.path.exists(CREDENTIAL_FILE): - credential = load_encrypted_config(CREDENTIAL_FILE, encryption_pass) - self.username = credential["username"] - self.password = credential["password"] - else: - log.info("No credential file found, let's make one") - log.info("NOTE: DO NOT SAVE YOUR CREDENTIALS IN CHROME, CLICK NEVER!") - credential = self.await_credential_input() - create_encrypted_config(credential, CREDENTIAL_FILE) - self.username = credential["username"] - self.password = credential["password"] - if os.path.exists(AUTOBUY_CONFIG_PATH): with open(AUTOBUY_CONFIG_PATH) as json_file: try: @@ -281,20 +190,16 @@ def __init__( ) exit(0) - if not self.create_driver(): + if not self.create_driver(self.profile_path): exit(1) for key in AMAZON_URLS.keys(): AMAZON_URLS[key] = AMAZON_URLS[key].format(domain=self.amazon_website) - - @staticmethod - def await_credential_input(): - username = input("Amazon login ID: ") - password = stdiomask.getpass(prompt="Amazon Password: ") - return { - "username": username, - "password": password, - } + if self.alt_offers: + log.info("Using alternate page for offer parsing.") + self.ACTIVE_OFFER_URL = AMAZON_URLS["ALT_OFFER_URL"] + else: + self.ACTIVE_OFFER_URL = AMAZON_URLS["OFFER_URL"] def run(self, delay=DEFAULT_REFRESH_DELAY, test=False): self.testing = test @@ -306,7 +211,7 @@ def run(self, delay=DEFAULT_REFRESH_DELAY, test=False): try: self.get_page(url=AMAZON_URLS["BASE_URL"]) break - except sel_exceptions: + except sel_exceptions.WebDriverException: log.error( "Couldn't talk to " + AMAZON_URLS["BASE_URL"] @@ -318,8 +223,9 @@ def run(self, delay=DEFAULT_REFRESH_DELAY, test=False): if cart_quantity > 0: log.warning(f"Found {cart_quantity} item(s) in your cart.") log.info("Delete all item(s) in cart before starting bot.") - log.info("Exiting now...") - time.sleep(5) + self.driver.get(AMAZON_URLS["CART_URL"]) + log.info("Exiting in 30 seconds...") + time.sleep(30) return self.handle_startup() if not self.is_logged_in(): @@ -331,8 +237,9 @@ def run(self, delay=DEFAULT_REFRESH_DELAY, test=False): if self.get_cart_count() > 0: log.warning(f"Found {cart_quantity} item(s) in your cart.") log.info("Delete all item(s) in cart before starting bot.") - log.info("Exiting now...") - time.sleep(5) + self.driver.get(AMAZON_URLS["CART_URL"]) + log.info("Exiting in 30 seconds...") + time.sleep(30) return keep_going = True @@ -415,8 +322,9 @@ def handle_startup(self): def is_logged_in(self): try: text = self.driver.find_element_by_id("nav-link-accountList").text - return not any(sign_in in text for sign_in in SIGN_IN_TEXT) + return not any(sign_in in text for sign_in in amazon_config["SIGN_IN_TEXT"]) except sel_exceptions.NoSuchElementException: + return False @debug @@ -442,12 +350,27 @@ def login(self): if email_field: try: - email_field.send_keys(self.username + Keys.RETURN) + email_field.send_keys(amazon_config["username"] + Keys.RETURN) except sel_exceptions.ElementNotInteractableException: log.info("Email not needed.") else: log.info("Email not needed.") + if "reverification" in self.driver.current_url: + log.warning( + "Beta code for allowing user to solve OTP. Please report success/failures " + "to #feature-testing on Discord" + ) + # Maybe/Probably/Likely a One Time Password prompt? Let's wait until the user takes action + self.notification_handler.play_alarm_sound() + log.error("One Time Password input required... pausing for user input") + try: + WebDriverWait(self.driver, timeout=300).until( + lambda d: "/ap/" not in d.driver.current_url + ) + except sel_exceptions.TimeoutException: + log.error("User did not solve One Time Password prompt in time.") + if self.driver.find_elements_by_xpath('//*[@id="auth-error-message-box"]'): log.error("Login failed, delete your credentials file") time.sleep(240) @@ -478,11 +401,11 @@ def login(self): captcha_entry = [] if password_field: - password_field.send_keys(self.password) + password_field.send_keys(amazon_config["password"]) # check for captcha try: captcha_entry = self.driver.find_element_by_xpath( - '//*[@id="auth-captcha-guess"]' + '//form[contains(@action,"validateCaptcha")]' ) except sel_exceptions.NoSuchElementException: password_field.send_keys(Keys.RETURN) @@ -491,34 +414,13 @@ def login(self): log.error("Password entry box did not exist") if captcha_entry: - try: - log.info("Stuck on a captcha... Lets try to solve it.") - captcha = AmazonCaptcha.fromdriver(self.driver) - solution = captcha.solve() - log.info(f"The solution is: {solution}") - if solution == "Not solved": - log.info( - f"Failed to solve {captcha.image_link}, lets reload and get a new captcha." - ) - self.driver.refresh() - else: - self.send_notification( - "Solving catpcha", "captcha", self.take_screenshots - ) - captcha_entry.send_keys(solution + Keys.RETURN) - self.wait_for_page_change(current_page) - - except Exception as e: - log.debug(e) - log.info("Error trying to solve captcha. Refresh and retry.") - self.driver.refresh() - time.sleep(5) - - if self.driver.title in TWOFA_TITLES: + self.handle_captcha(False) + if self.driver.title in amazon_config["TWOFA_TITLES"]: log.info("enter in your two-step verification code in browser") - while self.driver.title in TWOFA_TITLES: - time.sleep(0.2) - log.info(f"Logged in as {self.username}") + while self.driver.title in amazon_config["TWOFA_TITLES"]: + # Wait for the user to enter 2FA + time.sleep(2) + log.info(f'Logged in as {amazon_config["username"]}') @debug def run_asins(self, delay): @@ -539,26 +441,36 @@ def check_stock(self, asin, reserve_min, reserve_max, retry=0): if retry > DEFAULT_MAX_ATC_TRIES: log.info("max add to cart retries hit, returning to asin check") return False - if self.checkshipping: - if self.used: - f = furl(AMAZON_URLS["OFFER_URL"] + asin) + + if self.alt_offers: + if self.checkshipping: + if self.used: + f = furl(self.ACTIVE_OFFER_URL + asin) + else: + f = furl(self.ACTIVE_OFFER_URL + asin + "/ref=olp_f_new&f_new=true") else: - f = furl(AMAZON_URLS["OFFER_URL"] + asin + "/ref=olp_f_new&f_new=true") + if self.used: + f = furl(self.ACTIVE_OFFER_URL + asin + "/f_freeShipping=on") + else: + f = furl( + self.ACTIVE_OFFER_URL + + asin + + "/ref=olp_f_new&f_new=true&f_freeShipping=on" + ) else: - if self.used: - f = furl(AMAZON_URLS["OFFER_URL"] + asin + "/f_freeShipping=on") - else: - f = furl( - AMAZON_URLS["OFFER_URL"] - + asin - + "/ref=olp_f_new&f_new=true&f_freeShipping=on" - ) + # Force the flyout by default + f = furl(self.ACTIVE_OFFER_URL + asin + "/#aod") fail_counter = 0 presence.searching_update() + # handles initial page load only while True: try: self.get_page(f.url) + log.debug(f"Initial page title {self.driver.title}") + log.debug(f" page url: {self.driver.current_url}") + if self.driver.title in amazon_config["CAPTCHA_PAGE_TITLES"]: + self.handle_captcha() break except Exception: fail_counter += 1 @@ -581,7 +493,7 @@ def check_stock(self, asin, reserve_min, reserve_max, retry=0): take_screenshot=False, ) raise RuntimeError("Failed to restart bot") - elif not self.create_driver(): + elif not self.create_driver(self.profile_path): log.error("Failed to recreate webdriver processes") log.error("Please restart bot") self.send_notification( @@ -598,9 +510,155 @@ def check_stock(self, asin, reserve_min, reserve_max, retry=0): timeout = self.get_timeout() while True: - atc_buttons = self.driver.find_elements_by_xpath( - '//*[@name="submit.addToCart"]' + # Sanity check to see if we have any offers + try: + # Wait for the page to load before determining what's in it by looking for the footer + footer: WebElement = WebDriverWait( + self.driver, timeout=DEFAULT_MAX_TIMEOUT + ).until( + lambda d: d.find_elements_by_xpath( + "//div[@class='nav-footer-line'] | //img[@alt='Dogs of Amazon']" + ) + ) + if footer and footer[0].tag_name == "img": + log.info(f"Saw dogs for {asin}. Skipping...") + return False + + log.debug(f"After footer page title {self.driver.title}") + log.debug(f" page url: {self.driver.current_url}") + + offers = WebDriverWait(self.driver, timeout=DEFAULT_MAX_TIMEOUT).until( + lambda d: d.find_element_by_xpath( + "//div[@id='aod-container'] | " + "//div[@id='olpOfferList'] | " + "//div[@id='backInStock' or @id='outOfStock'] |" + "//span[@data-action='show-all-offers-display'] | " + "//input[@name='submit.add-to-cart' and not(//span[@data-action='show-all-offers-display'])]" + ) + ) + offer_count = [] + offer_id = offers.get_attribute("id") + if offer_id == "outOfStock" or offer_id == "backInStock": + # No dice... Early out and move on + log.info("Item is currently unavailable. Moving on...") + return False + + if offer_id == "olpOfferList": + # Offers Page ... count the 'a-row' classes to know how many offers we 'see' + offer_count = self.driver.find_elements_by_xpath( + "//div[@id='olpOfferList']//div[contains(@class, 'olpOffer')]" + ) + elif offer_id == "aod-container": + # Offer Flyout or Ajax call ... count the 'aod-offer' divs that we 'see' + offer_count = self.driver.find_elements_by_xpath( + "//div[@id='aod-pinned-offer' or @id='aod-offer']//input[@name='submit.addToCart']" + ) + elif offers.get_attribute("data-action") == "show-all-offers-display": + # PDP Page + # Find the offers link first, just to burn some cycles in case the flyout is loading + open_offers_link: WebElement = self.driver.find_element_by_xpath( + "//span[@data-action='show-all-offers-display']//a" + ) + + # Now check to see if we're already loading the flyout... + flyout = self.driver.find_elements_by_xpath( + "/html/body/div[@id='all-offers-display']" + ) + if flyout: + # This means we have a flyout already loading, as it gets inserted as the first + # div after the body tag of the document. Wait for the container to load and start + # the loop again to scan for known elements + log.debug( + "Found a loading flyout div. Waiting for offers to load..." + ) + WebDriverWait(self.driver, timeout=DEFAULT_MAX_TIMEOUT).until( + lambda d: d.find_element_by_xpath( + "//div[@id='aod-container'] " + ) + ) + continue + + log.debug("Attempting to click the open offers link...") + open_offers_link.click() + try: + # Now wait for the flyout to load + log.debug("Waiting for flyout...") + WebDriverWait(self.driver, timeout=DEFAULT_MAX_TIMEOUT).until( + lambda d: d.find_element_by_xpath( + "//div[@id='aod-container'] | //div[@id='olpOfferList']" + ) + ) + log.debug("Flyout should be open and populated.") + except sel_exceptions.TimeoutException as te: + log.error( + "Timed out waiting for the flyout to open and populate. Is the " + "connection slow? Do you see the flyout populate?" + ) + continue + elif ( + offers.get_attribute("aria-labelledby") + == "submit.add-to-cart-announce" + ): + # This assumes we're on a PDP with only an add to cart button... no offers + log.warning( + "NOT YET IMPLEMENTED: PDP represents only item worth considering. No other sellers available." + " TODO: Parse pricing and Add To Cart from PDP if item qualifies." + ) + else: + log.warning( + "We found elements, but didn't recognize any of the combinations." + ) + log.warning(f"Element found: {offers.tag_name}") + attrs = self.driver.execute_script( + "var items = {}; " + "for (index = 0; index < arguments[0].attributes.length; ++index) " + "{ items[arguments[0].attributes[index].name] = arguments[0].attributes[index].value }; " + "return items;", + offers, + ) + log.warning("Dumping element attributes:") + for attr in attrs: + log.warning(f"{attr} = {attrs[attr]}") + + return False + if len(offer_count) == 0: + log.info("No offers found. Moving on.") + return False + log.info( + f"Found {len(offer_count)} offers for {asin}. Evaluating offers..." + ) + + except sel_exceptions.TimeoutException as te: + log.error("Timed out waiting for offers to render. Skipping...") + log.error(f"URL: {self.driver.current_url}") + log.exception(te) + return False + except sel_exceptions.NoSuchElementException: + log.error("Unable to find any offers listing. Skipping...") + return False + except sel_exceptions.ElementClickInterceptedException as e: + log.debug( + "Covering element detected... Assuming it's a slow flyout... scanning document again..." + ) + continue + + atc_buttons: WebElement = self.driver.find_elements_by_xpath( + "//div[@id='aod-pinned-offer' or @id='aod-offer' or @id='olpOfferList']//input[@name='submit.addToCart']" ) + # if not atc_buttons: + # # Sanity check to see if we have a valid page, but no offers: + # offer_count = WebDriverWait(self.driver, timeout=25).until( + # lambda d: d.find_element_by_xpath( + # "//div[@id='aod-offer-list']//input[@id='aod-total-offer-count']" + # ) + # ) + # + # # offer_count = self.driver.find_element_by_xpath( + # # "//div[@id='aod-offer-list']//input[@id='aod-total-offer-count']" + # # ) + # if offer_count.get_attribute("value") == "0": + # log.info("Found zero offers explicitly. Moving to next ASIN.") + # return False if atc_buttons: # Early out if we found buttons break @@ -613,49 +671,102 @@ def check_stock(self, asin, reserve_min, reserve_max, retry=0): except sel_exceptions.NoSuchElementException: pass - if test and (test.text in NO_SELLERS): + if test and (test.text in amazon_config["NO_SELLERS"]): return False if time.time() > timeout: log.info(f"failed to load page for {asin}, going to next ASIN") return False timeout = self.get_timeout() + flyout_mode = False while True: prices = self.driver.find_elements_by_xpath( '//*[@class="a-size-large a-color-price olpOfferPrice a-text-bold"]' ) + if not prices: + # Try the flyout x-paths + prices = self.driver.find_elements_by_xpath( + "//div[@id='aod-pinned-offer' or @id='aod-offer']//div[contains(@id, 'aod-price')]//span[@class='a-price']//span[@class='a-offscreen']" + ) + if prices: + flyout_mode = True + break if prices: break if time.time() > timeout: log.info(f"failed to load prices for {asin}, going to next ASIN") return False shipping = [] + shipping_prices = [] if self.checkshipping: timeout = self.get_timeout() while True: - shipping = self.driver.find_elements_by_xpath( - '//*[@class="a-color-secondary"]' - ) + if not flyout_mode: + shipping = self.driver.find_elements_by_xpath( + '//*[@class="a-color-secondary"]' + ) if shipping: + # Convert to prices just in case + for idx, shipping_node in enumerate(shipping): + log.debug(f"Processing shipping node {idx}") + if self.checkshipping: + if amazon_config["SHIPPING_ONLY_IF"] in shipping_node.text: + shipping_prices.append(parse_price("0")) + else: + shipping_prices.append(parse_price(shipping_node.text)) + else: + shipping_prices.append(parse_price("0")) + else: + # Check for offers + offers = self.driver.find_elements_by_xpath( + "//div[@id='aod-pinned-offer' or @id='aod-offer']" + ) + for idx, offer in enumerate(offers): + tree = html.fromstring(offer.get_attribute("innerHTML")) + shipping_prices.append( + get_shipping_costs(tree, amazon_config["FREE_SHIPPING"]) + ) + if shipping_prices: break + if time.time() > timeout: log.info(f"failed to load shipping for {asin}, going to next ASIN") return False in_stock = False + for shipping_price in shipping_prices: + log.debug(f"\tShipping Price: {shipping_price}") for idx, atc_button in enumerate(atc_buttons): + # Condition check first, using the button to find the form that will divulge the item's condition + if flyout_mode: + condition: WebElement = atc_button.find_elements_by_xpath( + "./ancestor::form[@method='post']" + ) + + if condition: + atc_form_action = condition[0].get_attribute("action") + item_condition = get_item_condition(atc_form_action) + # Lower condition value imply newer + if item_condition.value > self.condition.value: + # Item is below our standards, so skip it + log.debug( + f"Skipping item because its condition is below the requested level: " + f"{item_condition} is below {self.condition}" + ) + continue + try: - price = parse_price(prices[idx].text) + if flyout_mode: + price = parse_price(prices[idx].get_attribute("innerHTML")) + else: + price = parse_price(prices[idx].text) except IndexError: log.debug("Price index error") return False try: if self.checkshipping: - if SHIPPING_ONLY_IF in shipping[idx].text: - ship_price = parse_price("0") - else: - ship_price = parse_price(shipping[idx].text) + ship_price = shipping_prices[idx] else: ship_price = parse_price("0") except IndexError: @@ -676,42 +787,100 @@ def check_stock(self, asin, reserve_min, reserve_max, retry=0): or math.isclose((price_float + ship_float), reserve_min, abs_tol=0.01) ): log.info("Item in stock and in reserve range!") - log.info("clicking add to cart") - self.notification_handler.play_notify_sound() - if self.detailed: - self.send_notification( - message=f"Found Stock ASIN:{asin}", - page_name="Stock Alert", - take_screenshot=self.take_screenshots, - ) - - presence.buy_update() - current_title = self.driver.title - # log.info(f"current page title is {current_title}") - try: - atc_button.click() - except IndexError: - log.debug("Index Error") - return False - self.wait_for_page_change(current_title) - # log.info(f"page title is {self.driver.title}") - if self.driver.title in SHOPING_CART_TITLES: - return True + log.info("Adding to cart") + # Get the offering ID + offering_id_elements = atc_button.find_elements_by_xpath( + "./preceding::input[@name='offeringID.1'][1]" + ) + if offering_id_elements: + log.info("Attempting Add To Cart with offer ID...") + offering_id = offering_id_elements[0].get_attribute("value") + if self.attempt_atc( + offering_id, max_atc_retries=DEFAULT_MAX_ATC_TRIES + ): + return True + else: + self.send_notification( + "Failed Add to Cart after {max-atc-retries}", + "failed-atc", + self.take_screenshots, + ) + self.save_page_source("failed-atc") + return False else: - log.info("did not add to cart, trying again") - log.debug(f"failed title was {self.driver.title}") - self.send_notification( - "Failed Add to Cart", "failed-atc", self.take_screenshots + log.error( + "Unable to find offering ID to add to cart. Using legacy mode." ) - self.save_page_source("failed-atc") - in_stock = self.check_stock( - asin=asin, - reserve_max=reserve_max, - reserve_min=reserve_min, - retry=retry + 1, + self.notification_handler.play_notify_sound() + if self.detailed: + self.send_notification( + message=f"Found Stock ASIN:{asin}", + page_name="Stock Alert", + take_screenshot=self.take_screenshots, + ) + + presence.buy_update() + current_title = self.driver.title + # log.info(f"current page title is {current_title}") + try: + atc_button.click() + except IndexError: + log.debug("Index Error") + return False + self.wait_for_page_change(current_title) + # log.info(f"page title is {self.driver.title}") + emtpy_cart_elements = self.driver.find_elements_by_xpath( + "//div[contains(@class, 'sc-your-amazon-cart-is-empty') or contains(@class, 'sc-empty-cart')]" ) + + if ( + not emtpy_cart_elements + and self.driver.title in amazon_config["SHOPPING_CART_TITLES"] + ): + return True + else: + log.info("did not add to cart, trying again") + if emtpy_cart_elements: + log.info( + "Cart appeared empty after clicking Add To Cart button" + ) + log.debug(f"failed title was {self.driver.title}") + self.send_notification( + "Failed Add to Cart", "failed-atc", self.take_screenshots + ) + self.save_page_source("failed-atc") + in_stock = self.check_stock( + asin=asin, + reserve_max=reserve_max, + reserve_min=reserve_min, + retry=retry + 1, + ) return in_stock + def attempt_atc(self, offering_id, max_atc_retries=DEFAULT_MAX_ATC_TRIES): + # Open the add.html URL in Selenium + f = f"{AMAZON_URLS['ATC_URL']}?OfferListingId.1={offering_id}&Quantity.1=1" + atc_attempts = 0 + while atc_attempts < max_atc_retries: + with self.wait_for_page_content_change(timeout=5): + self.driver.get(f) + xpath = "//input[@value='add' and @name='add']" + if wait_for_element_by_xpath(self.driver, xpath): + try: + with self.wait_for_page_content_change(timeout=10): + self.driver.find_element_by_xpath(xpath).click() + except sel_exceptions.NoSuchElementException: + log.error("Continue button not present on page") + else: + log.error("Continue button not present on page") + + # verify cart is non-zero + if self.get_cart_count() != 0: + return True + else: + atc_attempts = atc_attempts + 1 + return False + # search lists of asin lists, and remove the first list that matches provided asin @debug def remove_asin_list(self, asin): @@ -729,6 +898,7 @@ def navigate_pages(self, test): # time.sleep(self.page_wait_delay()) title = self.driver.title + log.info(f"Navigating page title: '{title}'") # see if this resolves blank page title issue? if title == "": timeout_seconds = DEFAULT_MAX_TIMEOUT @@ -744,27 +914,45 @@ def navigate_pages(self, test): if time.time() > timeout: log.debug("Time out reached, page title was still blank.") break - if title in SIGN_IN_TITLES: + if title in amazon_config["SIGN_IN_TITLES"]: self.login() - elif title in CAPTCHA_PAGE_TITLES: + elif title in amazon_config["CAPTCHA_PAGE_TITLES"]: self.handle_captcha() - elif title in SHOPING_CART_TITLES: + elif title in amazon_config["SHOPPING_CART_TITLES"]: self.handle_cart() - elif title in CHECKOUT_TITLES: + elif title in amazon_config["CHECKOUT_TITLES"]: self.handle_checkout(test) - elif title in ORDER_COMPLETE_TITLES: + elif title in amazon_config["ORDER_COMPLETE_TITLES"]: self.handle_order_complete() - elif title in PRIME_TITLES: + elif title in amazon_config["PRIME_TITLES"]: self.handle_prime_signup() - elif title in HOME_PAGE_TITLES: + elif title in amazon_config["HOME_PAGE_TITLES"]: # if home page, something went wrong self.handle_home_page() - elif title in DOGGO_TITLES: + elif title in amazon_config["DOGGO_TITLES"]: self.handle_doggos() - elif title in OUT_OF_STOCK: + elif title in amazon_config["OUT_OF_STOCK"]: self.handle_out_of_stock() - elif title in BUSINESS_PO_TITLES: + elif title in amazon_config["BUSINESS_PO_TITLES"]: self.handle_business_po() + elif title in amazon_config["ADDRESS_SELECT"]: + if not self.unknown_title_notification_sent: + self.notification_handler.play_alarm_sound() + self.send_notification( + "User interaction required for checkout!", + title, + self.take_screenshots, + ) + self.unknown_title_notification_sent = True + log.warning( + "Landed on address selection screen. Fairgame will NOT select an address for you. " + "Please select necessary options to arrive at the Review Order Page before the next " + "refresh, or complete checkout manually. You have 30 seconds." + ) + for i in range(30, 0, -1): + log.warning(f"{i}...") + time.sleep(1) + return else: log.debug(f"title is: [{title}]") # see if we can handle blank titles here @@ -849,7 +1037,7 @@ def navigate_pages(self, test): log.info("Clicked button.") self.wait_for_page_change(page_title=title) return - except sel_exceptions: + except sel_exceptions.WebDriverException: log.error("Could not click ship to address button") if self.get_cart_count() == 0: @@ -866,10 +1054,10 @@ def navigate_pages(self, test): # try to handle an unknown title log.error( - f"{title} is not a known title, please create issue indicating the title with a screenshot of page" + f"'{title}' is not a known page title. Please create issue indicating the title with a screenshot of page" ) self.send_notification( - "Encountered Unknown Page Title", + f"Encountered Unknown Page Title: `{title}", "unknown-title", self.take_screenshots, ) @@ -877,7 +1065,7 @@ def navigate_pages(self, test): log.info("going to try and redirect to cart page") try: self.driver.get(AMAZON_URLS["CART_URL"]) - except sel_exceptions: + except sel_exceptions.WebDriverException: log.error( "failed to load cart URL, refreshing and returning to handler" ) @@ -920,7 +1108,7 @@ def navigate_pages(self, test): button.click() log.info("Clicked ptc button") self.wait_for_page_change(page_title=current_title) - except sel_exceptions: + except sel_exceptions.WebDriverException: log.info( "Could not click button - refreshing and returning to checkout handler" ) @@ -974,7 +1162,7 @@ def handle_prime_signup(self): "Prime offer page popped up, user intervention required" ) timeout = self.get_timeout(timeout=60) - while self.driver.title in PRIME_TITLES: + while self.driver.title in amazon_config["PRIME_TITLES"]: if time.time() > timeout: log.info( "user did not intervene in time, will try and refresh page" @@ -1014,7 +1202,7 @@ def handle_home_page(self): @debug def handle_cart(self): self.start_time_atc = time.time() - log.info("clicking checkout.") + log.info("Looking for Proceed To Checkout button...") try: self.save_screenshot("ptc-page") except: @@ -1024,7 +1212,7 @@ def handle_cart(self): while True: try: button = self.driver.find_element_by_xpath( - '//*[@id="hlb-ptc-btn-native"]' + '//*[@id="hlb-ptc-btn-native"] | //input[@name="proceedToRetailCheckout"]' ) break except sel_exceptions.NoSuchElementException: @@ -1056,18 +1244,18 @@ def handle_cart(self): current_page = self.driver.title if button: + log.info("Found Checkout Button") if self.detailed: self.send_notification( message="Attempting to Proceed to Checkout", page_name="ptc", take_screenshot=self.take_screenshots, ) - log.info("Found Checkout Button") try: button.click() log.info("Clicked Proceed to Checkout Button") - self.wait_for_page_change(page_title=current_page) - except sel_exceptions: + self.wait_for_page_change(page_title=current_page, timeout=7) + except sel_exceptions.WebDriverException: log.error("Problem clicking Proceed to Checkout button.") log.info("Refreshing page to try again") self.driver.refresh() @@ -1139,13 +1327,14 @@ def handle_out_of_stock(self): self.try_to_checkout = False @debug - def handle_captcha(self): + def handle_captcha(self, check_presence=True): # wait for captcha to load + log.debug("Waiting for captcha to load.") time.sleep(DEFAULT_MAX_WEIRD_PAGE_DELAY) current_page = self.driver.title try: - if self.driver.find_element_by_xpath( - '//form[@action="/errors/validateCaptcha"]' + if not check_presence or self.driver.find_element_by_xpath( + '//form[contains(@action,"validateCaptcha")]' ): try: log.info("Stuck on a captcha... Lets try to solve it.") @@ -1226,6 +1415,16 @@ def save_page_source(self, page): with open(file_name, "w", encoding="utf-8") as f: f.write(page_source) + @contextmanager + def wait_for_page_content_change(self, timeout=30): + """Utility to help manage selenium waiting for a page to load after an action, like a click""" + old_page = self.driver.find_element_by_tag_name("html") + yield + WebDriverWait(self.driver, timeout).until(EC.staleness_of(old_page)) + WebDriverWait(self.driver, timeout).until( + EC.presence_of_element_located((By.XPATH, "//title")) + ) + def wait_for_page_change(self, page_title, timeout=3): time_to_end = self.get_timeout(timeout=timeout) while time.time() < time_to_end and ( @@ -1258,7 +1457,7 @@ def get_webdriver_pids(self): self.webdriver_child_pids.append(child.pid) def get_page(self, url): - check_cart_element = [] + check_cart_element = None current_page = [] try: check_cart_element = self.driver.find_element_by_xpath( @@ -1292,10 +1491,13 @@ def __del__(self): def show_config(self): log.info(f"{'=' * 50}") - log.info(f"Starting Amazon ASIN Hunt for {len(self.asin_list)} Products with:") + log.info( + f"Starting Amazon ASIN Hunt on {AMAZON_URLS['BASE_URL']} for {len(self.asin_list)} Products with:" + ) + log.info(f"--Offer URL of: {self.ACTIVE_OFFER_URL}") log.info(f"--Delay of {self.refresh_delay} seconds") if self.headless: - log.info(f"--Headless doesn't work!") + log.info(f"--Chrome is running in Headless mode") if self.used: log.info(f"--Used items are considered for purchase") if self.checkshipping: @@ -1303,7 +1505,7 @@ def show_config(self): else: log.info(f"--Free Shipping items only") if self.single_shot: - log.info("\tSingle Shot purchase enabled") + log.info("--Single Shot purchase enabled") if not self.take_screenshots: log.info( f"--Screenshotting is Disabled, DO NOT ASK FOR HELP IN TECH SUPPORT IF YOU HAVE NO SCREENSHOTS!" @@ -1312,8 +1514,6 @@ def show_config(self): log.info(f"--Detailed screenshots/notifications is enabled") if self.log_stock_check: log.info(f"--Additional stock check logging enabled") - if self.testing: - log.warning(f"--Testing Mode. NO Purchases will be made.") if self.slow_mode: log.warning(f"--Slow-mode enabled. Pages will fully load before execution.") if self.shipping_bypass: @@ -1332,18 +1532,24 @@ def show_config(self): log.info( f"--Looking for {len(asins)} ASINs between {self.reserve_min[idx]:.2f} and {self.reserve_max[idx]:.2f}" ) + if not presence.enabled: + log.info(f"--Discord Presence feature is disabled.") + if self.no_image: + log.info(f"--No images will be requested") + if not self.notification_handler.sound_enabled: + log.info(f"--Notification sounds are disabled.") + if self.ACTIVE_OFFER_URL == AMAZON_URLS["ALT_OFFER_URL"]: + log.info(f"--Using alternate offers URL") + if self.testing: + log.warning(f"--Testing Mode. NO Purchases will be made.") log.info(f"{'=' * 50}") - def create_driver(self): + def create_driver(self, path_to_profile): if self.setup_driver: if self.headless: enable_headless() - # profile_amz = ".profile-amz" - # # keep profile bloat in check - # if os.path.isdir(profile_amz): - # os.remove(profile_amz) prefs = { "profile.password_manager_enabled": False, "credentials_enable_service": False, @@ -1353,7 +1559,7 @@ def create_driver(self): else: prefs["profile.managed_default_content_settings.images"] = 0 options.add_experimental_option("prefs", prefs) - options.add_argument(f"user-data-dir=.profile-amz") + options.add_argument(f"user-data-dir={path_to_profile}") if not self.slow_mode: options.set_capability("pageLoadStrategy", "none") @@ -1361,8 +1567,7 @@ def create_driver(self): # Delete crashed, so restore pop-up doesn't happen path_to_prefs = os.path.join( - os.path.dirname(os.path.abspath("__file__")), - ".profile-amz", + path_to_profile, "Default", "Preferences", ) @@ -1379,7 +1584,7 @@ def create_driver(self): except Exception as e: log.error(e) log.error( - "If you have a JSON warning above, try deleting your .profile-amz folder" + "If you have a JSON warning above, try cleaning your profile (e.g. --clean-profile)" ) log.error( "If that's not it, you probably have a previous Chrome window open. You should close it." @@ -1423,3 +1628,168 @@ def get_timestamp_filename(name, extension): return name + "_" + date + extension else: return name + "_" + date + "." + extension + + +def get_shipping_costs(tree, free_shipping_string) -> Price: + # Assume Free Shipping and change otherwise + + # Shipping collection xpath: + # .//div[starts-with(@id, 'aod-bottlingDepositFee-')]/following-sibling::span + shipping_nodes = tree.xpath( + ".//div[starts-with(@id, 'aod-bottlingDepositFee-')]/following-sibling::*[1]" + ) + count = len(shipping_nodes) + log.debug(f"Found {count} shipping nodes.") + if count == 0: + log.warning("No shipping nodes found. Assuming zero.") + return FREE_SHIPPING_PRICE + elif count > 1: + log.warning("Found multiple shipping nodes. Using the first.") + + shipping_node = shipping_nodes[0] + # Shipping information is found within either a DIV or a SPAN following the bottleDepositFee DIV + # What follows is logic to parse out the various pricing formats within the HTML. Not ideal, but + # it's what we have to work with. + if shipping_node.text: + shipping_span_text = shipping_node.text.strip() + else: + shipping_span_text = "" + if shipping_node.tag == "div": + # Do we have any spans outlining the price? Typically seen like this: + #
+ # + + # S$21.44 + # shipping + #
+ shipping_spans = shipping_node.xpath(".//span") + if shipping_spans: + log.debug( + f"Found {len(shipping_spans)} shipping SPANs within the shipping DIV" + ) + # Look for a price + for shipping_span in shipping_spans: + if shipping_span.text and shipping_span.text != "+": + shipping_cost: Price = parse_price(shipping_span.text) + if shipping_cost.currency is not None: + log.debug( + f"Found parseable price with currency symbol: {shipping_cost.currency}" + ) + return shipping_cost + + if shipping_span_text == "": + # Assume zero shipping for an empty div + log.debug( + "Empty div found after bottleDepositFee. Assuming zero shipping." + ) + else: + # Assume zero shipping for unknown values in + log.warning( + f"Non-Empty div found after bottleDepositFee. Assuming zero. Stripped Value: '{shipping_span_text}'" + ) + elif shipping_node.tag == "span": + # Shipping values in the span are contained in: + # - another SPAN + # - hanging out alone in a B tag + # - Hanging out alone in an I tag + # - Nested in two I tags + # - "Prime FREE Delivery" in this node + + shipping_spans = shipping_node.findall("span") + shipping_bs = shipping_node.findall("b") + # shipping_is = shipping_node.findall("i") + shipping_is = shipping_node.xpath("//i[@aria-label]") + if len(shipping_spans) > 0: + # If the span starts with a "& " it's free shipping (right?) + if shipping_spans[0].text.strip() == "&": + # & Free Shipping message + log.debug("Found '& Free', assuming zero.") + elif shipping_spans[0].text.startswith("+"): + return parse_price(shipping_spans[0].text.strip()) + elif len(shipping_bs) > 0: + for message_node in shipping_bs: + + if message_node.text.upper() in free_shipping_string: + log.debug("Found free shipping string.") + else: + log.error( + f"Couldn't parse price from . Assuming 0. Do we need to add: '{message_node.text.upper()}'" + ) + elif len(shipping_is) > 0: + # If it has prime icon class, assume free Prime shipping + if "FREE" in shipping_is[0].attrib["aria-label"].upper(): + log.debug("Found Free shipping with Prime") + elif any( + shipping_span_text.upper() in free_message + for free_message in amazon_config["FREE_SHIPPING"] + ): + # We found some version of "free" inside the span.. but this relies on a match + log.warning( + f"Assuming free shipping based on this message: '{shipping_span_text}'" + ) + else: + log.error( + f"Unable to locate price. Assuming 0. Found this: '{shipping_span_text}' Consider reporting to #tech-support Discord." + ) + return FREE_SHIPPING_PRICE + + +class AmazonItemCondition(Enum): + # See https://sellercentral.amazon.com/gp/help/external/200386310?language=en_US&ref=efph_200386310_cont_G1831 + New = 10 + Renewed = 20 + Refurbished = 20 + Rental = 30 + Open_box = 40 + UsedLikeNew = 40 + UsedVeryGood = 50 + UsedGood = 60 + UsedAcceptable = 70 + CollectibleLikeNew = 40 + CollectibleVeryGood = 50 + CollectibleGood = 60 + CollectibleAcceptable = 70 + Unknown = 1000 + + @classmethod + def from_str(cls, label): + # Straight lookup + try: + condition = AmazonItemCondition[label] + return condition + except KeyError: + # Key doesn't exist as a Member, so try cleaning up the string + cleaned_label = "".join(label.split()) + cleaned_label = cleaned_label.replace("-", "") + try: + condition = AmazonItemCondition[cleaned_label] + return condition + except KeyError: + raise NotImplementedError + + +def get_item_condition(form_action) -> AmazonItemCondition: + """ Attempts to determine the Item Condition from the Add To Cart form action """ + if "_new_" in form_action: + # log.debug(f"Item condition is new") + return AmazonItemCondition.New + elif "_used_" in form_action: + # log.debug(f"Item condition is used") + return AmazonItemCondition.UsedGood + elif "_col_" in form_action: + # og.debug(f"Item condition is collectible") + return AmazonItemCondition.CollectibleGood + else: + # log.debug(f"Item condition is unknown: {form_action}") + return AmazonItemCondition.Unknown + + +def wait_for_element_by_xpath(d, xpath, timeout=10): + try: + WebDriverWait(d, timeout).until( + EC.presence_of_element_located((By.XPATH, xpath)) + ) + except sel_exceptions.TimeoutException: + log.error(f"failed to find {xpath}") + return False + + return True diff --git a/stores/bestbuy.py b/stores/bestbuy.py index cff26cda..a0a7cddd 100644 --- a/stores/bestbuy.py +++ b/stores/bestbuy.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import json import webbrowser from time import sleep diff --git a/stores/nvidia.py b/stores/nvidia.py index eefd3b9e..7ac1137b 100644 --- a/stores/nvidia.py +++ b/stores/nvidia.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import concurrent import json import webbrowser diff --git a/utils/__init__.py b/utils/__init__.py index e69de29b..c5e3c3e3 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -0,0 +1,18 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame diff --git a/utils/debugger.py b/utils/debugger.py index fc7dabab..d17ba596 100644 --- a/utils/debugger.py +++ b/utils/debugger.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + from utils.logger import log import functools diff --git a/utils/discord_presence.py b/utils/discord_presence.py index 202d5a80..172adf53 100644 --- a/utils/discord_presence.py +++ b/utils/discord_presence.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import time from pypresence import Presence diff --git a/utils/encryption.py b/utils/encryption.py index a9b20c55..92516cda 100644 --- a/utils/encryption.py +++ b/utils/encryption.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import getpass as getpass import stdiomask import json diff --git a/utils/http.py b/utils/http.py index ab565bbb..225d45a7 100644 --- a/utils/http.py +++ b/utils/http.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry diff --git a/utils/json_utils.py b/utils/json_utils.py index 7ce9188f..fd5b58df 100644 --- a/utils/json_utils.py +++ b/utils/json_utils.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import json diff --git a/utils/logger.py b/utils/logger.py index 14e0d38d..801a00ae 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -1,9 +1,30 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import coloredlogs import logging import os - +from utils.version import version from logging import handlers +FORMAT = "%(asctime)s|{}|%(levelname)s|%(message)s".format(version) + LOG_DIR = "logs" LOG_FILE_NAME = "fairgame.log" if not os.path.exists(LOG_DIR): @@ -20,7 +41,9 @@ if os.path.isfile(LOG_FILE_PATH): # Create a transient handler to do the rollover for us on startup. This won't # be added to the logger as a handler... just used to roll the log on startup. - rollover_handler = handlers.RotatingFileHandler(LOG_FILE_PATH, backupCount=10) + rollover_handler = handlers.RotatingFileHandler( + LOG_FILE_PATH, backupCount=10, maxBytes=100 * 1024 * 1024 + ) # Prior log file exists, so roll it to get a clean log for this run try: rollover_handler.doRollover() @@ -31,7 +54,7 @@ logging.basicConfig( filename=LOG_FILE_PATH, level=logging.DEBUG, - format='%(levelname)s: "%(asctime)s - %(message)s', + format=FORMAT, ) log = logging.getLogger("fairgame") @@ -39,10 +62,8 @@ LOGLEVEL = os.environ.get("LOGLEVEL", "INFO").upper() stream_handler = logging.StreamHandler() -stream_handler.setFormatter( - logging.Formatter('%(levelname)s: "%(asctime)s - %(message)s') -) +stream_handler.setFormatter(logging.Formatter(FORMAT)) log.addHandler(stream_handler) -coloredlogs.install(LOGLEVEL, logger=log) +coloredlogs.install(LOGLEVEL, logger=log, fmt=FORMAT) diff --git a/utils/selenium_utils.py b/utils/selenium_utils.py index 2dd62bb1..d7121fa6 100644 --- a/utils/selenium_utils.py +++ b/utils/selenium_utils.py @@ -1,3 +1,22 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import requests from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.action_chains import ActionChains diff --git a/utils/version.py b/utils/version.py index 138e96c2..d275ce97 100644 --- a/utils/version.py +++ b/utils/version.py @@ -1,24 +1,53 @@ +# FairGame - Automated Purchasing Program +# Copyright (C) 2021 Hari Nagarajan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The author may be contacted through the project's GitHub, at: +# https://github.com/Hari-Nagarajan/fairgame + import requests +from packaging.version import Version, parse, InvalidVersion + +_LATEST_URL = "https://api.github.com/repos/Hari-Nagarajan/fairgame/releases/latest" + +# Use a Version object to gain additional version identification capabilities +# See https://github.com/pypa/packaging for details +# See https://www.python.org/dev/peps/pep-0440/ for specification +# See https://www.python.org/dev/peps/pep-0440/#examples-of-compliant-version-schemes for examples + +__VERSION = "0.6.0" +version = Version(__VERSION) -from utils.logger import log -LATEST_URL = "https://api.github.com/repos/Hari-Nagarajan/fairgame/releases/latest" +def is_latest(): + remote_version = get_latest_version() -version = "0.5.4" + if version < remote_version: + return False + elif version.is_prerelease: + return False + else: + return True -def check_version(): +def get_latest_version(): try: - r = requests.get(LATEST_URL) + r = requests.get(_LATEST_URL) data = r.json() - remote_version = str(data["tag_name"]) - - if version < remote_version: - log.warning( - f"You are running FairGame v{version}, but the most recent version is v{remote_version}... Consider upgrading" - ) - else: - log.info(f"FairGame v{version}") - except: - log.error("Failed version check. Continuing execution with mystery code.") - pass + latest_version = parse(str(data["tag_name"])) + except InvalidVersion: + # Return a safe, but wrong version + latest_version = parse("0.0") + return latest_version