Skip to content

Commit 41a76db

Browse files
authored
Merge pull request #19 from jahofmann/master
Use TAO provided JSON for workout generation
2 parents 78f6bb8 + df17ccb commit 41a76db

File tree

4 files changed

+93
-110
lines changed

4 files changed

+93
-110
lines changed

trainaspower/finalsurge.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def add_workout(workout: models.Workout) -> None:
144144
add_wo = finalsurge_session.post(
145145
"https://beta.finalsurge.com/api/WorkoutSave",
146146
params=params,
147-
json={
147+
data=json.dumps({
148148
"key": wo_key,
149149
"workout_date": workout.date.isoformat(),
150150
"order": 1,
@@ -158,7 +158,8 @@ def add_workout(workout: models.Workout) -> None:
158158
"planned_amount_type": f"{workout.distance.units:~}",
159159
"planned_duration": round(workout.duration.to("seconds").magnitude),
160160
},
161-
},
161+
}).encode("utf-8"),
162+
headers={'Content-Type': 'application/json; charset=UTF-8'},
162163
)
163164
if not wo_key:
164165
wo_key = add_wo.json()["new_workout_key"]
@@ -184,4 +185,4 @@ def remove_workout(wo_date: date) -> None:
184185
}
185186
response = finalsurge_session.get(
186187
"https://beta.finalsurge.com/api/WorkoutDelete", params=params
187-
)
188+
)

trainaspower/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def setup_logging():
2626
rotation="3 days",
2727
retention="6 days",
2828
diagnose=True,
29+
encoding="utf-8"
2930
)
3031

3132

trainaspower/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ureg = UnitRegistry()
1010
mile = ureg.mile
1111
kilometer = ureg.kilometer
12+
meter = ureg.meter
1213
second = ureg.second
1314
minute = ureg.minute
1415

trainaspower/trainasone.py

Lines changed: 87 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dateparser
66
import requests_html
77
from loguru import logger
8+
import re
89

910
from . 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

7375
def 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

216196
def parse_distance(text: str) -> models.Quantity:

0 commit comments

Comments
 (0)