55import dateparser
66import requests_html
77from loguru import logger
8+ import re
89
910from . import models
1011
@@ -45,7 +46,8 @@ def get_next_workouts(config) -> Generator[models.Workout, None, None]:
4546 for day in upcoming :
4647 if day .find (".workout" ):
4748 date = dateparser .parse (day .find (".title" , first = True ).text )
48- workout_url = day .find (".workout a" , first = True ).absolute_links .pop ()
49+ workout_url = day .find (
50+ ".workout a" , first = True ).absolute_links .pop ()
4951 yield get_workout (workout_url , date , config )
5052 found = True
5153 if not found :
@@ -71,121 +73,110 @@ def decode_cloudflare_email(encoded_email):
7173
7274
7375def get_workout (workout_url : str , date : datetime .date , config : models .Config ) -> models .Workout :
74- r = tao_session .get (workout_url )
75- try :
76- workout_html = r .html
77- steps = workout_html .find (".workoutSteps>ol>li" )
78- w = models .Workout ()
79- w .date = date
80- name = workout_html .find (".summary span" , first = True ).text
81- number_element = workout_html .find (".summary sup" , first = True )
82- cf_email = number_element .find (".__cf_email__" , first = True )
83- if cf_email :
84- number = decode_cloudflare_email (cf_email .attrs ['data-cfemail' ])
85- else :
86- number = number_element .text
87- number = number .rstrip ("@" )
76+ workout_json_url = workout_url .replace (
77+ "plannedWorkout?" , "plannedWorkoutDownload?sourceFormat=GARMIN_TRAINING&" )
78+ r = tao_session .get (workout_json_url , headers = {'Content-Type' : 'application/json; charset=utf-8' })
79+ r .encoding = r .apparent_encoding
80+ r_base = tao_session .get (workout_url )
81+ w = models .Workout ()
82+ w .date = date
8883
89- w .duration = parse_duration (workout_html .find (".detail>span" , first = True ).text )
90- w .distance = parse_distance (workout_html .find (".detail" , first = True ).text )
84+ try :
85+ # Fetch the duration and distance from TAO
86+ workout_html = r_base .html
87+ w .duration = parse_duration (
88+ workout_html .find (".detail>span" , first = True ).text )
89+ w .distance = parse_distance (
90+ workout_html .find (".detail" , first = True ).text )
91+
92+ r .encoding = 'utf-8'
93+ workout_json = r .json ()
94+ steps = workout_json ["steps" ]
95+ title = workout_json ["workoutName" ]
96+ m = re .match ("^W([A-Z\d]+)@? (.*)" , title )
97+ number = m .group (1 )
98+ name = m .group (2 ).strip ()
9199 w .id = number
92100 w .name = f"{ number } { name } "
101+
93102 logger .info ("Converting TrainAsOne workout to power." )
94- w .steps = list (convert_steps (steps , config ))
103+ w .steps = list (convert_steps (
104+ steps , config , "Perceived Effort" in name ))
95105 return w
96106 except Exception as exc :
97107 raise FindWorkoutException (
98108 f"Error finding workout steps: { exc .args } " , "taoworkout.html" , r .text
99109 ) from exc
100110
101111
102- def convert_steps (steps , config : models .Config ) -> Generator [models .Step , None , None ]:
103- for index , step in enumerate (steps ):
104- if step .find ("ol" ):
105- times_match = re .search (r" (\d+) times" , step .text )
106- if times_match :
107- times = int (times_match .group (1 ))
108- else :
109- times = 1
112+ def convert_steps (steps , config : models .Config , perceived_effort : bool ) -> Generator [models .Step , None , None ]:
113+ recovery_step_types = ["REST" , "RECOVERY" , "COOLDOWN" ]
114+ active_step_types = ["ACTIVE" , "INTERVAL" ]
115+ for step in steps :
116+ if step ["type" ] == "WorkoutRepeatStep" :
117+ times = int (step ["repeatValue" ])
110118 out_step = models .RepeatStep (times )
111- out_step .steps = list (convert_steps (step .find ("ol>li" ), config ))
112- out_step .steps [0 ].type = "REST"
119+ repeat_steps = list (convert_steps (
120+ step ["steps" ], config , perceived_effort ))
121+ out_step .steps = repeat_steps
113122 else :
114123 out_step = models .ConcreteStep ()
115- out_step .description = step .text
124+ if "description" in step :
125+ out_step .description = step ["description" ]
116126
117- if "pace-VERY_EASY" in step . attrs [ "class" ] :
118- if index < 2 :
119- out_step . type = "WARMUP"
120- else :
127+ if step [ "intensity" ] == "WARMUP" :
128+ out_step . type = "WARMUP"
129+ elif step [ "intensity" ] in active_step_types :
130+ if "targetValueLow" in step and step [ "targetValueLow" ] == 0.0 :
121131 out_step .type = "REST"
122- elif any (
123- t in step . attrs [ "class" ] for t in [ "pace-RECOVERY" , "pace-STANDING" ]
124- ) :
132+ else :
133+ out_step . type = "ACTIVE"
134+ elif step [ "intensity" ] in recovery_step_types :
125135 out_step .type = "REST"
126- else :
127- out_step .type = "ACTIVE"
128136
129- try :
130- out_step .length = parse_duration (step .text )
131- except ValueError :
132- # 3.2km assessments are the only steps that do not have a duration
133- distance = parse_distance (step .text )
134- out_step .power_range = suggested_power_range_for_distance (distance )
137+ if step ["durationType" ] == "DISTANCE" :
138+ distance = step ["durationValue" ] * models .meter
139+ out_step .power_range = suggested_power_range_for_distance (
140+ distance )
135141 out_step .length = distance
136142 yield out_step
137143 continue
144+ else :
145+ out_step .length = step ["durationValue" ] * models .second
138146
139147 try :
140- out_step .pace_range = parse_pace_range (step .text )
141- except ValueError :
142- # 6 minute assessments, RECOVERY, and perceived effort segments do not have a pace
148+ out_step .pace_range = parse_pace_range (
149+ step ["targetValueLow" ], step ["targetValueHigh" ])
150+ except (ValueError , KeyError ):
151+ # 6 minute assessments, RECOVERY, COOLDOWN, and perceived effort segments do not have a pace
143152 # Provide a generous power range based on %CP for slower ranges
144- if "pace-RECOVERY" in step .attrs ["class" ]:
145- out_step .power_range = models .PowerRange (0 , get_critical_power () * 0.8 )
146- elif "pace-EXTREME" in step .attrs ["class" ]:
147- out_step .power_range = suggested_power_range_for_time (
148- out_step .length
149- )
150- elif "pace-VERY_EASY" in step .attrs ["class" ]:
151- # Perceived effort warmup
152- cp = get_critical_power ()
153- out_step .power_range = models .PowerRange (cp * 0.3 , cp * 0.8 )
154- elif "pace-EASY" in step .attrs ["class" ]:
155- # Perceived effort main body
156- cp = get_critical_power ()
157- out_step .power_range = models .PowerRange (cp * 0.55 , cp * 0.9 )
158-
153+ if step ["targetType" ] == "OPEN" :
154+ if perceived_effort :
155+ if step ["stepOrder" ] == 2 :
156+ # Perceived effort warmup
157+ cp = get_critical_power ()
158+ out_step .power_range = models .PowerRange (
159+ cp * 0.3 , cp * 0.8 )
160+ elif step ["stepOrder" ] == 3 :
161+ # Perceived effort main body
162+ cp = get_critical_power ()
163+ out_step .power_range = models .PowerRange (
164+ cp * 0.55 , cp * 0.9 )
165+ else :
166+ # Standing
167+ out_step .power_range = models .PowerRange (0 , 50 )
168+ elif step ["intensity" ] in recovery_step_types :
169+ out_step .power_range = models .PowerRange (
170+ 0 , get_critical_power () * 0.8 )
171+ else :
172+ out_step .power_range = suggested_power_range_for_time (
173+ out_step .length )
174+ else :
175+ raise ValueError (
176+ "Failed to parse pace_range for step without an OPEN target." )
159177 else :
160- out_step .power_range = convert_pace_range_to_power (out_step .pace_range )
161-
162- if "pace-VERY_EASY" in step .attrs ["class" ]:
163- out_step .power_range = models .PowerRange (
164- out_step .power_range .min + config .very_easy_pace_adjust [0 ],
165- out_step .power_range .max + config .very_easy_pace_adjust [1 ],
166- )
167- elif "pace-EASY" in step .attrs ["class" ]:
168- out_step .power_range = models .PowerRange (
169- out_step .power_range .min + config .easy_pace_adjust [0 ],
170- out_step .power_range .max + config .easy_pace_adjust [1 ],
171- )
172- elif "pace-RECOVERY" in step .attrs ["class" ]:
173- out_step .power_range = models .PowerRange (
174- out_step .power_range .min + config .recovery_pace_adjust [0 ],
175- out_step .power_range .max + config .recovery_pace_adjust [1 ],
176- )
177- elif "pace-FAST" in step .attrs ["class" ]:
178- out_step .power_range = models .PowerRange (
179- out_step .power_range .min + config .fast_pace_adjust [0 ],
180- out_step .power_range .max + config .fast_pace_adjust [1 ],
181- )
182- elif "pace-EXTREME" in step .attrs ["class" ]:
183- out_step .power_range = models .PowerRange (
184- out_step .power_range .min + config .extreme_pace_adjust [0 ],
185- out_step .power_range .max + config .extreme_pace_adjust [1 ],
186- )
187- elif "pace-STANDING" in step .attrs ["class" ]:
188- out_step .power_range = models .PowerRange (0 , 50 )
178+ out_step .power_range = convert_pace_range_to_power (
179+ out_step .pace_range )
189180
190181 yield out_step
191182
@@ -195,22 +186,11 @@ def parse_time(pace_string: str) -> models.Quantity:
195186 return min * models .minute + sec * models .second
196187
197188
198- def parse_pace_range (step_string : str ) -> models .PaceRange :
199- range_match = re .search (r"\[(.*)\]" , step_string )
200- if not range_match :
201- raise ValueError (f"Could not find pace range in `{ step_string } `" )
202- range_string = range_match .group (1 ).strip ()
203- length = models .mile
204- if range_string .endswith ("km" ):
205- length = models .kilometer
206- range_string = range_string [:- 4 ]
207- min , max = range_string .split ("-" )
208- if range_string .startswith (">" ):
209- max = parse_time (max ) / length
210- min = models .ureg .Quantity (0 , units = models .second / length )
211- else :
212- min , max = parse_time (min ) / length , parse_time (max ) / length
213- return models .PaceRange (min , max )
189+ def parse_pace_range (min_provided : float , max_provided : float ) -> models .PaceRange :
190+ min = 0.0
191+ if min_provided != 0.0 :
192+ min = (1 / min_provided )
193+ return models .PaceRange (min * models .second / models .meter , (1 / max_provided ) * models .second / models .meter )
214194
215195
216196def parse_distance (text : str ) -> models .Quantity :
0 commit comments