run_ical 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +33 -0
- data/.rubocop.yml +34 -1
- data/Gemfile +3 -1
- data/README.md +51 -0
- data/Rakefile +5 -1
- data/exe/run_ical +13 -22
- data/lib/run_ical/data/advanced_10_k.rb +5 -2
- data/lib/run_ical/data/advanced_10_k.yaml +57 -0
- data/lib/run_ical/data/advanced_15_k.rb +5 -2
- data/lib/run_ical/data/advanced_15_k.yaml +71 -0
- data/lib/run_ical/data/advanced_5_k.rb +5 -2
- data/lib/run_ical/data/advanced_5_k.yaml +57 -0
- data/lib/run_ical/data/advanced_8_k.rb +11 -0
- data/lib/run_ical/data/advanced_8_k.yaml +57 -0
- data/lib/run_ical/data/half_marathon_advanced.rb +5 -2
- data/lib/run_ical/data/half_marathon_advanced.yaml +85 -0
- data/lib/run_ical/data/half_marathon_hm3.rb +5 -2
- data/lib/run_ical/data/half_marathon_hm3.yaml +85 -0
- data/lib/run_ical/data/half_marathon_intermediate1.rb +5 -2
- data/lib/run_ical/data/half_marathon_intermediate1.yaml +85 -0
- data/lib/run_ical/data/half_marathon_intermediate2.rb +5 -2
- data/lib/run_ical/data/half_marathon_intermediate2.yaml +85 -0
- data/lib/run_ical/data/half_marathon_novice1.rb +5 -2
- data/lib/run_ical/data/half_marathon_novice1.yaml +85 -0
- data/lib/run_ical/data/half_marathon_novice2.rb +5 -2
- data/lib/run_ical/data/half_marathon_novice2.yaml +85 -0
- data/lib/run_ical/data/half_marathon_walkers.rb +11 -0
- data/lib/run_ical/data/half_marathon_walkers.yaml +85 -0
- data/lib/run_ical/data/intermediate_10_k.rb +5 -2
- data/lib/run_ical/data/intermediate_10_k.yaml +57 -0
- data/lib/run_ical/data/intermediate_15_k.rb +5 -2
- data/lib/run_ical/data/intermediate_15_k.yaml +71 -0
- data/lib/run_ical/data/intermediate_5_k.rb +5 -2
- data/lib/run_ical/data/intermediate_5_k.yaml +57 -0
- data/lib/run_ical/data/intermediate_8_k.rb +5 -2
- data/lib/run_ical/data/intermediate_8_k.yaml +57 -0
- data/lib/run_ical/data/marathon_3.rb +11 -0
- data/lib/run_ical/data/marathon_3.yaml +169 -0
- data/lib/run_ical/data/marathon_advanced1.rb +11 -0
- data/lib/run_ical/data/marathon_advanced1.yaml +127 -0
- data/lib/run_ical/data/marathon_advanced2.rb +11 -0
- data/lib/run_ical/data/marathon_advanced2.yaml +127 -0
- data/lib/run_ical/data/marathon_boston_bound.rb +11 -0
- data/lib/run_ical/data/marathon_boston_bound.yaml +86 -0
- data/lib/run_ical/data/marathon_dopey_challenge.rb +11 -0
- data/lib/run_ical/data/marathon_dopey_challenge.yaml +127 -0
- data/lib/run_ical/data/marathon_intermediate1.rb +11 -0
- data/lib/run_ical/data/marathon_intermediate1.yaml +127 -0
- data/lib/run_ical/data/marathon_intermediate2.rb +11 -0
- data/lib/run_ical/data/marathon_intermediate2.yaml +127 -0
- data/lib/run_ical/data/marathon_novice1.rb +5 -2
- data/lib/run_ical/data/marathon_novice1.yaml +127 -0
- data/lib/run_ical/data/marathon_novice2.rb +4 -1
- data/lib/run_ical/data/marathon_novice2.yaml +90 -89
- data/lib/run_ical/data/marathon_novice_supreme.rb +11 -0
- data/lib/run_ical/data/marathon_novice_supreme.yaml +211 -0
- data/lib/run_ical/data/marathon_personal_best.rb +11 -0
- data/lib/run_ical/data/marathon_personal_best.yaml +211 -0
- data/lib/run_ical/data/marathon_senior.rb +11 -0
- data/lib/run_ical/data/marathon_senior.yaml +57 -0
- data/lib/run_ical/data/novice_10_k.rb +5 -2
- data/lib/run_ical/data/novice_10_k.yaml +57 -0
- data/lib/run_ical/data/novice_15_k.rb +5 -2
- data/lib/run_ical/data/novice_15_k.yaml +71 -0
- data/lib/run_ical/data/novice_5_k.rb +5 -2
- data/lib/run_ical/data/novice_5_k.yaml +57 -0
- data/lib/run_ical/data/novice_8_k.rb +5 -2
- data/lib/run_ical/data/novice_8_k.yaml +57 -0
- data/lib/run_ical/data/walkers_5_k.rb +5 -2
- data/lib/run_ical/data/walkers_5_k.yaml +57 -0
- data/lib/run_ical/data/walking_10_k.rb +5 -2
- data/lib/run_ical/data/walking_10_k.yaml +57 -0
- data/lib/run_ical/data.rb +33 -0
- data/lib/run_ical/version.rb +3 -1
- data/lib/run_ical.rb +47 -27
- data/run_ical.gemspec +15 -10
- data/tasks/higdon_programs.yml +211 -0
- data/tasks/refresh_data.rake +167 -0
- metadata +96 -36
- data/.travis.yml +0 -5
data/run_ical.gemspec
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
|
|
2
|
-
lib = File.expand_path("
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
5
|
require "run_ical/version"
|
|
5
6
|
|
|
@@ -9,30 +10,34 @@ Gem::Specification.new do |spec|
|
|
|
9
10
|
spec.authors = ["Sean Cashin"]
|
|
10
11
|
spec.email = ["scashin133@gmail.com"]
|
|
11
12
|
|
|
12
|
-
spec.summary =
|
|
13
|
-
spec.description =
|
|
13
|
+
spec.summary = "Generate an ical file for a race"
|
|
14
|
+
spec.description = "Uses Hal Higdon's plans for creating a calendar"
|
|
14
15
|
spec.homepage = "https://github.com/scashin133/run_ical"
|
|
15
16
|
spec.license = "MIT"
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
# The data loaders use YAML.safe_load_file, which arrived in Psych 3.3 / Ruby 3.0.
|
|
19
|
+
spec.required_ruby_version = ">= 3.1"
|
|
20
|
+
|
|
21
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
18
22
|
f.match(%r{^(test|spec|features)/})
|
|
19
23
|
end
|
|
20
24
|
spec.bindir = "exe"
|
|
21
25
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
22
26
|
spec.require_paths = ["lib"]
|
|
23
27
|
|
|
24
|
-
spec.add_dependency "
|
|
28
|
+
spec.add_dependency "activesupport", ">= 5.0.0"
|
|
25
29
|
spec.add_dependency "base64"
|
|
26
30
|
spec.add_dependency "bigdecimal"
|
|
31
|
+
spec.add_dependency "icalendar", ">= 2.0.0"
|
|
27
32
|
spec.add_dependency "logger"
|
|
28
33
|
spec.add_dependency "mutex_m"
|
|
29
34
|
spec.add_dependency "ostruct"
|
|
30
|
-
spec.add_dependency "
|
|
31
|
-
spec.add_dependency "activesupport", ">= 5.0.0"
|
|
35
|
+
spec.add_dependency "yaml"
|
|
32
36
|
|
|
33
|
-
spec.add_development_dependency "bundler", "~> 2.0"
|
|
34
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
|
35
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
|
36
37
|
spec.add_development_dependency "irb"
|
|
38
|
+
spec.add_development_dependency "nokogiri" # rake data:refresh
|
|
39
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
37
40
|
spec.add_development_dependency "rdoc"
|
|
41
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
42
|
+
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
38
43
|
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Source manifest for `rake data:refresh`.
|
|
2
|
+
#
|
|
3
|
+
# One entry per training program, in the order they should be presented to users:
|
|
4
|
+
# by distance ascending, then by difficulty. `rake data:refresh` emits POSITION
|
|
5
|
+
# from this order, so reordering here reorders RunIcal::Data.programs.
|
|
6
|
+
#
|
|
7
|
+
# `class` is the public API name passed to RunIcal::Generator.generate(program:)
|
|
8
|
+
# -- never rename an existing one. DESCRIPTION is derived as "<distance> - <level>".
|
|
9
|
+
# `weeks` is asserted against the scraped table so a silent site change fails loudly.
|
|
10
|
+
---
|
|
11
|
+
- class: Novice5K
|
|
12
|
+
file: novice_5_k
|
|
13
|
+
distance: "5-K"
|
|
14
|
+
level: "Novice"
|
|
15
|
+
weeks: 8
|
|
16
|
+
url: https://www.halhigdon.com/training-programs/5k-training/novice-5k/
|
|
17
|
+
- class: Intermediate5K
|
|
18
|
+
file: intermediate_5_k
|
|
19
|
+
distance: "5-K"
|
|
20
|
+
level: "Intermediate"
|
|
21
|
+
weeks: 8
|
|
22
|
+
url: https://www.halhigdon.com/training-programs/5k-training/intermediate-5k/
|
|
23
|
+
- class: Advanced5K
|
|
24
|
+
file: advanced_5_k
|
|
25
|
+
distance: "5-K"
|
|
26
|
+
level: "Advanced"
|
|
27
|
+
weeks: 8
|
|
28
|
+
url: https://www.halhigdon.com/training-programs/5k-training/advanced-5k/
|
|
29
|
+
- class: Walkers5K
|
|
30
|
+
file: walkers_5_k
|
|
31
|
+
distance: "5-K"
|
|
32
|
+
level: "Walkers"
|
|
33
|
+
weeks: 8
|
|
34
|
+
url: https://www.halhigdon.com/training-programs/5k-training/walkers-5k/
|
|
35
|
+
- class: Novice8K
|
|
36
|
+
file: novice_8_k
|
|
37
|
+
distance: "8-K"
|
|
38
|
+
level: "Novice"
|
|
39
|
+
weeks: 8
|
|
40
|
+
url: https://www.halhigdon.com/training-programs/8k-training/novice-8k/
|
|
41
|
+
- class: Intermediate8K
|
|
42
|
+
file: intermediate_8_k
|
|
43
|
+
distance: "8-K"
|
|
44
|
+
level: "Intermediate"
|
|
45
|
+
weeks: 8
|
|
46
|
+
url: https://www.halhigdon.com/training-programs/8k-training/intermediate-8k/
|
|
47
|
+
- class: Advanced8K
|
|
48
|
+
file: advanced_8_k
|
|
49
|
+
distance: "8-K"
|
|
50
|
+
level: "Advanced"
|
|
51
|
+
weeks: 8
|
|
52
|
+
url: https://www.halhigdon.com/training-programs/8k-training/advanced-8k/
|
|
53
|
+
- class: Novice10K
|
|
54
|
+
file: novice_10_k
|
|
55
|
+
distance: "10-K"
|
|
56
|
+
level: "Novice"
|
|
57
|
+
weeks: 8
|
|
58
|
+
url: https://www.halhigdon.com/training-programs/10k-training/novice-10k/
|
|
59
|
+
- class: Intermediate10K
|
|
60
|
+
file: intermediate_10_k
|
|
61
|
+
distance: "10-K"
|
|
62
|
+
level: "Intermediate"
|
|
63
|
+
weeks: 8
|
|
64
|
+
url: https://www.halhigdon.com/training-programs/10k-training/intermediate-10k/
|
|
65
|
+
- class: Advanced10K
|
|
66
|
+
file: advanced_10_k
|
|
67
|
+
distance: "10-K"
|
|
68
|
+
level: "Advanced"
|
|
69
|
+
weeks: 8
|
|
70
|
+
url: https://www.halhigdon.com/training-programs/10k-training/advanced-10k/
|
|
71
|
+
- class: Walking10K
|
|
72
|
+
file: walking_10_k
|
|
73
|
+
distance: "10-K"
|
|
74
|
+
level: "Walkers"
|
|
75
|
+
weeks: 8
|
|
76
|
+
url: https://www.halhigdon.com/training-programs/10k-training/walkers-10k/
|
|
77
|
+
- class: Novice15K
|
|
78
|
+
file: novice_15_k
|
|
79
|
+
distance: "15-K"
|
|
80
|
+
level: "Novice"
|
|
81
|
+
weeks: 10
|
|
82
|
+
url: https://www.halhigdon.com/training-programs/15k-10-mile-training/novice-15k-10-mile/
|
|
83
|
+
- class: Intermediate15K
|
|
84
|
+
file: intermediate_15_k
|
|
85
|
+
distance: "15-K"
|
|
86
|
+
level: "Intermediate"
|
|
87
|
+
weeks: 10
|
|
88
|
+
url: https://www.halhigdon.com/training-programs/15k-10-mile-training/intermediate-15k-10-mile/
|
|
89
|
+
- class: Advanced15K
|
|
90
|
+
file: advanced_15_k
|
|
91
|
+
distance: "15-K"
|
|
92
|
+
level: "Advanced"
|
|
93
|
+
weeks: 10
|
|
94
|
+
url: https://www.halhigdon.com/training-programs/15k-10-mile-training/advanced-15k-10-mile/
|
|
95
|
+
- class: HalfMarathonNovice1
|
|
96
|
+
file: half_marathon_novice1
|
|
97
|
+
distance: "Half Marathon"
|
|
98
|
+
level: "Novice 1"
|
|
99
|
+
weeks: 12
|
|
100
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/novice-1-half-marathon/
|
|
101
|
+
- class: HalfMarathonNovice2
|
|
102
|
+
file: half_marathon_novice2
|
|
103
|
+
distance: "Half Marathon"
|
|
104
|
+
level: "Novice 2"
|
|
105
|
+
weeks: 12
|
|
106
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/novice-2-half-marathon/
|
|
107
|
+
- class: HalfMarathonIntermediate1
|
|
108
|
+
file: half_marathon_intermediate1
|
|
109
|
+
distance: "Half Marathon"
|
|
110
|
+
level: "Intermediate 1"
|
|
111
|
+
weeks: 12
|
|
112
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/intermediate-1-half-marathon/
|
|
113
|
+
- class: HalfMarathonIntermediate2
|
|
114
|
+
file: half_marathon_intermediate2
|
|
115
|
+
distance: "Half Marathon"
|
|
116
|
+
level: "Intermediate 2"
|
|
117
|
+
weeks: 12
|
|
118
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/intermediate-2-half-marathon/
|
|
119
|
+
- class: HalfMarathonAdvanced
|
|
120
|
+
file: half_marathon_advanced
|
|
121
|
+
distance: "Half Marathon"
|
|
122
|
+
level: "Advanced"
|
|
123
|
+
weeks: 12
|
|
124
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/advanced-half-marathon/
|
|
125
|
+
- class: HalfMarathonHM3
|
|
126
|
+
file: half_marathon_hm3
|
|
127
|
+
distance: "Half Marathon"
|
|
128
|
+
level: "HM3"
|
|
129
|
+
weeks: 12
|
|
130
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/half-marathon-3/
|
|
131
|
+
- class: HalfMarathonWalkers
|
|
132
|
+
file: half_marathon_walkers
|
|
133
|
+
distance: "Half Marathon"
|
|
134
|
+
level: "Walkers"
|
|
135
|
+
weeks: 12
|
|
136
|
+
url: https://www.halhigdon.com/training-programs/half-marathon-training/walkers-half-marathon/
|
|
137
|
+
- class: MarathonNovice1
|
|
138
|
+
file: marathon_novice1
|
|
139
|
+
distance: "Marathon"
|
|
140
|
+
level: "Novice 1"
|
|
141
|
+
weeks: 18
|
|
142
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/novice-1-marathon/
|
|
143
|
+
- class: MarathonNovice2
|
|
144
|
+
file: marathon_novice2
|
|
145
|
+
distance: "Marathon"
|
|
146
|
+
level: "Novice 2"
|
|
147
|
+
weeks: 18
|
|
148
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/novice-2-marathon/
|
|
149
|
+
- class: MarathonIntermediate1
|
|
150
|
+
file: marathon_intermediate1
|
|
151
|
+
distance: "Marathon"
|
|
152
|
+
level: "Intermediate 1"
|
|
153
|
+
weeks: 18
|
|
154
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/intermediate-1-marathon/
|
|
155
|
+
- class: MarathonIntermediate2
|
|
156
|
+
file: marathon_intermediate2
|
|
157
|
+
distance: "Marathon"
|
|
158
|
+
level: "Intermediate 2"
|
|
159
|
+
weeks: 18
|
|
160
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/intermediate-2-marathon/
|
|
161
|
+
- class: MarathonAdvanced1
|
|
162
|
+
file: marathon_advanced1
|
|
163
|
+
distance: "Marathon"
|
|
164
|
+
level: "Advanced 1"
|
|
165
|
+
weeks: 18
|
|
166
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/advanced-1-marathon/
|
|
167
|
+
- class: MarathonAdvanced2
|
|
168
|
+
file: marathon_advanced2
|
|
169
|
+
distance: "Marathon"
|
|
170
|
+
level: "Advanced 2"
|
|
171
|
+
weeks: 18
|
|
172
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/advanced-2-marathon/
|
|
173
|
+
- class: MarathonNoviceSupreme
|
|
174
|
+
file: marathon_novice_supreme
|
|
175
|
+
distance: "Marathon"
|
|
176
|
+
level: "Novice Supreme"
|
|
177
|
+
weeks: 30
|
|
178
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/novice-supreme/
|
|
179
|
+
- class: MarathonPersonalBest
|
|
180
|
+
file: marathon_personal_best
|
|
181
|
+
distance: "Marathon"
|
|
182
|
+
level: "Personal Best"
|
|
183
|
+
weeks: 30
|
|
184
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/personal-best/
|
|
185
|
+
- class: MarathonSenior
|
|
186
|
+
file: marathon_senior
|
|
187
|
+
distance: "Marathon"
|
|
188
|
+
level: "Senior"
|
|
189
|
+
weeks: 8
|
|
190
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/senior/
|
|
191
|
+
- class: Marathon3
|
|
192
|
+
file: marathon_3
|
|
193
|
+
distance: "Marathon"
|
|
194
|
+
level: "Marathon 3"
|
|
195
|
+
weeks: 24
|
|
196
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/marathon-3/
|
|
197
|
+
- class: MarathonDopeyChallenge
|
|
198
|
+
file: marathon_dopey_challenge
|
|
199
|
+
distance: "Marathon"
|
|
200
|
+
level: "Dopey Challenge"
|
|
201
|
+
weeks: 18
|
|
202
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/dopey-challenge/
|
|
203
|
+
# Boston is run on Patriots Day (a Monday), so its final table row holds a single
|
|
204
|
+
# Monday cell instead of a full week. The scraper truncates to 12 weeks + race day.
|
|
205
|
+
- class: MarathonBostonBound
|
|
206
|
+
file: marathon_boston_bound
|
|
207
|
+
distance: "Marathon"
|
|
208
|
+
level: "Boston Bound"
|
|
209
|
+
weeks: 13
|
|
210
|
+
partial_final_week: true
|
|
211
|
+
url: https://www.halhigdon.com/training-programs/marathon-training/boston-bound/
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
# Re-scrapes the training plans in lib/run_ical/data from halhigdon.com.
|
|
8
|
+
#
|
|
9
|
+
# rake data:refresh # every program in tasks/higdon_programs.yml
|
|
10
|
+
# rake data:refresh[Novice5K] # just one
|
|
11
|
+
#
|
|
12
|
+
# Every page renders the schedule as a static table with a `Week | Mon..Sun` header
|
|
13
|
+
# row, in miles first and kilometres second. We take the miles table, flatten it
|
|
14
|
+
# row-major into one activity per day, and write a YAML sidecar plus a loader stub.
|
|
15
|
+
module HigdonScraper
|
|
16
|
+
DATA_DIR = File.expand_path("../lib/run_ical/data", __dir__)
|
|
17
|
+
MANIFEST = File.expand_path("higdon_programs.yml", __dir__)
|
|
18
|
+
DAYS_PER_WEEK = 7
|
|
19
|
+
|
|
20
|
+
# The final cell of a plan is the race itself. Higdon isn't consistent about it:
|
|
21
|
+
# most read "<distance> Race", but Novice 10K ends on "10K Run", Walking 10K on
|
|
22
|
+
# "10K Walk", and the marathons on a bare "Marathon" / "Boston Marathon".
|
|
23
|
+
RACE = /\A(\d+K (Race|Run|Walk)|(Boston |Half )?Marathon)\z/i
|
|
24
|
+
|
|
25
|
+
class ScrapeError < StandardError; end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Every program in the manifest, or the single one named. Position is taken from
|
|
30
|
+
# the full manifest so refreshing one program still writes the right ordering.
|
|
31
|
+
def programs(only = nil)
|
|
32
|
+
all = YAML.safe_load_file(MANIFEST)
|
|
33
|
+
all.each_with_index { |program, index| program["position"] = index }
|
|
34
|
+
return all unless only
|
|
35
|
+
|
|
36
|
+
selected = all.select { |program| program["class"] == only }
|
|
37
|
+
raise ScrapeError, "unknown program #{only}" if selected.empty?
|
|
38
|
+
|
|
39
|
+
selected
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scrape(program)
|
|
43
|
+
rows = schedule_rows(Nokogiri::HTML(fetch(program["url"])), program)
|
|
44
|
+
unless rows.size == program["weeks"]
|
|
45
|
+
raise ScrapeError, "#{program['class']}: expected #{program['weeks']} weeks, got #{rows.size}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
activities = rows.flat_map { |row| week_activities(row) }
|
|
49
|
+
activities = trim_partial_final_week(activities) if program["partial_final_week"]
|
|
50
|
+
validate!(program, activities)
|
|
51
|
+
activities
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch(url)
|
|
55
|
+
uri = URI(url)
|
|
56
|
+
response = Net::HTTP.get_response(uri, "User-Agent" => "run_ical data refresh")
|
|
57
|
+
raise ScrapeError, "#{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
58
|
+
|
|
59
|
+
response.body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def schedule_rows(doc, program)
|
|
63
|
+
table = schedule_table(doc)
|
|
64
|
+
raise ScrapeError, "#{program['class']}: no schedule table found" unless table
|
|
65
|
+
|
|
66
|
+
table.css("tbody tr").map { |tr| tr.css("td").map { |td| cell_text(td) } }.reject(&:empty?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The first table whose header starts "Week | Mon" -- the walkers half marathon page
|
|
70
|
+
# leads with an unrelated pace legend, and the kilometre table always follows the
|
|
71
|
+
# miles one, so first-match gives us miles.
|
|
72
|
+
def schedule_table(doc)
|
|
73
|
+
doc.css("table").find do |candidate|
|
|
74
|
+
headers = candidate.css("th").map { |th| cell_text(th) }
|
|
75
|
+
headers[0] == "Week" && headers[1] == "Mon"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Cells are taken by position, not by header: a few pages ship an empty <th> where
|
|
80
|
+
# "Wed" belongs. The 8K pages add a ninth "Miles" total column, and on the novice
|
|
81
|
+
# page the race sits in it rather than under Sunday -- pull it back onto race day.
|
|
82
|
+
def week_activities(row)
|
|
83
|
+
week = row[1, DAYS_PER_WEEK]
|
|
84
|
+
total = row[DAYS_PER_WEEK + 1]
|
|
85
|
+
week[-1] = total if total&.match?(RACE)
|
|
86
|
+
week
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Boston's last row is a lone Monday race day padded with six empty cells.
|
|
90
|
+
def trim_partial_final_week(activities)
|
|
91
|
+
race = activities.reverse.find { |activity| !activity.empty? }
|
|
92
|
+
activities.take(activities.size - DAYS_PER_WEEK) << race
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate!(program, activities)
|
|
96
|
+
blank = activities.index(&:empty?)
|
|
97
|
+
raise ScrapeError, "#{program['class']}: empty activity at index #{blank}" if blank
|
|
98
|
+
|
|
99
|
+
expected = expected_size(program)
|
|
100
|
+
unless activities.size == expected
|
|
101
|
+
raise ScrapeError, "#{program['class']}: expected #{expected} days, got #{activities.size}"
|
|
102
|
+
end
|
|
103
|
+
return if activities.last.match?(RACE)
|
|
104
|
+
|
|
105
|
+
raise ScrapeError, "#{program['class']}: plan ends on #{activities.last.inspect}, not a race"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def expected_size(program)
|
|
109
|
+
weeks = program["weeks"]
|
|
110
|
+
return weeks * DAYS_PER_WEEK unless program["partial_final_week"]
|
|
111
|
+
|
|
112
|
+
((weeks - 1) * DAYS_PER_WEEK) + 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Higdon's markup is littered with non-breaking spaces and hard line breaks.
|
|
116
|
+
def cell_text(node)
|
|
117
|
+
node.text.gsub(/[[:space:]]+/, " ").strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write(program, activities)
|
|
121
|
+
write_yaml(program, activities)
|
|
122
|
+
write_loader(program, program["position"])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Ruby's emitter quotes numeric-looking strings, which keeps the marathon plans'
|
|
126
|
+
# bare mileage cells ("6", "20") loading back as Strings.
|
|
127
|
+
def write_yaml(program, activities)
|
|
128
|
+
body = activities.map { |activity| { "activity" => activity } }
|
|
129
|
+
File.write(File.join(DATA_DIR, "#{program['file']}.yaml"), body.to_yaml)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_loader(program, position)
|
|
133
|
+
File.write(File.join(DATA_DIR, "#{program['file']}.rb"), loader_source(program, position))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# POSITION is the program's index in the manifest, which is ordered by distance
|
|
137
|
+
# then difficulty -- see RunIcal::Data.programs.
|
|
138
|
+
def loader_source(program, position)
|
|
139
|
+
<<~RUBY
|
|
140
|
+
module RunIcal
|
|
141
|
+
module Data
|
|
142
|
+
class #{program['class']}
|
|
143
|
+
DISTANCE = #{program['distance'].inspect}
|
|
144
|
+
LEVEL = #{program['level'].inspect}
|
|
145
|
+
POSITION = #{position}
|
|
146
|
+
DESCRIPTION = "\#{DISTANCE} - \#{LEVEL}"
|
|
147
|
+
DATA = YAML.safe_load_file(File.join(__dir__, '#{program['file']}.yaml'), symbolize_names: true)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
RUBY
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
namespace :data do
|
|
156
|
+
desc "Re-scrape training plan data from halhigdon.com (optionally one program)"
|
|
157
|
+
task :refresh, [:program] do |_t, args|
|
|
158
|
+
require "nokogiri"
|
|
159
|
+
|
|
160
|
+
HigdonScraper.programs(args[:program]).each do |program|
|
|
161
|
+
activities = HigdonScraper.scrape(program)
|
|
162
|
+
HigdonScraper.write(program, activities)
|
|
163
|
+
puts format("%<name>-26s %<days>3d days %<race>s",
|
|
164
|
+
name: program["class"], days: activities.size, race: activities.last)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|