diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7216c9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Prevent users from accidentally committing secrets. +.venv/ + +.idea/ diff --git a/MCSA.py b/MCSA.py index 0a1cf5d..a86b088 100644 --- a/MCSA.py +++ b/MCSA.py @@ -2,37 +2,55 @@ import sys import time import smtplib +from datetime import datetime as dt from dotenv import load_dotenv from selenium import webdriver from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By from webdriver_manager.chrome import ChromeDriverManager -def set_store_cookie(driver): - # Get the main store page URL so you don't overload the product webpage. Selenium limitation - driver.get("https://www.microcenter.com") +STORES = { + 'Tustin': '101', + 'Denver': '181', + 'Miami': '185', + 'Duluth': '065', + 'Marietta': '041', + 'Chicago': '151', + 'Westmont': '025', + 'Indianapolis': '165', + 'Overland Park': '191', + 'Cambridge': '121', + 'Rockville': '085', + 'Parkville': '125', + 'Madison Heights': '055', + 'St. Louis Park': '045', + 'Brentwood': '095', + 'Charlotte': '175', + 'North Jersey': '075', + 'Westbury': '171', + 'Brooklyn': '115', + 'Flushing': '145', + 'Yonkers': '105', + 'Columbus': '141', + 'Mayfield Heights': '051', + 'Sharonville': '071', + 'St. Davids': '061', + 'Houston': '155', + 'Dallas': '131', + 'Fairfax': '081', +} - # Wait for the page to load, adjust as needed (seconds) - time.sleep(0) +# Update this with whatever products you want to monitor. +PRODUCTS = { + 'fan': 'https://www.microcenter.com/product/667290/lian-li-uni-fan-reverse-sl-infinity-fluid-dynamic-bearing-120mm-case-fan-white', + 'cpu': 'https://www.microcenter.com/product/687907/amd-ryzen-7-9800x3d-granite-ridge-am5-470ghz-8-core-boxed-processor-heatsink-not-included', +} - # Set the storeSelected cookie. Default: 131 (Dallas store) - driver.add_cookie({ - 'name': 'storeSelected', - 'value': '131', - 'domain': '.microcenter.com', - 'path': '/', - 'secure': True, - 'httpOnly': False - }) - # Get the product page URL - driver.get("https://www.microcenter.com/product/687907/amd-ryzen-7-9800x3d-granite-ridge-am5-470ghz-8-core-boxed-processor-heatsink-not-included") - - # Wait for the page to load, adjust as needed (seconds) - time.sleep(0) -def prompt(): +def prompt_for_email_details(): # Prompts the user for email and password securely print("Security Disclaimer:\n" "Your personal information can only be accessed by a system administrator while the program is running.\n" @@ -48,24 +66,22 @@ def prompt(): os.environ["email"] = input("Enter your email: ") os.environ["password"] = input("Enter your password: ") -def test_email(): - # Send the test email + +def send_test_email(): try: # Get the environment variables email_user = os.getenv("email") email_password = os.getenv("password") - # Confirm whether email credentials were entered - if not email_user or not email_password: - raise Exception("Test email credentials not found") - # Set up the email msg = MIMEMultipart() msg['From'] = email_user msg['To'] = email_user msg['Subject'] = 'Test Email Successful' - msg.attach(MIMEText('This is a test email to confirm that your email login is correctly working alongside SMTP within the Microcenter Stock Python Application.\n\n' - 'You may now let the application run in the background while it actively checks stock of your selected Microcenter product.','plain')) + msg.attach(MIMEText( + 'This is a test email to confirm that your email login is correctly working alongside SMTP within the Microcenter Stock Python Application.\n\n' + 'You may now let the application run in the background while it actively checks stock of your selected Microcenter product.', + 'plain')) # Connect to the email server and send the email with smtplib.SMTP("smtp.gmail.com", 587) as server: @@ -73,29 +89,94 @@ def test_email(): server.login(email_user, email_password) server.sendmail(email_user, email_user, msg.as_string()) - print("Test email sent successfully!") - - # Test email exception + print(f"[{dt.now()}] Test email sent successfully!") except Exception as e: - print(f"Failed to send test email: {e}") - print("Application will continue without user email") + print(f"[{dt.now()}] Failed to send test email: {e}") + print(f"[{dt.now()}] Application will continue without user email") + + +def prompt_for_product(): + print(f"The following products are enabled: {', '.join(PRODUCTS.keys())}") + + os.environ["selected_product"] = input('Select your desired product: ').strip().lower() + + while not os.getenv("selected_product") or os.getenv("selected_product") not in PRODUCTS: + print('Invalid product selected') + os.environ["selected_product"] = input('Select your desired product: ').strip().lower() + + +# Helper method to generate the list of stores to their IDs if needed. +# Since Micro Center store locations are pretty static, +# we can just run this once and update the mapping. +def get_stores(): + # Set up Chrome options to enable headless mode + chrome_options = Options() + chrome_options.add_argument("--headless") # Enable headless mode + chrome_options.add_argument("--disable-gpu") # Disable GPU acceleration + chrome_options.add_argument("--no-sandbox") # Fix potential environment issues + + driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=chrome_options) + driver.get('https://www.microcenter.com') + + # Wait for the page to load, adjust as needed (seconds) + time.sleep(1) + + store_selector_elements = driver.find_elements(By.CSS_SELECTOR, + '.changeMyStore ul.dropdown-menu li.dropdown-itemLI') + stores = {} + for store_element in store_selector_elements: + store_name = store_element.find_element(By.CLASS_NAME, 'storeName').get_attribute('innerText') + store_id = store_element.get_attribute('class').split()[-1].split('_')[ + -1] # e.g., 'store_041' -> ['store', '041'] + stores[store_name] = store_id + + driver.quit() + + return stores + + +def set_current_store(driver, store_id): + # Get the main store page URL so you don't overload the product webpage. Selenium limitation + driver.get("https://www.microcenter.com") -def send_email(): + # Wait for the page to load, adjust as needed (seconds) + time.sleep(0) + + # Micro Center uses the 'storeSelected' cookie to determine the currently selected store. Default: 131 (Dallas store) + driver.add_cookie({ + 'name': 'storeSelected', + 'value': store_id, + 'domain': '.microcenter.com', + 'path': '/', + 'secure': True, + 'httpOnly': False + }) + + +def navigate_to_product(driver, product_link): + driver.get(product_link) + + # Wait for the page to load, adjust as needed (seconds) + time.sleep(0) + + +def send_in_stock_email(store, product_name, quantity_in_stock): try: # Get the environment variables email_user = os.getenv("email") email_password = os.getenv("password") - # Confirm whether email credentials were entered - if not email_user or not email_password: - raise Exception("Email credentials not found") - # Set up the email msg = MIMEMultipart() msg['From'] = email_user msg['To'] = email_user msg['Subject'] = 'Your Microcenter Product is Now In Stock' - msg.attach(MIMEText('Your product is in stock. Reserve it online or head to the store to get it while supplies last.','plain')) + msg.attach( + MIMEText( + f'{product_name} is in stock at {store}. There are {quantity_in_stock} in stock. Reserve it online or head to the store to get it while supplies last.', + 'plain' + ) + ) # Connect to the email server and send the email with smtplib.SMTP("smtp.gmail.com", 587) as server: @@ -103,53 +184,73 @@ def send_email(): server.login(email_user, email_password) server.sendmail(email_user, email_user, msg.as_string()) - print("Email sent successfully!") - - # Test email exception + print(f"[{dt.now()}] Email sent successfully!") except Exception as e: - print(f"Failed to send email: {e}") + print(f"[{dt.now()}] Failed to send email: {e}") + def check_stock(driver): - # Set the store cookie to Dallas (storeSelected: 131) - set_store_cookie(driver) - - # Get the page source (HTML) - page_source = driver.page_source - - # Search for 'inStock': 'False' in the page source - if "'inStock':'True'" in page_source: - print("In Stock!") - send_email() - driver.quit() # Close the browser - sys.exit() - else: - print("Out of Stock!") + # Micro Center's site uses a DOM element with the class 'inventoryCnt', which also lists the quantity in stock if present. + # If no such DOM element exists, the product is sold out. + try: + inventory_count_element = driver.find_element(By.CLASS_NAME, 'inventoryCnt') + return inventory_count_element.get_attribute('innerText').split()[0].strip() # It'll be either 1-24 or 25+. + except: + # Selenium throws an exception if no such element can be found. This is expected. + pass + + # Just to double check, we'll also check their Google Tag Manager event, which uses 'inStock': 'True|False' to indicate its availability. + # Inventory count is not available that way though, so we'll just consider it 1 or 0 to be simple. + return str(int("'inStock':'True'" in driver.page_source)) + def main(): - # Enter email and password - prompt() + prompt_for_product() - # Specify the config.env path + # Load email credentials from the config.env file if possible config_path = ".venv/config.env" - - # Load email credentials from the config.env file load_dotenv(config_path) - # Test email - test_email() + if not os.getenv("email") or not os.getenv("password"): + prompt_for_email_details() - while True: - # Set up Chrome options to enable headless mode - chrome_options = Options() - chrome_options.add_argument("--headless") # Enable headless mode - chrome_options.add_argument("--disable-gpu") # Disable GPU acceleration - chrome_options.add_argument("--no-sandbox") # Fix potential environment issues + # Confirm whether email credentials were entered + if not os.getenv("email") or not os.getenv("password"): + raise Exception("Email credentials not found") + + if not os.getenv("disable_test_email") or (os.getenv("disable_test_email") and os.getenv("disable_test_email").strip().lower() != 'true'): + send_test_email() + else: + print(f'[{dt.now()}] Skipping test email') - # Set up the WebDriver with the specified options + # Set up Chrome options to enable headless mode + chrome_options = Options() + chrome_options.add_argument("--headless") # Enable headless mode + chrome_options.add_argument("--disable-gpu") # Disable GPU acceleration + chrome_options.add_argument("--no-sandbox") # Fix potential environment issues + + stores_to_check = ['Dallas', 'Houston'] # Update this with the stores you want to check. Reference the map at the start. + while True: driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=chrome_options) + for store in stores_to_check: + set_current_store(driver, STORES[store]) + navigate_to_product(driver, PRODUCTS[os.getenv('selected_product')]) + product_name = driver.find_element(By.CLASS_NAME, 'product-header').get_attribute('innerText') + quantity_in_stock = check_stock(driver) + + if quantity_in_stock != '0': + print(f'[{dt.now()}] {product_name} is in stock at {store}! Available quantity: {quantity_in_stock}') + send_in_stock_email(store, product_name, quantity_in_stock) + driver.quit() # Close the browser + sys.exit() + else: + print(f'[{dt.now()}] {product_name} is out of stock at {store}!') + + time.sleep(10) # An arbitrary delay between checking stores to hopefully avoid being flagged as a bot. + driver.quit() # Use a new driver and close it afterwards for every loop to mitigate memory leaks. + time.sleep( + 300) # Change the number to check stock sooner/faster (seconds). Default: checks stock every ~5 minutes - # Call the function to check stock - check_stock(driver) # Check stock status once - time.sleep(300) # Change the number to check stock sooner/faster (seconds). Default: checks stock every ~5 minutes -main() \ No newline at end of file +if __name__ == '__main__': + main() diff --git a/README.md b/README.md index fb45816..bd5ce13 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Check the stock of a specific Micro Center product. Optionally, securely link yo ## Installation -**Note: The default code looks for an AMD Ryzen 7 9800X3D at a Micro Center Store in Dallas, TX +**Note: The default code checks the Micro Center Stores in Dallas, TX and Houston, TX Step-by-step instructions for setting up the project: @@ -24,33 +24,22 @@ Check the stock of a specific Micro Center product. Optionally, securely link yo pip install -r requirements.txt ``` *Alternatively: To use your system's command console/terminal window to install the items listed within the requirements folder:
- 1\. Change directory (cd) to the project folder's location that contains requirements.txt
+ 1\. Change directory (`cd`) to the project folder's location that contains requirements.txt
2\. Run the above command

-3. On Line 23 within the code, replace the Store Identification Number with your local Micro Center Store Identification Number: +3. On Line 232 within the code, replace the store names with the name of your local Micro Center store(s): ```python - # Line 23 - 'value': 'store number here', - ``` -
- To find your local Micro Center Store Number:
- 1. Make sure you have selected a store on www.microcenter.com
- 2. Right-Click the page, choose 'Inspect'
- 3. Click the double arrow (>>) at the top of the Inspect Element console and select 'Application'
- 4. Under 'Storage', click 'Cookies'
- 5. Under the 'Name' column, look for 'storeSelected' and find the appropriate value associated with that cookie.
- 6. Replace the number on Line 23 in the code with the appropriate Store Identification Number as shown in the example below.
- - ```python - # Example - # Line 23 - 'value': '131', + # Line 232 + stores_to_check = ['Dallas'] ``` +
-4. On Line 30 within the code, replace the Product Page URL with your desired product: +4. On Line 47 within the code, add or update the Product Page URL with your desired product: ```python - # Line 30 - Get the product page URL - driver.get("https://www.microcenter.com/your-product-url-here") + # Line 47 - Get the product page URL + PRODUCTS = { + 'simple-part-name-here': 'https://www.microcenter.com/your-product-url-here', + } ```
5. (Optional) Adjust the speed that the program functions: @@ -58,7 +47,7 @@ Check the stock of a specific Micro Center product. Optionally, securely link yo
Slower Internet = Higher Number (~30) ```python - # Line 18 & Line 33 - Wait for the page to load, + # Line 122, 143, 160, 249, 252 - Wait for the page to load, # adjust as needed (seconds) time.sleep(30) ``` @@ -67,7 +56,7 @@ Check the stock of a specific Micro Center product. Optionally, securely link yo Faster Internet = Lower Number (~5) ```python - # Line 18 & Line 33 - Wait for the page to load, + # Line 122, 143, 160, 249, 252 - Wait for the page to load, # adjust as needed (seconds) time.sleep(5) ``` @@ -88,15 +77,15 @@ Check the stock of a specific Micro Center product. Optionally, securely link yo 5\. Enter your email
6\. Enter your password
7\. Expect to receive a test email from yourself confirming that your email is linked with the application
+ 8\. To avoid having to re-enter your email and app password every time, it is recommended to place them in `./venv/config.env`. See `config.env.example` for reference. That's all! Leave the program running in the background. If you shut down the computer, be sure to run through the steps again to get it working again. ### Future Update Plans -1. Show and email the amount in stock
-2. GUI window
-3. Multiple Micro Center stores listed - no code-changing required
-4. Works for other websites
+1. GUI window
+2. Multiple Micro Center stores listed - no code-changing required
+3. Works for other websites
### If this program helped you, buy me a coffee :)
diff --git a/config.env b/config.env deleted file mode 100644 index e69de29..0000000 diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..9d57e34 --- /dev/null +++ b/config.env.example @@ -0,0 +1,3 @@ +email= +password="" +disable_test_email=false