├── .dockerignore ├── .gitignore ├── Alert.py ├── Dockerfile ├── LICENSE ├── README.md └── check.py /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !*.py 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Pipfile 2 | Pipfile.lock 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | -------------------------------------------------------------------------------- /Alert.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.mime.text import MIMEText 3 | from socket import gaierror 4 | 5 | 6 | class BaseAlert(object): 7 | def _print_ahead(self, method): 8 | def wrapper(msgbody): 9 | print(msgbody) 10 | method(msgbody) 11 | return wrapper 12 | 13 | 14 | class SmtpAlert(BaseAlert): 15 | def __init__(self, dest=None, login=None, password=None): 16 | self.dest = dest 17 | self.login = login 18 | self.password = password 19 | self.send = self._print_ahead(self.send_smtp) 20 | 21 | def send_smtp(self, msgbody): 22 | message = MIMEText(msgbody, _charset="UTF-8") 23 | message['From'] = self.login 24 | message['To'] = self.dest 25 | message['Subject'] = "Apple Stock Alert" 26 | 27 | try: 28 | mailer = smtplib.SMTP('smtp.gmail.com:587') 29 | except gaierror: 30 | print("Couldn't reach Gmail server") 31 | return 32 | mailer.ehlo() 33 | mailer.starttls() 34 | mailer.ehlo() 35 | mailer.login(self.login, self.password) 36 | mailer.sendmail(self.login, self.dest, message.as_string()) 37 | mailer.close() 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | ADD * /work/ 4 | 5 | WORKDIR /work/ 6 | ENTRYPOINT ["python", "check.py"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Yuhao Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # applechecker 2 | 3 | > Check Apple Store Inventory 4 | 5 | Keep checking Apple Store inventory and send you alert when nearby stores have your desired device in stock. 6 | Also let you know if inventory becomes zero again so you don't jump out of bed when it is already too late. 7 | 8 | * [Prerequisites](#prerequisites) 9 | * [Usage](#usage) 10 | 11 | ## Prerequisites 12 | 13 | * A gmail account if want email alert. (Got refused by Gmail because the app is insecure? [Enable 2-Step Verification](https://support.google.com/accounts/answer/185839?hl=en) and [generate an App password]() for it. Or follow [this instruction](https://support.google.com/accounts/answer/6010255?hl=en) to allow insecure login) 14 | 15 | ## Usage 16 | 17 | ``` 18 | python check.py 19 | ``` 20 | 21 | ### Example: 22 | 23 | Every 5 seconds, check availability of `Apple Watch Stainless Steel Case with White Sport Band` near zipcode 12345 and send email alert to `recipient@example.com` using gmail account `sender@gmail.com`. 24 | 25 | ``` 26 | python /path/to/check.py "MNPR2LL/A" "12345" 5 recipient@example.com sender@gmail.com sender_password 27 | ``` 28 | 29 | Model number is a unique identifier, U.S. models end with "LL/*". (https://www.theiphonewiki.com/wiki/Model_Regions) 30 | 31 | * For Apple Watch: model number hides in query string in URL of the item page. 32 | 33 | Example: 34 | `http://www.apple.com/shop/buy-watch/apple-watch/silver-stainless-steel-stainless-steel-sport-band?preSelect=true&product=`**`MNPR2LL/A`**`&step=detail#` 35 | 36 | * iPhone: inspect the item page and look for a request to `http://www.apple.com/shop/delivery-message?` 37 | 38 | Example: 39 | `http://www.apple.com/shop/delivery-message?parts.0=`**`MN5L2LL%2FA`**`&cppart=TMOBILE%2FUS&_=1474171709609` 40 | 41 | or just check your model number here: http://www.everyi.com/ 42 | 43 | To verify, visit `http://store.apple.com/xc/product/` and see if it shows the product you want. 44 | 45 | ### Docker Example: 46 | 47 | ``` 48 | # foreground 49 | docker run --rm -t yuha0/applechecker "MNPR2LL/A" "12345" 5 recipient@example.com sender@gmail.com "ekffyblvhbyhpowd" 50 | ``` 51 | 52 | ``` 53 | # background 54 | docker run -d --restart always yuha0/applechecker "MNPR2LL/A" "12345" 5 recipient@example.com sender@gmail.com "ekffyblvhbyhpowd" 55 | ``` 56 | -------------------------------------------------------------------------------- /check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | import sys 4 | import time 5 | import json 6 | from socket import gaierror 7 | try: 8 | # python 3 9 | from urllib.request import urlopen 10 | from urllib.parse import urlencode 11 | except ImportError: 12 | # python2 13 | from urllib import urlopen 14 | from urllib import urlencode 15 | from Alert import SmtpAlert 16 | 17 | # only tested for US stores 18 | URL = "http://www.apple.com/shop/retail/pickup-message" 19 | BUY = "http://store.apple.com/xc/product/" 20 | 21 | DATEFMT = "[%m/%d/%Y-%H:%M:%S]" 22 | LOADING = ['-', '\\', '|', '/'] 23 | 24 | INPUT_ERRORS = {"Products Invalid or not buyable", 25 | "Invalid zip code or city/state."} 26 | 27 | INITMSG = "{} Start monitoring {} inventory in area {}." 28 | 29 | 30 | def main(model, zipcode, sec=5, *alert_params): 31 | good_stores = [] 32 | my_alert = SmtpAlert(*alert_params) 33 | params = {'parts.0': model, 34 | 'location': zipcode} 35 | sec = int(sec) 36 | i, cnt = 0, sec 37 | init = True 38 | while True: 39 | if cnt < sec: 40 | # loading sign refreshes every second 41 | print('Checking...{}'.format(LOADING[i]), end='\r') 42 | i = i + 1 if i < 3 else 0 43 | cnt += 1 44 | time.sleep(1) 45 | continue 46 | cnt = 0 47 | try: 48 | response = urlopen( 49 | "{}?{}".format(URL, urlencode(params))) 50 | json_body = json.load(response)['body'] 51 | stores = json_body['stores'][:8] 52 | item = (stores[0]['partsAvailability'] 53 | [model]['messageTypes']['regular']['storePickupProductTitle']) 54 | if init: 55 | my_alert.send(INITMSG.format( 56 | time.strftime(DATEFMT), item, zipcode)) 57 | init = False 58 | except (ValueError, KeyError, gaierror) as reqe: 59 | error_msg = "Failed to query Apple Store, details: {}".format(reqe) 60 | try: 61 | error_msg = json_body['errorMessage'] 62 | if error_msg in INPUT_ERRORS: 63 | sys.exit(1) 64 | except KeyError: 65 | pass 66 | finally: 67 | print(error_msg) 68 | time.sleep(int(sec)) 69 | continue 70 | 71 | for store in stores: 72 | sname = store['storeName'] 73 | item = (stores[0]['partsAvailability'] 74 | [model]['messageTypes']['regular']['storePickupProductTitle']) 75 | if store['partsAvailability'][model]['pickupDisplay'] \ 76 | == "available": 77 | if sname not in good_stores: 78 | good_stores.append(sname) 79 | msg = u"{} Found it! {} has {}! {}{}".format( 80 | time.strftime(DATEFMT), sname, item, BUY, model) 81 | my_alert.send(msg) 82 | else: 83 | if sname in good_stores: 84 | good_stores.remove(sname) 85 | msg = u"{} Oops all {} in {} are gone :( ".format( 86 | time.strftime(DATEFMT), item, sname) 87 | my_alert.send(msg) 88 | 89 | if good_stores: 90 | print(u"{current} Still Avaiable: {stores}".format( 91 | current=time.strftime(DATEFMT), 92 | stores=', '.join([s for s in good_stores]) 93 | if good_stores else "None")) 94 | 95 | 96 | if __name__ == '__main__': 97 | main(*sys.argv[1:]) 98 | --------------------------------------------------------------------------------