diff --git a/requirements.txt b/requirements.txt index ab90481..3e0c1de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ lxml +requests +cloudscraper diff --git a/setup.py b/setup.py index d6289c9..08673a4 100644 --- a/setup.py +++ b/setup.py @@ -11,36 +11,25 @@ def read(fname): setup( - name='withings-sync', - version='3.4.2', - author='Masayuki Hamasaki, Steffen Vogel', - author_email='post@steffenvogel.de', - description='A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road.', - license='MIT', - keywords='garmin withings sync api scale smarthome', - url='http://packages.python.org/an_example_pypi_project', - packages=['withings_sync'], - long_description=read('README.md'), - long_description_content_type='text/markdown', + name="withings-sync", + version="3.5", + author="Masayuki Hamasaki, Steffen Vogel", + author_email="post@steffenvogel.de", + description="A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road.", + license="MIT", + keywords="garmin withings sync api scale smarthome", + url="http://packages.python.org/an_example_pypi_project", + packages=["withings_sync"], + long_description=read("README.md"), + long_description_content_type="text/markdown", classifiers=[ - 'Topic :: Utilities', - 'License :: OSI Approved :: MIT License', - ], - install_requires=[ - 'lxml', - 'requests', - # request has a fix dependency on charset-normalizer charset_normalizer~=2.0.0 - # (see https://requests.readthedocs.io/en/latest/user/advanced/#encodings) - # We pin it here to avoid conflicts with httpx - 'charset-normalizer~=2.0.0', - 'httpx', - 'httpx[http2]' + "Topic :: Utilities", + "License :: OSI Approved :: MIT License", ], + install_requires=["lxml", "requests", "cloudscraper"], entry_points={ - 'console_scripts': [ - 'withings-sync=withings_sync.sync:main' - ], + "console_scripts": ["withings-sync=withings_sync.sync:main"], }, zip_safe=False, - include_package_data=True + include_package_data=True, ) diff --git a/withings_sync/garmin.py b/withings_sync/garmin.py index ffd4723..bdba85b 100644 --- a/withings_sync/garmin.py +++ b/withings_sync/garmin.py @@ -1,149 +1,166 @@ -from datetime import timedelta +"""This module handles the Garmin connectivity.""" import urllib.request -import httpx import urllib.error import urllib.parse import re -import sys import json import logging +import cloudscraper -log = logging.getLogger('garmin') + +log = logging.getLogger("garmin") class LoginSucceeded(Exception): - pass + """Used to raise on LoginSucceeded""" class LoginFailed(Exception): - pass + """Used to raise on LoginFailed""" class APIException(Exception): - pass + """Used to raise on APIException""" + +class GarminConnect: + """Main GarminConnect class""" -class GarminConnect(object): - LOGIN_URL = 'https://connect.garmin.com/signin' - UPLOAD_URL = 'https://connect.garmin.com/modern/proxy/upload-service/upload/.fit' + LOGIN_URL = "https://connect.garmin.com/signin" + UPLOAD_URL = "https://connect.garmin.com/modern/proxy/upload-service/upload/.fit" def create_opener(self, cookie): + """Garmin opener""" this = self class _HTTPRedirectHandler(urllib.request.HTTPRedirectHandler): - def http_error_302(self, req, fp, code, msg, headers): + def http_error_302( + self, req, fp, code, msg, headers + ): # pylint: disable=too-many-arguments if req.get_full_url() == this.LOGIN_URL: raise LoginSucceeded - return urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers) + return urllib.request.HTTPRedirectHandler.http_error_302( + self, req, fp, code, msg, headers + ) - return urllib.request.build_opener(_HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie)) + return urllib.request.build_opener( + _HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie) + ) # From https://github.com/cpfair/tapiriik + @staticmethod + def get_session(email=None, password=None): + """tapiriik get_session code""" + session = cloudscraper.CloudScraper() - def _get_session(self, record=None, email=None, password=None): - session = httpx.Client(http2=True) - - # JSIG CAS, cool I guess. - # Not quite OAuth though, so I'll continue to collect raw credentials. - # Commented stuff left in case this ever breaks because of missing parameters... data = { - 'username': email, - 'password': password, - '_eventId': 'submit', - 'embed': 'true', - # 'displayNameRequired': 'false' + "username": email, + "password": password, + "_eventId": "submit", + "embed": "true", } params = { - 'service': 'https://connect.garmin.com/modern', - # 'redirectAfterAccountLoginUrl': 'http://connect.garmin.com/modern', - # 'redirectAfterAccountCreationUrl': 'http://connect.garmin.com/modern', - # 'webhost': 'olaxpw-connect00.garmin.com', - 'clientId': 'GarminConnect', - 'gauthHost': 'https://sso.garmin.com/sso', - # 'rememberMeShown': 'true', - # 'rememberMeChecked': 'false', - 'consumeServiceTicket': 'false', - # 'id': 'gauth-widget', - # 'embedWidget': 'false', - # 'cssUrl': 'https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css', - # 'source': 'http://connect.garmin.com/en-US/signin', - # 'createAccountShown': 'true', - # 'openCreateAccount': 'false', - # 'usernameShown': 'true', - # 'displayNameShown': 'false', - # 'initialFocus': 'true', - # 'locale': 'en' + "service": "https://connect.garmin.com/modern", + "clientId": "GarminConnect", + "gauthHost": "https://sso.garmin.com/sso", + "consumeServiceTicket": "false", } headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36', - 'Referer': 'https://jhartman.pl', - 'origin': 'https://sso.garmin.com' - } - - # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... - preResp = session.get('https://sso.garmin.com/sso/signin', params=params, headers=headers) - if preResp.status_code != 200: - raise APIException('SSO prestart error %s %s' % (preResp.status_code, preResp.text)) - - ssoResp = session.post('https://sso.garmin.com/sso/login', params=params, data=data, headers=headers) - - if ssoResp.status_code != 200 or 'temporarily unavailable' in ssoResp.text: - raise APIException('SSO error %s %s' % (ssoResp.status_code, ssoResp.text)) - - if '>sendEvent(\'FAIL\')' in ssoResp.text: - raise APIException('Invalid login') - - if '>sendEvent(\'ACCOUNT_LOCKED\')' in ssoResp.text: - raise APIException('Account Locked') - - if 'renewPassword' in ssoResp.text: - raise APIException('Reset password') + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36", + "Referer": "https://jhartman.pl", + "origin": "https://sso.garmin.com", + } + + # I may never understand what motivates people to mangle a perfectly + # good protocol like HTTP in the ways they do... + preresp = session.get( + "https://sso.garmin.com/sso/signin", params=params, headers=headers + ) + if preresp.status_code != 200: + raise APIException( + f"SSO prestart error {preresp.status_code} {preresp.text}" + ) + + ssoresp = session.post( + "https://sso.garmin.com/sso/login", + params=params, + data=data, + allow_redirects=False, + headers=headers, + ) + + if ssoresp.status_code == 429: + raise APIException( + "SSO error 429: You are being rate limited: " + + "The owner of this website (sso.garmin.com) " + + "has banned you temporarily from accessing this website." + ) + + if ssoresp.status_code != 200 or "temporarily unavailable" in ssoresp.text: + raise APIException(f"SSO error {ssoresp.status_code} {ssoresp.text}") + + if ">sendEvent('FAIL')" in ssoresp.text: + raise APIException("Invalid login") + + if ">sendEvent('ACCOUNT_LOCKED')" in ssoresp.text: + raise APIException("Account Locked") + + if "renewPassword" in ssoresp.text: + raise APIException("Reset password") # self.print_cookies(cookies=session.cookies) # ...AND WE'RE NOT DONE YET! - gcRedeemResp = session.get('https://connect.garmin.com/modern', headers=headers) - if gcRedeemResp.status_code != 302: - raise APIException(f'GC redeem-start error {gcRedeemResp.status_code} {gcRedeemResp.text}') + gcredeemresp = session.get( + "https://connect.garmin.com/modern", allow_redirects=False, headers=headers + ) + if gcredeemresp.status_code != 302: + raise APIException( + f"GC redeem-start error {gcredeemresp.status_code} {gcredeemresp.text}" + ) - url_prefix = 'https://connect.garmin.com' + url_prefix = "https://connect.garmin.com" # There are 6 redirects that need to be followed to get the correct cookie # ... :( max_redirect_count = 7 current_redirect_count = 1 while True: - url = gcRedeemResp.headers['location'] + url = gcredeemresp.headers["location"] # Fix up relative redirects. - if url.startswith('/'): + if url.startswith("/"): url = url_prefix + url - url_prefix = '/'.join(url.split('/')[:3]) - gcRedeemResp = session.get(url) - - if (current_redirect_count >= max_redirect_count and - gcRedeemResp.status_code != 200): - raise APIException(f'GC redeem {current_redirect_count}/' - '{max_redirect_count} error ' - '{gcRedeemResp.status_code} ' - '{gcRedeemResp.text}') - - if gcRedeemResp.status_code in [200, 404]: + url_prefix = "/".join(url.split("/")[:3]) + gcredeemresp = session.get(url, allow_redirects=False) + + if ( + current_redirect_count >= max_redirect_count + and gcredeemresp.status_code != 200 + ): + raise APIException( + f"GC redeem {current_redirect_count}/" + "{max_redirect_count} error " + "{gcredeemresp.status_code} " + "{gcredeemresp.text}" + ) + + if gcredeemresp.status_code in [200, 404]: break current_redirect_count += 1 if current_redirect_count > max_redirect_count: break - # self.print_cookies(session.cookies) + # GarminConnect.print_cookies(session.cookies) session.headers.update(headers) return session - @staticmethod def get_json(page_html, key): """Return json from text.""" @@ -153,52 +170,46 @@ def get_json(page_html, key): return json.loads(json_text) return None - - def print_cookies(self, cookies): - log.debug('Cookies: ') + @staticmethod + def print_cookies(cookies): + """print cookies""" + log.debug("Cookies: ") for key, value in list(cookies.items()): - log.debug(' %s = %s', key, value) - - - def login(self, username, password): - - session = self._get_session(email=username, password=password) + log.debug(" %s = %s", key, value) + @staticmethod + def login(username, password): + """login to Garmin""" + session = GarminConnect.get_session(email=username, password=password) try: - dashboard = session.get('http://connect.garmin.com/modern',follow_redirects=True) + dashboard = session.get("http://connect.garmin.com/modern") userdata = GarminConnect.get_json(dashboard.text, "VIEWER_SOCIAL_PROFILE") - username = userdata['userName'] + username = userdata["displayName"] - log.info('Garmin Connect User Name: %s', username) + log.info("Garmin Connect User Name: %s", username) - except Exception as e: - log.error(e) - log.error('Unable to retrieve Garmin username! Most likely: ' - 'incorrect Garmin login or password!') + except Exception as exception: # pylint: disable=broad-except + log.error(exception) + log.error( + "Unable to retrieve Garmin username! Most likely: " + "incorrect Garmin login or password!" + ) log.debug(dashboard.text) return session - def upload_file(self, f, session): - files = { - 'data': ( - 'withings.fit', f - ) - } - - res = session.post(self.UPLOAD_URL, - files=files, - headers={'nk': 'NT'}) - + def upload_file(self, ffile, session): + """upload fit file to Garmin connect""" + files = {"data": ("withings.fit", ffile)} + res = session.post(self.UPLOAD_URL, files=files, headers={"nk": "NT"}) try: resp = res.json() - - if 'detailedImportResult' not in resp: + if "detailedImportResult" not in resp: raise KeyError except (ValueError, KeyError): - if res.status_code == 204: # HTTP result 204 - 'no content' - log.error('No data to upload, try to use --fromdate and --todate') + if res.status_code == 204: # HTTP result 204 - 'no content' + log.error("No data to upload, try to use --fromdate and --todate") else: - log.error('Bad response during GC upload: %s', res.status_code) + log.error("Bad response during GC upload: %s", res.status_code) return res.status_code in [200, 201, 204]