diff --git a/README.md b/README.md index 8deb394..eef03a0 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,11 @@ If you have many activities, you may find that this script crashes with an "Oper You will need a little experience running things from the command line to use this script. That said, here are the usage details from the `--help` flag: ``` -usage: gcexport.py [-h] [--version] [-v] [--username USERNAME] - [--password PASSWORD] [-c COUNT] - [-sd START_DATE] [-ed END_DATE] - [-e EXTERNAL] [-a ARGS] - [-f {gpx,tcx,original,json}] [-d DIRECTORY] [-s SUBDIR] - [-lp LOGPATH] [-u] [-ot] [--desc [DESC]] [-t TEMPLATE] - [-fp] [-sa START_ACTIVITY_NO] [-ex FILE] +usage: gcexport.py [-h] [--version] [-v] [--username USERNAME] [--password PASSWORD] + [-c COUNT] [-sd START_DATE] [-ed END_DATE] [-e EXTERNAL] [-a ARGS] + [-f {gpx,tcx,original,json}] [-d DIRECTORY] [-s SUBDIR] [-lp LOGPATH] + [-u] [-ot] [--desc [DESC]] [-t TEMPLATE] [-fp] [-sa START_ACTIVITY_NO] + [-ex FILE] [-tf TYPE_FILTER] [-ss DIRECTORY] Garmin Connect Exporter @@ -94,10 +92,26 @@ optional arguments: -sa START_ACTIVITY_NO, --start_activity_no START_ACTIVITY_NO give index for first activity to import, i.e. skipping the newest activities -ex FILE, --exclude FILE - JSON file with Array of activity IDs to exclude from download. + JSON file with array of activity IDs to exclude from download. Format example: {"ids": ["6176888711"]} + -tf TYPE_FILTER, --type_filter TYPE_FILTER + comma-separated list of activity type IDs to allow. Format example: 3,9 + -ss DIRECTORY, --session DIRECTORY + enable loading and storing SSO information from/to given directory ``` +### Authentication + +You have to authenticate with username and password, and possibly an MFA code, at least for an initial login. + +The script is then using OAuth tokens (thanks to the [garth](https://github.com/matin/garth) library). +You can persist the OAuth token by giving a session directory, removing the need to provide username/password/MFA +for every script run. + +But keep the persistent tokens safe; if somebody gets hold of your tokens, they might be able to +read all your data in Garmin Connect (e.g. your health data), maybe even change or delete it. + + ### Examples - `python gcexport.py --count all` @@ -181,7 +195,7 @@ For the history of this fork see the [CHANGELOG](CHANGELOG.md) Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md) -Contributors as of 2023-10 (Hope I didn't forget anyone, +Contributors as of 2024-03 (Hope I didn't forget anyone, see also [Contributors](https://github.com/pe-st/garmin-connect-export/graphs/contributors)): - Kyle Krafka @kjkjava @@ -210,6 +224,8 @@ see also [Contributors](https://github.com/pe-st/garmin-connect-export/graphs/co - @geraudloup - @app4g - Simon Ă…gren @agrensimon +- @embear +- Joe Timmerman @joetimmerman ## License diff --git a/gcexport.py b/gcexport.py index e70907f..0bc843d 100644 --- a/gcexport.py +++ b/gcexport.py @@ -46,6 +46,7 @@ # PyPI imports import garth +from garth.exc import GarthException # Local application/library specific imports from filtering import read_exclude, update_download_stats @@ -53,7 +54,7 @@ COOKIE_JAR = http.cookiejar.CookieJar() OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(COOKIE_JAR), urllib.request.HTTPSHandler(debuglevel=0)) -SCRIPT_VERSION = '4.2.0' +SCRIPT_VERSION = '4.3.0' # This version here should correspond to what is written in CONTRIBUTING.md#python-3x-versions MINIMUM_PYTHON_VERSION = (3, 8) @@ -476,7 +477,11 @@ def parse_arguments(argv): parser.add_argument('-sa', '--start_activity_no', type=int, default=1, help='give index for first activity to import, i.e. skipping the newest activities') parser.add_argument('-ex', '--exclude', metavar='FILE', - help='JSON file with Array of activity IDs to exclude from download. Format example: {"ids": ["6176888711"]}') + help='JSON file with array of activity IDs to exclude from download. Format example: {"ids": ["6176888711"]}') + parser.add_argument('-tf', '--type_filter', + help='comma-separated list of activity type IDs to allow. Format example: 3,9') + parser.add_argument('-ss', '--session', metavar='DIRECTORY', + help='enable loading and storing SSO information from/to given directory') # fmt: on return parser.parse_args(argv[1:]) @@ -486,12 +491,40 @@ def login_to_garmin_connect(args): """ Perform all HTTP requests to login to Garmin Connect. """ - username = args.username if args.username else input('Username: ') - password = args.password if args.password else getpass() + garth_session_directory = args.session if args.session else None - print('Authenticating using OAuth...', end=' ') + print('Authenticating...', end='') try: - garth.login(username, password) + login_required = False + + # try to load data if a session directory is given + if garth_session_directory: + try: + garth.resume(garth_session_directory) + except GarthException as ex: + logging.debug("Could not resume session, error: %s", ex) + login_required = True + try: + garth.client.username + except GarthException as ex: + logging.debug("Session expired, error: %s", ex) + login_required = True + logging.info("Authenticating using OAuth token from %s", garth_session_directory) + else: + login_required = True + + if login_required: + username = args.username if args.username else input('Username: ') + password = args.password if args.password else getpass() + garth.login(username, password) + + # try to store data if a session directory is given + if garth_session_directory: + try: + garth.save(garth_session_directory) + except GarthException as ex: + logging.warning("Unable to store session data to %s, error: %s", garth_session_directory, ex) + except Exception as ex: raise GarminException(f'Authentication failure ({ex}). Did you enter correct credentials?') from ex print(' Done.') @@ -930,7 +963,7 @@ def fetch_activity_list(args, total_to_download): return activities -def annotate_activity_list(activities, start, exclude_list): +def annotate_activity_list(activities, start, exclude_list, type_filter): """ Creates an action list with a tuple per activity summary @@ -944,6 +977,7 @@ def annotate_activity_list(activities, start, exclude_list): :param start: One-based index of the first non-skipped activity (i.e. with 1 no activity gets skipped, with 2 the first activity gets skipped etc) :param exclude_list: List of activity ids that have to be skipped explicitly + :param type_filter: list of activity types to include in the output :return: List of action tuples """ @@ -953,6 +987,8 @@ def annotate_activity_list(activities, start, exclude_list): action = 's' elif str(activity['activityId']) in exclude_list: action = 'e' + elif type_filter is not None and activity['activityType']['typeId'] not in type_filter: + action = 'f' else: action = 'd' @@ -1079,13 +1115,14 @@ def copy_details_to_summary(summary, details): # fmt: on -def process_activity_item(item, number_of_items, device_dict, activity_type_name, event_type_name, csv_filter, args): +def process_activity_item(item, number_of_items, device_dict, type_filter, activity_type_name, event_type_name, csv_filter, args): """ Process one activity item: download the data, parse it and write a line to the CSV file :param item: activity item tuple, see `annotate_activity_list()` :param number_of_items: total number of items (for progress output) :param device_dict: cache (dict) of already known devices + :param type_filter: list of activity types to include in the output :param activity_type_name: lookup table for activity type descriptions :param event_type_name: lookup table for event type descriptions :param csv_filter: object encapsulating CSV file access @@ -1109,6 +1146,14 @@ def process_activity_item(item, number_of_items, device_dict, activity_type_name print(f"({current_index}/{number_of_items}) [{actvty['activityId']}]") return + # Action: Filtered out by typeId + if action == 'f': + # Display which entry we're skipping. + type_id = actvty['activityType']['typeId'] + print(f"Filtering out due to type ID {type_id} not in {type_filter}: Garmin Connect activity ", end='') + print(f"({current_index}/{number_of_items}) [{actvty['activityId']}]") + return + # Action: download # Display which entry we're working on. print('Downloading: Garmin Connect activity ', end='') @@ -1241,7 +1286,10 @@ def main(argv): event_type_name = load_properties(event_type_props) activities = fetch_activity_list(args, total_to_download) - action_list = annotate_activity_list(activities, args.start_activity_no, exclude_list) + + type_filter = list(map(int, args.type_filter.split(','))) if args.type_filter is not None else None + + action_list = annotate_activity_list(activities, args.start_activity_no, exclude_list, type_filter) csv_filename = os.path.join(args.directory, 'activities.csv') csv_existed = os.path.isfile(csv_filename) @@ -1256,7 +1304,9 @@ def main(argv): # Process each activity. for item in action_list: - process_activity_item(item, len(action_list), device_dict, activity_type_name, event_type_name, csv_filter, args) + process_activity_item( + item, len(action_list), device_dict, type_filter, activity_type_name, event_type_name, csv_filter, args + ) logging.info('CSV file written.')