Skip to content

Commit

Permalink
Merge pull request jaroslawhartman#87 from stynoo/cloudscraper
Browse files Browse the repository at this point in the history
garmin.py: re-introduce cloudscraper (+codestyle)
  • Loading branch information
longstone authored Oct 25, 2022
2 parents f381e5c + 7a1f793 commit 464d081
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 144 deletions.
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
lxml
requests
cloudscraper
43 changes: 16 additions & 27 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,25 @@ def read(fname):


setup(
name='withings-sync',
version='3.4.2',
author='Masayuki Hamasaki, Steffen Vogel',
author_email='[email protected]',
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="[email protected]",
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,
)
245 changes: 128 additions & 117 deletions withings_sync/garmin.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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]

0 comments on commit 464d081

Please sign in to comment.