diff --git a/garminexport/cli/backup.py b/garminexport/cli/backup.py index 72fdeee..66a563e 100644 --- a/garminexport/cli/backup.py +++ b/garminexport/cli/backup.py @@ -18,6 +18,10 @@ log = logging.getLogger(__name__) DEFAULT_MAX_RETRIES = 7 """The default maximum number of retries to make when fetching a single activity.""" +DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' +"""The default `User-Agent` to use for HTTP requests when none is supplied by +the user. +""" def parse_args() -> argparse.Namespace: """Parse CLI arguments. @@ -59,6 +63,9 @@ def parse_args() -> argparse.Namespace: help=("The maximum number of retries to make on failed attempts to fetch an activity. " "Exponential backoff will be used, meaning that the delay between successive attempts " "will double with every retry, starting at one second. DEFAULT: {}").format(DEFAULT_MAX_RETRIES)) + parser.add_argument( + "--user-agent", type=str, default=DEFAULT_USER_AGENT, + help="A value to use for the `User-Agent` request header. Use an authentic browser agent string to prevent being blocked by Garmin. A tool such as `user_agent` (`ua`) can be used to generate such values.") return parser.parse_args() @@ -70,6 +77,7 @@ def main(): try: incremental_backup(username=args.username, password=args.password, + user_agent_fn=lambda:DEFAULT_USER_AGENT, backup_dir=args.backup_dir, export_formats=args.format, ignore_errors=args.ignore_errors, diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 2ac4674..e221009 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -81,18 +81,27 @@ class GarminClient(object): """ - def __init__(self, username, password): + def __init__(self, username, password, user_agent_fn=None): """Initialize a :class:`GarminClient` instance. :param username: Garmin Connect user name or email address. :type username: str :param password: Garmin Connect account password. :type password: str + :keyword user_agent_fn: A function that, when called, produces a + `User-Agent` string to be used as `User-Agent` for the remainder of the + session. If set to None, the default user agent of the http request + library is used. + :type user_agent_fn: Callable[[], str] + """ self.username = username self.password = password + self._user_agent_fn = user_agent_fn + self.session = None + def __enter__(self): self.connect() return self @@ -118,7 +127,15 @@ class GarminClient(object): "embed": "false", "_csrf": self._get_csrf_token(), } - headers = {'origin': 'https://sso.garmin.com'} + headers = { + 'origin': 'https://sso.garmin.com', + } + if self._user_agent_fn: + user_agent = self._user_agent_fn() + if not user_agent: + raise ValueError("user_agent_fn didn't produce a value") + headers['User-Agent'] = user_agent + auth_response = self.session.post( SSO_SIGNIN_URL, headers=headers, params=self._auth_params(), data=form_data) log.debug("got auth response: %s", auth_response.text) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py index 7c6b6b3..6c0f883 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -3,7 +3,7 @@ import getpass import logging import os from datetime import timedelta -from typing import List +from typing import Callable, List import garminexport.backup from garminexport.backup import supported_export_formats @@ -15,6 +15,7 @@ log = logging.getLogger(__name__) def incremental_backup(username: str, password: str = None, + user_agent_fn: Callable[[],str] = None, backup_dir: str = os.path.join(".", "activities"), export_formats: List[str] = None, ignore_errors: bool = False, @@ -23,6 +24,11 @@ def incremental_backup(username: str, :param username: Garmin Connect user name :param password: Garmin Connect user password. Default: None. If not provided, would be asked interactively. + :keyword user_agent_fn: A function that, when called, produces a + `User-Agent` string to be used as `User-Agent` for the remainder of the + session. If set to None, the default user agent of the http request + library is used. + :type user_agent_fn: Callable[[], str] :param backup_dir: Destination directory for downloaded activities. Default: ./activities/". :param export_formats: List of desired output formats (json_summary, json_details, gpx, tcx, fit). Default: `None` which means all supported formats will be backed up. @@ -34,6 +40,7 @@ def incremental_backup(username: str, The activities are stored in a local directory on the user's computer. The backups are incremental, meaning that only activities that aren't already stored in the backup directory will be downloaded. + """ # if no --format was specified, all formats are to be backed up export_formats = export_formats if export_formats else supported_export_formats @@ -50,7 +57,7 @@ def incremental_backup(username: str, delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), stop_strategy=MaxRetriesStopStrategy(max_retries)) - with GarminClient(username, password) as client: + with GarminClient(username, password, user_agent_fn) as client: # get all activity ids and timestamps from Garmin account log.info("scanning activities for %s ...", username) activities = set(retryer.call(client.list_activities))