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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +33 -0
  3. data/.rubocop.yml +34 -1
  4. data/Gemfile +3 -1
  5. data/README.md +51 -0
  6. data/Rakefile +5 -1
  7. data/exe/run_ical +13 -22
  8. data/lib/run_ical/data/advanced_10_k.rb +5 -2
  9. data/lib/run_ical/data/advanced_10_k.yaml +57 -0
  10. data/lib/run_ical/data/advanced_15_k.rb +5 -2
  11. data/lib/run_ical/data/advanced_15_k.yaml +71 -0
  12. data/lib/run_ical/data/advanced_5_k.rb +5 -2
  13. data/lib/run_ical/data/advanced_5_k.yaml +57 -0
  14. data/lib/run_ical/data/advanced_8_k.rb +11 -0
  15. data/lib/run_ical/data/advanced_8_k.yaml +57 -0
  16. data/lib/run_ical/data/half_marathon_advanced.rb +5 -2
  17. data/lib/run_ical/data/half_marathon_advanced.yaml +85 -0
  18. data/lib/run_ical/data/half_marathon_hm3.rb +5 -2
  19. data/lib/run_ical/data/half_marathon_hm3.yaml +85 -0
  20. data/lib/run_ical/data/half_marathon_intermediate1.rb +5 -2
  21. data/lib/run_ical/data/half_marathon_intermediate1.yaml +85 -0
  22. data/lib/run_ical/data/half_marathon_intermediate2.rb +5 -2
  23. data/lib/run_ical/data/half_marathon_intermediate2.yaml +85 -0
  24. data/lib/run_ical/data/half_marathon_novice1.rb +5 -2
  25. data/lib/run_ical/data/half_marathon_novice1.yaml +85 -0
  26. data/lib/run_ical/data/half_marathon_novice2.rb +5 -2
  27. data/lib/run_ical/data/half_marathon_novice2.yaml +85 -0
  28. data/lib/run_ical/data/half_marathon_walkers.rb +11 -0
  29. data/lib/run_ical/data/half_marathon_walkers.yaml +85 -0
  30. data/lib/run_ical/data/intermediate_10_k.rb +5 -2
  31. data/lib/run_ical/data/intermediate_10_k.yaml +57 -0
  32. data/lib/run_ical/data/intermediate_15_k.rb +5 -2
  33. data/lib/run_ical/data/intermediate_15_k.yaml +71 -0
  34. data/lib/run_ical/data/intermediate_5_k.rb +5 -2
  35. data/lib/run_ical/data/intermediate_5_k.yaml +57 -0
  36. data/lib/run_ical/data/intermediate_8_k.rb +5 -2
  37. data/lib/run_ical/data/intermediate_8_k.yaml +57 -0
  38. data/lib/run_ical/data/marathon_3.rb +11 -0
  39. data/lib/run_ical/data/marathon_3.yaml +169 -0
  40. data/lib/run_ical/data/marathon_advanced1.rb +11 -0
  41. data/lib/run_ical/data/marathon_advanced1.yaml +127 -0
  42. data/lib/run_ical/data/marathon_advanced2.rb +11 -0
  43. data/lib/run_ical/data/marathon_advanced2.yaml +127 -0
  44. data/lib/run_ical/data/marathon_boston_bound.rb +11 -0
  45. data/lib/run_ical/data/marathon_boston_bound.yaml +86 -0
  46. data/lib/run_ical/data/marathon_dopey_challenge.rb +11 -0
  47. data/lib/run_ical/data/marathon_dopey_challenge.yaml +127 -0
  48. data/lib/run_ical/data/marathon_intermediate1.rb +11 -0
  49. data/lib/run_ical/data/marathon_intermediate1.yaml +127 -0
  50. data/lib/run_ical/data/marathon_intermediate2.rb +11 -0
  51. data/lib/run_ical/data/marathon_intermediate2.yaml +127 -0
  52. data/lib/run_ical/data/marathon_novice1.rb +5 -2
  53. data/lib/run_ical/data/marathon_novice1.yaml +127 -0
  54. data/lib/run_ical/data/marathon_novice2.rb +4 -1
  55. data/lib/run_ical/data/marathon_novice2.yaml +90 -89
  56. data/lib/run_ical/data/marathon_novice_supreme.rb +11 -0
  57. data/lib/run_ical/data/marathon_novice_supreme.yaml +211 -0
  58. data/lib/run_ical/data/marathon_personal_best.rb +11 -0
  59. data/lib/run_ical/data/marathon_personal_best.yaml +211 -0
  60. data/lib/run_ical/data/marathon_senior.rb +11 -0
  61. data/lib/run_ical/data/marathon_senior.yaml +57 -0
  62. data/lib/run_ical/data/novice_10_k.rb +5 -2
  63. data/lib/run_ical/data/novice_10_k.yaml +57 -0
  64. data/lib/run_ical/data/novice_15_k.rb +5 -2
  65. data/lib/run_ical/data/novice_15_k.yaml +71 -0
  66. data/lib/run_ical/data/novice_5_k.rb +5 -2
  67. data/lib/run_ical/data/novice_5_k.yaml +57 -0
  68. data/lib/run_ical/data/novice_8_k.rb +5 -2
  69. data/lib/run_ical/data/novice_8_k.yaml +57 -0
  70. data/lib/run_ical/data/walkers_5_k.rb +5 -2
  71. data/lib/run_ical/data/walkers_5_k.yaml +57 -0
  72. data/lib/run_ical/data/walking_10_k.rb +5 -2
  73. data/lib/run_ical/data/walking_10_k.yaml +57 -0
  74. data/lib/run_ical/data.rb +33 -0
  75. data/lib/run_ical/version.rb +3 -1
  76. data/lib/run_ical.rb +47 -27
  77. data/run_ical.gemspec +15 -10
  78. data/tasks/higdon_programs.yml +211 -0
  79. data/tasks/refresh_data.rake +167 -0
  80. metadata +96 -36
  81. 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("../lib", __FILE__)
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 = %q{Generate an ical file for a race}
13
- spec.description = %q{Uses Hal Higdon's plans for creating a calendar}
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
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
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 "yaml"
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 "icalendar", ">= 2.0.0"
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