adapted to new authentication procedure
This commit is contained in:
parent
ef9b875465
commit
aaed3f44bb
@ -59,7 +59,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-E", "--ignore-errors", action='store_true',
|
"-E", "--ignore-errors", action='store_true',
|
||||||
help="Ignore errors and keep going. Default: FALSE")
|
help="Ignore errors and keep going. Default: FALSE")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not args.log_level in LOG_LEVELS:
|
if not args.log_level in LOG_LEVELS:
|
||||||
raise ValueError("Illegal log-level: {}".format(args.log_level))
|
raise ValueError("Illegal log-level: {}".format(args.log_level))
|
||||||
@ -67,32 +67,31 @@ if __name__ == "__main__":
|
|||||||
# if no --format was specified, all formats are to be backed up
|
# if no --format was specified, all formats are to be backed up
|
||||||
args.format = args.format if args.format else export_formats
|
args.format = args.format if args.format else export_formats
|
||||||
log.info("backing up formats: %s", ", ".join(args.format))
|
log.info("backing up formats: %s", ", ".join(args.format))
|
||||||
|
|
||||||
logging.root.setLevel(LOG_LEVELS[args.log_level])
|
logging.root.setLevel(LOG_LEVELS[args.log_level])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not os.path.isdir(args.backup_dir):
|
if not os.path.isdir(args.backup_dir):
|
||||||
os.makedirs(args.backup_dir)
|
os.makedirs(args.backup_dir)
|
||||||
|
|
||||||
if not args.password:
|
if not args.password:
|
||||||
args.password = getpass.getpass("Enter password: ")
|
args.password = getpass.getpass("Enter password: ")
|
||||||
|
|
||||||
with GarminClient(args.username, args.password) as client:
|
with GarminClient(args.username, args.password) as client:
|
||||||
# get all activity ids and timestamps from Garmin account
|
# get all activity ids and timestamps from Garmin account
|
||||||
log.info("retrieving activities for {} ...".format(args.username))
|
log.info("scanning activities for %s ...", args.username)
|
||||||
activities = set(client.list_activities())
|
activities = set(client.list_activities())
|
||||||
log.info("account has a total of {} activities.".format(
|
log.info("account has a total of %d activities", len(activities))
|
||||||
len(activities)))
|
|
||||||
|
|
||||||
missing_activities = garminexport.backup.need_backup(
|
missing_activities = garminexport.backup.need_backup(
|
||||||
activities, args.backup_dir, args.format)
|
activities, args.backup_dir, args.format)
|
||||||
backed_up = activities - missing_activities
|
backed_up = activities - missing_activities
|
||||||
log.info("{} contains {} backed up activities.".format(
|
log.info("%s contains %d backed up activities",
|
||||||
args.backup_dir, len(backed_up)))
|
args.backup_dir, len(backed_up))
|
||||||
|
|
||||||
|
log.info("activities that aren't backed up: %d",
|
||||||
|
len(missing_activities))
|
||||||
|
|
||||||
log.info("activities that aren't backed up: {}".format(
|
|
||||||
len(missing_activities)))
|
|
||||||
|
|
||||||
for index, activity in enumerate(missing_activities):
|
for index, activity in enumerate(missing_activities):
|
||||||
id, start = activity
|
id, start = activity
|
||||||
log.info("backing up activity %d from %s (%d out of %d) ..." %
|
log.info("backing up activity %d from %s (%d out of %d) ..." %
|
||||||
@ -106,5 +105,4 @@ if __name__ == "__main__":
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||||
log.error(u"failed with exception: %s", e)
|
log.error(u"failed with exception: %s", str(e))
|
||||||
raise
|
|
||||||
|
@ -49,10 +49,10 @@ def require_session(client_function):
|
|||||||
client_object = args[0]
|
client_object = args[0]
|
||||||
if not client_object.session:
|
if not client_object.session:
|
||||||
raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'")
|
raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'")
|
||||||
return client_function(*args, **kwargs)
|
return client_function(*args, **kwargs)
|
||||||
return check_session
|
return check_session
|
||||||
|
|
||||||
|
|
||||||
class GarminClient(object):
|
class GarminClient(object):
|
||||||
"""A client class used to authenticate with Garmin Connect and
|
"""A client class used to authenticate with Garmin Connect and
|
||||||
extract data from the user account.
|
extract data from the user account.
|
||||||
@ -62,13 +62,13 @@ class GarminClient(object):
|
|||||||
automatically take care of logging in to Garmin Connect before any
|
automatically take care of logging in to Garmin Connect before any
|
||||||
further interactions and logging out after the block completes or
|
further interactions and logging out after the block completes or
|
||||||
a failure occurs.
|
a failure occurs.
|
||||||
|
|
||||||
Example of use: ::
|
Example of use: ::
|
||||||
with GarminClient("my.sample@sample.com", "secretpassword") as client:
|
with GarminClient("my.sample@sample.com", "secretpassword") as client:
|
||||||
ids = client.list_activity_ids()
|
ids = client.list_activity_ids()
|
||||||
for activity_id in ids:
|
for activity_id in ids:
|
||||||
gpx = client.get_activity_gpx(activity_id)
|
gpx = client.get_activity_gpx(activity_id)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, username, password):
|
def __init__(self, username, password):
|
||||||
@ -86,119 +86,70 @@ class GarminClient(object):
|
|||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.connect()
|
self.connect()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self._authenticate()
|
self._authenticate()
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
if self.session:
|
if self.session:
|
||||||
self.session.close()
|
self.session.close()
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
def _authenticate(self):
|
def _authenticate(self):
|
||||||
log.info("authenticating user ...")
|
log.info("authenticating user ...")
|
||||||
params = {
|
form_data = {
|
||||||
"service": "http://connect.garmin.com/post-auth/login",
|
"username": self.username,
|
||||||
"clientId": "GarminConnect",
|
"password": self.password,
|
||||||
"consumeServiceTicket": "false",
|
"embed": "false"
|
||||||
"gauthHost": "https://sso.garmin.com/sso"
|
}
|
||||||
}
|
request_params = {
|
||||||
flow_execution_key = self._get_flow_execution_key(params)
|
"service": "https://connect.garmin.com/modern"
|
||||||
log.debug("flow execution key: '{}'".format(flow_execution_key))
|
}
|
||||||
validation_url = self._get_auth_ticket(flow_execution_key, params)
|
auth_response = self.session.post(
|
||||||
# recently, the validation url has started to escape slash characters
|
SSO_LOGIN_URL, params=request_params, data=form_data)
|
||||||
# (with a backslash). remove any such occurences.
|
log.debug("got auth response: %s", auth_response.text)
|
||||||
validation_url = validation_url.replace("\/", "/")
|
if auth_response.status_code != 200:
|
||||||
log.debug("auth ticket validation url: {}".format(validation_url))
|
raise ValueError(
|
||||||
self._validate_auth_ticket(validation_url)
|
"authentication failure: did you enter valid credentials?")
|
||||||
|
auth_ticket_url = self._extract_auth_ticket_url(
|
||||||
|
auth_response.text)
|
||||||
|
log.debug("auth ticket url: '%s'", auth_ticket_url)
|
||||||
|
|
||||||
# Referer seems to be a header that is required by the REST API
|
log.info("claiming auth ticket ...")
|
||||||
self.session.headers.update({'Referer': "https://some.random.site"})
|
response = self.session.get(auth_ticket_url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_flow_execution_key(self, request_params):
|
|
||||||
# The flowExecutionKey is embedded in the
|
|
||||||
# https://sso.garmin.com/sso/login response page. For example:
|
|
||||||
# <!-- flowExecutionKey: [e3s1] -->
|
|
||||||
log.debug("get flow execution key ...")
|
|
||||||
response = self.session.get(SSO_LOGIN_URL, params=request_params)
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"auth failure: %s: code %d: %s" %
|
"auth failure: failed to claim auth ticket: %s: %d\n%s" %
|
||||||
(SSO_LOGIN_URL, response.status_code, response.text))
|
(auth_ticket_url, response.status_code, response.text))
|
||||||
# extract flowExecutionKey
|
|
||||||
match = re.search(r'<!-- flowExecutionKey: \[(\w+)\]', response.text)
|
# appears like we need to touch base with the old API to initiate
|
||||||
|
# some form of legacy session. otherwise certain downloads will fail.
|
||||||
|
self.session.get('https://connect.garmin.com/legacy/session')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_auth_ticket_url(self, auth_response):
|
||||||
|
"""Extracts an authentication ticket URL from the response of an
|
||||||
|
authentication form submission. The auth ticket URL is typically
|
||||||
|
of form:
|
||||||
|
|
||||||
|
https://connect.garmin.com/modern?ticket=ST-0123456-aBCDefgh1iJkLmN5opQ9R-cas
|
||||||
|
|
||||||
|
:param auth_response: HTML response from an auth form submission.
|
||||||
|
"""
|
||||||
|
match = re.search(
|
||||||
|
r'response_url\s*=\s*"(https:[^"]+)"', auth_response)
|
||||||
if not match:
|
if not match:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"auth failure: unable to extract flowExecutionKey: %s:\n%s" %
|
"auth failure: unable to extract auth ticket URL. did you provide a correct username/password?")
|
||||||
(SSO_LOGIN_URL, response.text))
|
auth_ticket_url = match.group(1).replace("\\", "")
|
||||||
flow_execution_key = match.group(1)
|
return auth_ticket_url
|
||||||
return flow_execution_key
|
|
||||||
|
|
||||||
|
|
||||||
def _get_auth_ticket(self, flow_execution_key, request_params):
|
|
||||||
data = {
|
|
||||||
"username": self.username, "password": self.password,
|
|
||||||
"_eventId": "submit", "embed": "true", "lt": flow_execution_key
|
|
||||||
}
|
|
||||||
log.debug("single sign-on ...")
|
|
||||||
sso_response = self.session.post(
|
|
||||||
SSO_LOGIN_URL, params=request_params,
|
|
||||||
data=data, allow_redirects=False)
|
|
||||||
# response must contain an SSO ticket
|
|
||||||
ticket_match = re.search("ticket=([^']+)'", sso_response.text)
|
|
||||||
if not ticket_match:
|
|
||||||
raise ValueError("auth failure: unable to get auth ticket: "
|
|
||||||
"did you enter valid credentials?")
|
|
||||||
ticket = ticket_match.group(1)
|
|
||||||
log.debug("SSO ticket: {}".format(ticket))
|
|
||||||
# response should contain a URL where auth ticket can be validated
|
|
||||||
validation_url = re.search(
|
|
||||||
r"response_url\s+=\s+'([^']+)'", sso_response.text)
|
|
||||||
validation_url = validation_url.group(1)
|
|
||||||
return validation_url
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_auth_ticket(self, validation_url):
|
|
||||||
log.debug("validating auth ticket at %s ...", validation_url)
|
|
||||||
response = self.session.get(validation_url, allow_redirects=False)
|
|
||||||
|
|
||||||
# It appears as if from this point on, the User-Agent header needs to
|
|
||||||
# be set to something similar to the value below for authentication
|
|
||||||
# to succeed and for downloads of .fit files to work properly.
|
|
||||||
self.session.headers.update({
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36'
|
|
||||||
})
|
|
||||||
|
|
||||||
# we will be redirected several times. just follow through ..
|
|
||||||
while response.status_code == 302:
|
|
||||||
redirect_url = response.headers['Location']
|
|
||||||
log.debug("redirected to: '%s'", redirect_url)
|
|
||||||
response = self.session.get(redirect_url, allow_redirects=False)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise Exception(
|
|
||||||
u"auth failure: unable to validate auth ticket: {}:\n{}".format(
|
|
||||||
response.status_code, response.text))
|
|
||||||
|
|
||||||
# auth ticket successfully validated.
|
|
||||||
# our client should now have all necessary cookies set.
|
|
||||||
|
|
||||||
# as a final step in the "Garmin login rain dance", it appears
|
|
||||||
# as though we need to touch on their legacy session page before
|
|
||||||
# being granted access to some api services (such as the
|
|
||||||
# activity-search-service).
|
|
||||||
self.session.get('https://connect.garmin.com/legacy/session')
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@require_session
|
@require_session
|
||||||
def list_activities(self):
|
def list_activities(self):
|
||||||
"""Return all activity ids stored by the logged in user, along
|
"""Return all activity ids stored by the logged in user, along
|
||||||
@ -206,7 +157,7 @@ class GarminClient(object):
|
|||||||
|
|
||||||
:returns: The full list of activity identifiers.
|
:returns: The full list of activity identifiers.
|
||||||
:rtype: tuples of (int, datetime)
|
:rtype: tuples of (int, datetime)
|
||||||
"""
|
"""
|
||||||
ids = []
|
ids = []
|
||||||
batch_size = 100
|
batch_size = 100
|
||||||
# fetch in batches since the API doesn't allow more than a certain
|
# fetch in batches since the API doesn't allow more than a certain
|
||||||
@ -225,12 +176,12 @@ class GarminClient(object):
|
|||||||
|
|
||||||
Should the index be out of bounds or the account empty, an empty
|
Should the index be out of bounds or the account empty, an empty
|
||||||
list is returned.
|
list is returned.
|
||||||
|
|
||||||
:param start_index: The index of the first activity to retrieve.
|
:param start_index: The index of the first activity to retrieve.
|
||||||
:type start_index: int
|
:type start_index: int
|
||||||
:param max_limit: The (maximum) number of activities to retrieve.
|
:param max_limit: The (maximum) number of activities to retrieve.
|
||||||
:type max_limit: int
|
:type max_limit: int
|
||||||
|
|
||||||
:returns: A list of activity identifiers.
|
:returns: A list of activity identifiers.
|
||||||
:rtype: list of str
|
:rtype: list of str
|
||||||
"""
|
"""
|
||||||
@ -253,8 +204,8 @@ class GarminClient(object):
|
|||||||
for entry in results["activities"] ]
|
for entry in results["activities"] ]
|
||||||
log.debug("got {} activities.".format(len(entries)))
|
log.debug("got {} activities.".format(len(entries)))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
@require_session
|
@require_session
|
||||||
def get_activity_summary(self, activity_id):
|
def get_activity_summary(self, activity_id):
|
||||||
"""Return a summary about a given activity. The
|
"""Return a summary about a given activity. The
|
||||||
summary contains several statistics, such as duration, GPS starting
|
summary contains several statistics, such as duration, GPS starting
|
||||||
@ -287,10 +238,10 @@ class GarminClient(object):
|
|||||||
response = self.session.get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activityDetails/{}".format(activity_id))
|
response = self.session.get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activityDetails/{}".format(activity_id))
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format(
|
raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format(
|
||||||
activity_id, response.status_code, response.text))
|
activity_id, response.status_code, response.text))
|
||||||
return json.loads(response.text)
|
return json.loads(response.text)
|
||||||
|
|
||||||
@require_session
|
@require_session
|
||||||
def get_activity_gpx(self, activity_id):
|
def get_activity_gpx(self, activity_id):
|
||||||
"""Return a GPX (GPS Exchange Format) representation of a
|
"""Return a GPX (GPS Exchange Format) representation of a
|
||||||
given activity. If the activity cannot be exported to GPX
|
given activity. If the activity cannot be exported to GPX
|
||||||
@ -308,7 +259,7 @@ class GarminClient(object):
|
|||||||
# and is the one used when exporting through the Garmin
|
# and is the one used when exporting through the Garmin
|
||||||
# Connect web page.
|
# Connect web page.
|
||||||
#response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id))
|
#response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id))
|
||||||
|
|
||||||
# A 404 (Not Found) or 204 (No Content) response are both indicators
|
# A 404 (Not Found) or 204 (No Content) response are both indicators
|
||||||
# of a gpx file not being available for the activity. It may, for
|
# of a gpx file not being available for the activity. It may, for
|
||||||
# example be a manually entered activity without any device data.
|
# example be a manually entered activity without any device data.
|
||||||
@ -316,7 +267,7 @@ class GarminClient(object):
|
|||||||
return None
|
return None
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(u"failed to fetch GPX for activity {}: {}\n{}".format(
|
raise Exception(u"failed to fetch GPX for activity {}: {}\n{}".format(
|
||||||
activity_id, response.status_code, response.text))
|
activity_id, response.status_code, response.text))
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
@ -334,13 +285,13 @@ class GarminClient(object):
|
|||||||
or ``None`` if the activity cannot be exported to TCX.
|
or ``None`` if the activity cannot be exported to TCX.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.session.get("https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}".format(activity_id))
|
response = self.session.get("https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}".format(activity_id))
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
return None
|
return None
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(u"failed to fetch TCX for activity {}: {}\n{}".format(
|
raise Exception(u"failed to fetch TCX for activity {}: {}\n{}".format(
|
||||||
activity_id, response.status_code, response.text))
|
activity_id, response.status_code, response.text))
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
@ -372,9 +323,9 @@ class GarminClient(object):
|
|||||||
fn, ext = os.path.splitext(path)
|
fn, ext = os.path.splitext(path)
|
||||||
if fn==str(activity_id):
|
if fn==str(activity_id):
|
||||||
return ext[1:], zip.open(path).read()
|
return ext[1:], zip.open(path).read()
|
||||||
return (None,None)
|
return (None,None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_fit(self, activity_id):
|
def get_activity_fit(self, activity_id):
|
||||||
"""Return a FIT representation for a given activity. If the activity
|
"""Return a FIT representation for a given activity. If the activity
|
||||||
doesn't have a FIT source (for example, if it was entered manually
|
doesn't have a FIT source (for example, if it was entered manually
|
||||||
|
Loading…
Reference in New Issue
Block a user