├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── myusps └── __init__.py ├── pylintrc ├── setup.py ├── tests └── test_myusps.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | myusps.egg-info/ 3 | .tox/ 4 | *.pickle 5 | *.pyc 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | cache: 2 | directories: 3 | - $HOME/.cache/pip 4 | language: python 5 | python: "3.5" 6 | install: "pip install tox" 7 | script: tox 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 happyleavesaoc 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | 3 | .PHONY: dist update 4 | dist: 5 | rm -f dist/*.whl dist/*.tar.gz 6 | python setup.py sdist 7 | 8 | release: 9 | twine upload dist/*.tar.gz 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/happyleavesaoc/python-myusps.svg?branch=master)](https://travis-ci.org/happyleavesaoc/python-myusps) [![PyPI version](https://badge.fury.io/py/myusps.svg)](https://badge.fury.io/py/myusps) 2 | 3 | # python-myusps 4 | 5 | Python 3 API for [USPS Informed Delivery](https://my.usps.com/mobileWeb/pages/intro/start.action), a way to track packages and mail. 6 | 7 | ## Prerequisites 8 | 9 | ### USPS 10 | 11 | Sign up for Informed Delivery and verify your address. 12 | 13 | ### Chrome 14 | 15 | Install Google Chrome and Chromedriver. These are dependencies for the Selenium webdriver, which is used internally to this module to facilitate the login process. 16 | 17 | Instructions (adapt as necessary for your OS): 18 | - Ubuntu 16: https://gist.github.com/ziadoz/3e8ab7e944d02fe872c3454d17af31a5 19 | - RHEL 7: https://stackoverflow.com/a/46686621 20 | 21 | Note that installing Selenium Server is not required. 22 | 23 | ## Install 24 | 25 | `pip install myusps` 26 | 27 | ## Usage 28 | 29 | ```python 30 | import myusps 31 | 32 | # Establish a session. 33 | # Use the login credentials you use to login to My USPS via the web. 34 | # A login failure raises a `USPSError`. 35 | session = myusps.get_session("username", "password") 36 | 37 | # Get your profile information as a dict. Includes name, address, phone, etc. 38 | profile = myusps.get_profile(session) 39 | 40 | # Get all packages that My UPS knows about. 41 | packages = myusps.get_packages(session) 42 | 43 | # Get mail delivered on a given day. 44 | import datetime 45 | mail = myusps.get_mail(session, datetime.datetime.now().date()) 46 | ``` 47 | 48 | ## Caching 49 | Session cookies are cached by default in `./usps_cookies.pickle` and will be used if available instead of logging in. If the cookies expire, a new session will be established automatically. 50 | 51 | HTTP requests are cached by default in `./usps_cache.sqlite`. HTTP caching defaults to 5 minutes and can be turned off by passing `cache=False` to `get_session`. The cache expiry can be adjusted with the keyword argument `cache_expiry`. 52 | 53 | ## Development 54 | 55 | ### Lint 56 | 57 | `tox` 58 | 59 | ### Release 60 | 61 | `make release` 62 | 63 | ### Contributions 64 | 65 | Contributions are welcome. Please submit a PR that passes `tox`. 66 | 67 | ## Disclaimer 68 | Not affiliated with USPS. Does not use [USPS Web Tools API](https://www.usps.com/business/web-tools-apis/welcome.htm). Use at your own risk. 69 | -------------------------------------------------------------------------------- /myusps/__init__.py: -------------------------------------------------------------------------------- 1 | """My USPS (Informed Deliver) interface.""" 2 | 3 | import datetime 4 | import logging 5 | import os.path 6 | import pickle 7 | import re 8 | from bs4 import BeautifulSoup 9 | from dateutil.parser import parse 10 | import requests 11 | from requests.auth import AuthBase 12 | import requests_cache 13 | from selenium import webdriver 14 | from selenium.common.exceptions import TimeoutException, WebDriverException 15 | from selenium.webdriver.support import expected_conditions as EC 16 | from selenium.webdriver.support.ui import WebDriverWait 17 | from selenium.webdriver.firefox.options import Options 18 | 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | HTML_PARSER = 'html.parser' 22 | BASE_URL = 'https://reg.usps.com' 23 | MY_USPS_URL = BASE_URL + '/login?app=MyUSPS' 24 | AUTHENTICATE_URL = BASE_URL + '/entreg/json/AuthenticateAction' 25 | LOGIN_URL = BASE_URL + '/entreg/LoginAction_input?app=Phoenix&appURL=https://www.usps.com/' 26 | DASHBOARD_URL = 'https://informeddelivery.usps.com/box/pages/secure/DashboardAction_input.action' 27 | INFORMED_DELIVERY_IMAGE_URL = 'https://informeddelivery.usps.com/box/pages/secure/' 28 | PROFILE_URL = 'https://store.usps.com/store/myaccount/profile.jsp' 29 | WELCOME_TITLE = 'Welcome | USPS' 30 | LOGIN_TIMEOUT = 10 31 | COOKIE_PATH = './usps_cookies.pickle' 32 | CACHE_PATH = './usps_cache' 33 | ATTRIBUTION = 'Information provided by www.usps.com' 34 | USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) ' \ 35 | 'Chrome/41.0.2228.0 Safari/537.36' 36 | CHROME_WEBDRIVER_ARGS = [ 37 | '--headless', '--user-agent={}'.format(USER_AGENT), '--disable-extensions', 38 | '--disable-gpu', '--no-sandbox' 39 | ] 40 | FIREFOXOPTIONS = Options() 41 | FIREFOXOPTIONS.add_argument("--headless") 42 | 43 | 44 | class USPSError(Exception): 45 | """USPS error.""" 46 | 47 | pass 48 | 49 | 50 | def _save_cookies(requests_cookiejar, filename): 51 | """Save cookies to a file.""" 52 | with open(filename, 'wb') as handle: 53 | pickle.dump(requests_cookiejar, handle) 54 | 55 | 56 | def _load_cookies(filename): 57 | """Load cookies from a file.""" 58 | with open(filename, 'rb') as handle: 59 | return pickle.load(handle) 60 | 61 | 62 | def _get_primary_status(row): 63 | """Get package primary status.""" 64 | try: 65 | return row.find('div', {'class': 'pack_h3'}).string 66 | except AttributeError: 67 | return None 68 | 69 | 70 | def _get_secondary_status(row): 71 | """Get package secondary status.""" 72 | try: 73 | return row.find('div', {'id': 'coltextR3'}).contents[1] 74 | except (AttributeError, IndexError): 75 | return None 76 | 77 | 78 | def _get_shipped_from(row): 79 | """Get where package was shipped from.""" 80 | try: 81 | spans = row.find('div', {'id': 'coltextR2'}).find_all('span') 82 | if len(spans) < 2: 83 | return None 84 | return spans[1].string 85 | except AttributeError: 86 | return None 87 | 88 | 89 | def _get_status_timestamp(row): 90 | """Get latest package timestamp.""" 91 | try: 92 | divs = row.find('div', {'id': 'coltextR3'}).find_all('div') 93 | if len(divs) < 2: 94 | return None 95 | timestamp_string = divs[1].string 96 | except AttributeError: 97 | return None 98 | try: 99 | return parse(timestamp_string) 100 | except ValueError: 101 | return None 102 | 103 | 104 | def _get_delivery_date(row): 105 | """Get delivery date (estimated or actual).""" 106 | try: 107 | month = row.find('div', {'class': 'date-small'}).string 108 | day = row.find('div', {'class': 'date-num-large'}).string 109 | except AttributeError: 110 | return None 111 | try: 112 | return parse('{} {}'.format(month, day)).date() 113 | except ValueError: 114 | return None 115 | 116 | 117 | def _get_tracking_number(row): 118 | """Get package tracking number.""" 119 | try: 120 | return row.find('div', {'class': 'pack_h4'}).string 121 | except AttributeError: 122 | return None 123 | 124 | 125 | def _get_mailpiece_image(row): 126 | """Get mailpiece image url.""" 127 | try: 128 | return row.find('img', {'class': 'mailpieceIMG'}).get('src') 129 | except AttributeError: 130 | return None 131 | 132 | 133 | def _get_mailpiece_id(image): 134 | parts = image.split('=') 135 | if len(parts) != 2: 136 | return None 137 | return parts[1] 138 | 139 | 140 | def _get_mailpiece_url(image): 141 | """Get mailpiece url.""" 142 | return '{}{}'.format(INFORMED_DELIVERY_IMAGE_URL, image) 143 | 144 | def _get_driver(driver_type): 145 | """Get webdriver.""" 146 | if driver_type == 'phantomjs': 147 | return webdriver.PhantomJS(service_log_path=os.path.devnull) 148 | if driver_type == 'firefox': 149 | return webdriver.Firefox(firefox_options=FIREFOXOPTIONS) 150 | elif driver_type == 'chrome': 151 | chrome_options = webdriver.ChromeOptions() 152 | for arg in CHROME_WEBDRIVER_ARGS: 153 | chrome_options.add_argument(arg) 154 | return webdriver.Chrome(chrome_options=chrome_options) 155 | else: 156 | raise USPSError('{} not supported'.format(driver_type)) 157 | 158 | def _login(session): 159 | """Login. 160 | 161 | Use Selenium webdriver to login. USPS authenticates users 162 | in part by a key generated by complex, obfuscated client-side 163 | Javascript, which can't (easily) be replicated in Python. 164 | Invokes the webdriver once to perform login, then uses the 165 | resulting session cookies with a standard Python `requests` 166 | session. 167 | """ 168 | _LOGGER.debug("attempting login") 169 | session.cookies.clear() 170 | try: 171 | session.remove_expired_responses() 172 | except AttributeError: 173 | pass 174 | try: 175 | driver = _get_driver(session.auth.driver) 176 | except WebDriverException as exception: 177 | raise USPSError(str(exception)) 178 | driver.get(LOGIN_URL) 179 | username = driver.find_element_by_name('username') 180 | username.send_keys(session.auth.username) 181 | password = driver.find_element_by_name('password') 182 | password.send_keys(session.auth.password) 183 | driver.find_element_by_id('btn-submit').click() 184 | try: 185 | WebDriverWait(driver, LOGIN_TIMEOUT).until(EC.title_is(WELCOME_TITLE)) 186 | except TimeoutException: 187 | raise USPSError('login failed') 188 | for cookie in driver.get_cookies(): 189 | session.cookies.set(name=cookie['name'], value=cookie['value']) 190 | _save_cookies(session.cookies, session.auth.cookie_path) 191 | 192 | 193 | def _get_dashboard(session, date=None): 194 | # Default to today's date 195 | if not date: 196 | date = datetime.datetime.now().date() 197 | response = session.get(DASHBOARD_URL, params={ 198 | 'selectedDate': '{0:%m}/{0:%d}/{0:%Y}'.format(date) 199 | }, allow_redirects=False) 200 | # If we get a HTTP redirect, the session has expired and 201 | # we need to login again (handled by @authenticated) 202 | if response.status_code == 302: 203 | raise USPSError('expired session') 204 | return response 205 | 206 | 207 | def authenticated(function): 208 | """Re-authenticate if session expired.""" 209 | def wrapped(*args): 210 | """Wrap function.""" 211 | try: 212 | return function(*args) 213 | except USPSError: 214 | _LOGGER.info("attempted to access page before login") 215 | _login(args[0]) 216 | return function(*args) 217 | return wrapped 218 | 219 | 220 | @authenticated 221 | def get_profile(session): 222 | """Get profile data.""" 223 | response = session.get(PROFILE_URL, allow_redirects=False) 224 | if response.status_code == 302: 225 | raise USPSError('expired session') 226 | parsed = BeautifulSoup(response.text, HTML_PARSER) 227 | profile = parsed.find('div', {'class': 'atg_store_myProfileInfo'}) 228 | data = {} 229 | for row in profile.find_all('tr'): 230 | cells = row.find_all('td') 231 | if len(cells) == 2: 232 | key = ' '.join(cells[0].find_all(text=True)).strip().lower().replace(' ', '_') 233 | value = ' '.join(cells[1].find_all(text=True)).strip() 234 | data[key] = value 235 | return data 236 | 237 | 238 | @authenticated 239 | def get_packages(session): 240 | """Get package data.""" 241 | _LOGGER.info("attempting to get package data") 242 | response = _get_dashboard(session) 243 | parsed = BeautifulSoup(response.text, HTML_PARSER) 244 | packages = [] 245 | for row in parsed.find_all('div', {'class': 'pack_row'}): 246 | packages.append({ 247 | 'tracking_number': _get_tracking_number(row), 248 | 'primary_status': _get_primary_status(row), 249 | 'secondary_status': _get_secondary_status(row), 250 | 'status_timestamp': _get_status_timestamp(row), 251 | 'shipped_from': _get_shipped_from(row), 252 | 'delivery_date': _get_delivery_date(row) 253 | }) 254 | return packages 255 | 256 | 257 | @authenticated 258 | def get_mail(session, date=None): 259 | """Get mail data.""" 260 | _LOGGER.info("attempting to get mail data") 261 | if not date: 262 | date = datetime.datetime.now().date() 263 | response = _get_dashboard(session, date) 264 | parsed = BeautifulSoup(response.text, HTML_PARSER) 265 | mail = [] 266 | for row in parsed.find_all('div', {'class': 'mailpiece'}): 267 | image = _get_mailpiece_image(row) 268 | if not image: 269 | continue 270 | mail.append({ 271 | 'id': _get_mailpiece_id(image), 272 | 'image': _get_mailpiece_url(image), 273 | 'date': date 274 | }) 275 | return mail 276 | 277 | # pylint: disable=too-many-arguments 278 | def get_session(username, password, cookie_path=COOKIE_PATH, cache=True, 279 | cache_expiry=300, cache_path=CACHE_PATH, driver='phantomjs'): 280 | """Get session, existing or new.""" 281 | class USPSAuth(AuthBase): # pylint: disable=too-few-public-methods 282 | """USPS authorization storage.""" 283 | 284 | def __init__(self, username, password, cookie_path, driver): 285 | """Init.""" 286 | self.username = username 287 | self.password = password 288 | self.cookie_path = cookie_path 289 | self.driver = driver 290 | 291 | def __call__(self, r): 292 | """Call is no-op.""" 293 | return r 294 | 295 | session = requests.Session() 296 | if cache: 297 | session = requests_cache.core.CachedSession(cache_name=cache_path, 298 | expire_after=cache_expiry) 299 | session.auth = USPSAuth(username, password, cookie_path, driver) 300 | session.headers.update({'User-Agent': USER_AGENT}) 301 | if os.path.exists(cookie_path): 302 | _LOGGER.debug("cookie found at: %s", cookie_path) 303 | session.cookies = _load_cookies(cookie_path) 304 | else: 305 | _login(session) 306 | return session -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | ignore= 4 | disable=I 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='myusps', 5 | version='1.3.2', 6 | description='Python 3 API for USPS Informed Delivery, a way to track packages and mailpieces.', 7 | url='https://github.com/happyleavesaoc/python-myusps/', 8 | license='MIT', 9 | author='happyleaves', 10 | author_email='happyleaves.tfr@gmail.com', 11 | packages=find_packages(), 12 | install_requires=['beautifulsoup4==4.6.0', 'python-dateutil==2.6.0', 'requests>=2.20.0', 'requests-cache==0.4.13', 'selenium==3.11.0'], 13 | classifiers=[ 14 | 'License :: OSI Approved :: MIT License', 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python :: 3', 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /tests/test_myusps.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import tempfile 3 | import unittest 4 | from bs4 import BeautifulSoup 5 | import requests_mock 6 | import myusps 7 | 8 | PACKAGE_ROW_HTML = """
9 |
10 |
Sep
11 |
7
12 | image of delivery status
13 |
14 |
Delivered
15 |
16 |
from
12345
17 |
primary
secondary
Sep 07, 2017 at 07:14 PM
18 |
19 |
20 |
""" 21 | 22 | 23 | MAILPIECE_ROW_HTML = """
24 | Scanned image of your mail piece 25 |
26 | 28 | image of expanding icon 29 | 30 |
31 |
""" 32 | 33 | 34 | class TestMyUSPSCookies(unittest.TestCase): 35 | 36 | def test_cookies(self): 37 | data = {'test': 'ok'} 38 | with tempfile.NamedTemporaryFile() as temp_file: 39 | myusps._save_cookies(data, temp_file.name) 40 | self.assertEqual(myusps._load_cookies(temp_file.name), data) 41 | 42 | 43 | class TestMyUSPSPackages(unittest.TestCase): 44 | 45 | row = BeautifulSoup(PACKAGE_ROW_HTML, myusps.HTML_PARSER) 46 | 47 | def test_get_primary_status(self): 48 | self.assertEqual(myusps._get_primary_status(TestMyUSPSPackages.row), 'primary') 49 | 50 | def test_get_secondary_status(self): 51 | self.assertEqual(myusps._get_secondary_status(TestMyUSPSPackages.row), 'secondary') 52 | 53 | def test_get_tracking_number(self): 54 | self.assertEqual(myusps._get_tracking_number(TestMyUSPSPackages.row), '12345') 55 | 56 | def test_get_shipped_from(self): 57 | self.assertEqual(myusps._get_shipped_from(TestMyUSPSPackages.row), 'from') 58 | 59 | def test_get_status_timestamp(self): 60 | self.assertEqual(myusps._get_status_timestamp(TestMyUSPSPackages.row), datetime.datetime(2017, 9, 7, 19, 14, 0)) 61 | 62 | 63 | def test_get_delivery_date(self): 64 | self.assertEqual(myusps._get_delivery_date(TestMyUSPSPackages.row), datetime.datetime(datetime.datetime.now().year, 9, 7).date()) 65 | 66 | 67 | class TestMyUSPSMailpieces(unittest.TestCase): 68 | 69 | row = BeautifulSoup(MAILPIECE_ROW_HTML, myusps.HTML_PARSER) 70 | 71 | def test_get_mailpiece_image(self): 72 | self.assertEqual(myusps._get_mailpiece_image(TestMyUSPSMailpieces.row), 'getMailpieceImageFile.action?id=12345') 73 | 74 | def test_get_mailpiece_id(self): 75 | self.assertEqual(myusps._get_mailpiece_id('getMailpieceImageFile.action?id=12345'), '12345') 76 | 77 | def test_get_mailpiece_url(self): 78 | self.assertEqual(myusps._get_mailpiece_url('getMailpieceImageFile.action?id=12345'), 'https://informeddelivery.usps.com/box/pages/secure/getMailpieceImageFile.action?id=12345') 79 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, lint 3 | skip_missing_interpreters = True 4 | 5 | 6 | [testenv] 7 | ignore_errors = True 8 | setenv = 9 | LANG=en_US.UTF-8 10 | PYTHONPATH={toxinidir}:{toxinidir}/myusps 11 | deps = 12 | pylint 13 | pydocstyle 14 | isort 15 | pytest 16 | pytest-cov 17 | pytest-sugar 18 | requests_mock 19 | commands = 20 | py.test -v --cov-report term-missing --cov myusps 21 | 22 | 23 | [testenv:lint] 24 | ignore_errors = True 25 | commands = 26 | pylint --output-format=colorized --reports n myusps 27 | pydocstyle myusps 28 | isort --recursive --check-only --diff myusps 29 | --------------------------------------------------------------------------------