diff --git a/garmin_uploader/api.py b/garmin_uploader/api.py index aef868d..17f3e09 100644 --- a/garmin_uploader/api.py +++ b/garmin_uploader/api.py @@ -1,4 +1,4 @@ -import requests +import cloudscraper import re from garmin_uploader import logger @@ -39,12 +39,11 @@ def authenticate(self, username, password): on Garmin Connect as closely as possible Outputs a Requests session, loaded with precious cookies """ - # Use a valid Browser user agent - # TODO: use several UA picked randomly - session = requests.Session() - session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:48.0) Gecko/20100101 Firefox/50.0', # noqa - }) + + # Use Cloudscraper to avoid cloudflare spam detection + # session = cloudscraper.create_scraper( browser={ 'browser': 'firefox', 'platform': 'windows', 'mobile': False } ) + session = cloudscraper.create_scraper() + logger.info('Using cloud scraper lib') # Request sso hostname sso_hostname = None @@ -64,7 +63,7 @@ def authenticate(self, username, password): ('redirectAfterAccountLoginUrl', 'https://connect.garmin.com/modern/'), # noqa ('redirectAfterAccountCreationUrl', 'https://connect.garmin.com/modern/'), # noqa ('gauthHost', sso_hostname), - ('locale', 'fr_FR'), + ('locale', 'en_US'), ('id', 'gauth-widget'), ('cssUrl', 'https://connect.garmin.com/gauth-custom-v3.2-min.css'), ('privacyStatementUrl', 'https://www.garmin.com/fr-FR/privacy/connect/'), # noqa @@ -215,20 +214,22 @@ def load_activity_types(self): Fetch valid activity types from Garmin Connect """ # Only fetch once - if self.activity_types: - return self.activity_types + if GarminAPI.activity_types: + return GarminAPI.activity_types logger.debug('Fetching activity types') - resp = requests.get(URL_ACTIVITY_TYPES, headers=self.common_headers) + # Use Cloudscraper to avoid cloudflare spam detection + session = cloudscraper.create_scraper() + resp = session.get(URL_ACTIVITY_TYPES, headers=self.common_headers) if not resp.ok: raise GarminAPIException('Failed to retrieve activity types') # Store as a clean dict, mapping keys and lower case common name types = resp.json() - self.activity_types = {t['typeKey']: t for t in types} + GarminAPI.activity_types = {t['typeKey']: t for t in types} - logger.debug('Fetched {} activity types'.format(len(self.activity_types))) # noqa - return self.activity_types + logger.debug('Fetched {} activity types'.format(len(GarminAPI.activity_types))) # noqa + return GarminAPI.activity_types def set_activity_type(self, session, activity): """ @@ -254,3 +255,37 @@ def set_activity_type(self, session, activity): res = session.post(url, json=data, headers=headers) if not res.ok: raise GarminAPIException('Activity type not set: {}'.format(res.content)) # noqa + + def set_activity_info(self, session, activity): + """ + Update activity fields + """ + assert activity.id is not None + + data = { + 'activityId': activity.id + } + + if activity.type: + # Load the corresponding type key on Garmin Connect + types = self.load_activity_types() + type_key = types.get(activity.type) + if type_key is None: + logger.error("Activity type '{}' not valid".format(activity.type)) + return False + else: + data['activityTypeDTO'] = type_key + + if activity.name: + data['activityName'] = activity.name + if activity.notes: + data['description'] = activity.notes + + assert len(data) > 1 + + url = '{}/{}'.format(URL_ACTIVITY_BASE, activity.id) + headers = dict(self.common_headers) # clone + headers['X-HTTP-Method-Override'] = 'PUT' # weird. again. + res = session.post(url, json=data, headers=headers) + if not res.ok: + raise GarminAPIException('Activity info not set: {}'.format(res.content)) # noqa diff --git a/garmin_uploader/workflow.py b/garmin_uploader/workflow.py index 8e80e06..6560f25 100644 --- a/garmin_uploader/workflow.py +++ b/garmin_uploader/workflow.py @@ -14,11 +14,12 @@ class Activity(object): """ Garmin Connect Activity model """ - def __init__(self, path, name=None, type=None): + def __init__(self, path, name=None, type=None, notes=None): self.id = None # provided on upload self.path = path self.name = name self.type = type + self.notes = notes def __repr__(self): if self.id is None: @@ -86,19 +87,12 @@ def upload(self, user): if uploaded: logger.info('Uploaded activity {}'.format(self)) - # Set activity name if specified - if self.name: + # Set activity info, if specified + if self.name or self.type or self.notes: try: - api.set_activity_name(user.session, self) + api.set_activity_info(user.session, self) except GarminAPIException as e: - logger.warning('Activity name update failed: {}'.format(e)) - - # Set activity type if specified - if self.type: - try: - api.set_activity_type(user.session, self) - except GarminAPIException as e: - logger.warning('Activity type update failed: {}'.format(e)) + logger.warning('Activity info update failed: {}'.format(e)) else: logger.info('Activity already uploaded {}'.format(self)) @@ -164,10 +158,10 @@ def is_activity(filename): # Valid file extensions are .tcx, .fit, and .gpx if extension in VALID_GARMIN_FILE_EXTENSIONS: - logger.debug("File '{}' extension '{}' is valid.".format(filename, extension)) # noqa + logger.debug("File '{}' extension '{}' is valid track file. ".format(filename, extension)) # noqa return True else: - logger.warning("File '{}' extension '{}' is not valid. Skipping file...".format(filename, extension)) # noqa + logger.warning("File '{}' extension '{}' is not valid track file. Skipping file...".format(filename, extension)) # noqa return False valid_paths, csv_files = [], [] @@ -208,13 +202,16 @@ def is_activity(filename): with open(csv_file, 'r') as csvfile: reader = csv.DictReader(csvfile) activities += [ - Activity(row['filename'], row['name'], row['type']) + Activity(row['filename'], row['name'], row['type'], row['notes']) for row in reader if is_activity(row['filename']) ] if len(activities) == 0: raise Exception('No valid files.') + else: + logger.info("{} activities will be processed...".format(len(activities))) + return activities @@ -238,9 +235,8 @@ def rate_limit(self): self.last_request = 0.0 wait_time = max(0, min_period - (time.time() - self.last_request)) - if wait_time <= 0: - return - time.sleep(wait_time) + if wait_time > 0: + logger.info("Rate limited for %f" % wait_time) + time.sleep(wait_time) self.last_request = time.time() - logger.info("Rate limited for %f" % wait_time)