diff --git a/requirements.txt b/requirements.txt index 3e0c1de..c2cd2ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ lxml requests cloudscraper +garth diff --git a/setup.py b/setup.py index bf706ff..e00068f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ def read(fname): setup( name="withings-sync", - version="3.6.2", + version="4.0.0", 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.", @@ -26,7 +26,7 @@ def read(fname): "Topic :: Utilities", "License :: OSI Approved :: MIT License", ], - install_requires=["lxml", "requests", "cloudscraper"], + install_requires=["lxml", "requests", "cloudscraper", "garth"], entry_points={ "console_scripts": ["withings-sync=withings_sync.sync:main"], }, diff --git a/withings_sync/garmin.py b/withings_sync/garmin.py index bdba85b..256edbc 100644 --- a/withings_sync/garmin.py +++ b/withings_sync/garmin.py @@ -1,11 +1,7 @@ """This module handles the Garmin connectivity.""" -import urllib.request -import urllib.error -import urllib.parse -import re -import json import logging import cloudscraper +import garth log = logging.getLogger("garmin") @@ -26,27 +22,7 @@ class APIException(Exception): class GarminConnect: """Main GarminConnect class""" - 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 - ): # 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.build_opener( - _HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie) - ) + UPLOAD_URL = "https://connect.garmin.com/upload-service/upload/.fit" # From https://github.com/cpfair/tapiriik @staticmethod @@ -54,149 +30,18 @@ def get_session(email=None, password=None): """tapiriik get_session code""" session = cloudscraper.CloudScraper() - data = { - "username": email, - "password": password, - "_eventId": "submit", - "embed": "true", - } - params = { - "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( - 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", 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" - - # 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"] - - # Fix up relative redirects. - if url.startswith("/"): - url = url_prefix + url - 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 - - # GarminConnect.print_cookies(session.cookies) - session.headers.update(headers) + try: + garth.login(email, password) + except Exception as ex: + raise APIException("Authentication failure: {}. Did you enter correct credentials?".format(ex)) + session.headers.update({'NK': 'NT', 'authorization': garth.client.oauth2_token.__str__(), 'di-backend': 'connectapi.garmin.com'}) return session - @staticmethod - def get_json(page_html, key): - """Return json from text.""" - found = re.search(key + r" = (\{.*\});", page_html, re.M) - if found: - json_text = found.group(1).replace('\\"', '"') - return json.loads(json_text) - return None - - @staticmethod - def print_cookies(cookies): - """print cookies""" - log.debug("Cookies: ") - for key, value in list(cookies.items()): - 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") - userdata = GarminConnect.get_json(dashboard.text, "VIEWER_SOCIAL_PROFILE") - username = userdata["displayName"] - - log.info("Garmin Connect User Name: %s", username) - - 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 + return GarminConnect.get_session(email=username, password=password) def upload_file(self, ffile, session): """upload fit file to Garmin connect"""