footballdata-api 0.2.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.
@@ -0,0 +1,332 @@
1
+
2
+
3
+
4
+ module Footballdata
5
+
6
+
7
+ TIMEZONES = {
8
+ 'eng.1' => 'Europe/London',
9
+ 'eng.2' => 'Europe/London',
10
+
11
+ 'es.1' => 'Europe/Madrid',
12
+
13
+ 'de.1' => 'Europe/Berlin',
14
+ 'fr.1' => 'Europe/Paris',
15
+ 'it.1' => 'Europe/Rome',
16
+ 'nl.1' => 'Europe/Amsterdam',
17
+
18
+ 'pt.1' => 'Europe/Lisbon',
19
+
20
+ ## todo/fix - pt.1
21
+ ## one team in madeira!!! check for different timezone??
22
+ ## CD Nacional da Madeira
23
+
24
+ 'br.1' => 'America/Sao_Paulo',
25
+ ## todo/fix - brazil has 4 timezones
26
+ ## really only two in use for clubs
27
+ ## west and east (amazonas et al)
28
+ ## for now use west for all - why? why not?
29
+ }
30
+
31
+
32
+ def self.convert( league:, season: )
33
+
34
+ ### note/fix: cl (champions league for now is a "special" case)
35
+ # if league.downcase == 'cl'
36
+ # convert_cl( league: league,
37
+ # season: season )
38
+ # return
39
+ # end
40
+
41
+ season = Season( season ) ## cast (ensure) season class (NOT string, integer, etc.)
42
+
43
+ league_code = LEAGUES[league.downcase]
44
+
45
+ matches_url = Metal.competition_matches_url( league_code, season.start_year )
46
+ teams_url = Metal.competition_teams_url( league_code, season.start_year )
47
+
48
+ data = Webcache.read_json( matches_url )
49
+ data_teams = Webcache.read_json( teams_url )
50
+
51
+
52
+ ## check for time zone
53
+ tz_name = TIMEZONES[ league.downcase ]
54
+ if tz_name.nil?
55
+ puts "!! ERROR - sorry no timezone configured for league #{league}"
56
+ exit 1
57
+ end
58
+
59
+ tz = TZInfo::Timezone.get( tz_name )
60
+ pp tz
61
+
62
+ ## build a (reverse) team lookup by name
63
+ puts "#{data_teams['teams'].size} teams"
64
+
65
+ teams_by_name = data_teams['teams'].reduce( {} ) do |h,rec|
66
+ h[ rec['name'] ] = rec
67
+ h
68
+ end
69
+
70
+ pp teams_by_name.keys
71
+
72
+
73
+
74
+ mods = MODS[ league.downcase ] || {}
75
+
76
+
77
+ recs = []
78
+
79
+ teams = Hash.new( 0 )
80
+
81
+
82
+ # stat = Stat.new
83
+
84
+ # track stati counts
85
+ stati = Hash.new(0)
86
+
87
+
88
+ matches = data[ 'matches']
89
+ matches.each do |m|
90
+ # stat.update( m )
91
+
92
+ team1 = m['homeTeam']['name']
93
+ team2 = m['awayTeam']['name']
94
+
95
+ score = m['score']
96
+
97
+
98
+
99
+ if m['stage'] == 'REGULAR_SEASON'
100
+ teams[ team1 ] += 1
101
+ teams[ team2 ] += 1
102
+
103
+ ### mods - rename club names
104
+ unless mods.nil? || mods.empty?
105
+ team1 = mods[ team1 ] if mods[ team1 ]
106
+ team2 = mods[ team2 ] if mods[ team2 ]
107
+ end
108
+
109
+
110
+
111
+ comments = ''
112
+ ft = ''
113
+ ht = ''
114
+
115
+ stati[m['status']] += 1 ## track stati counts for logs
116
+
117
+ case m['status']
118
+ when 'SCHEDULED', 'TIMED' ## , 'IN_PLAY'
119
+ ft = ''
120
+ ht = ''
121
+ when 'FINISHED'
122
+ ## todo/fix: assert duration == "REGULAR"
123
+ assert( score['duration'] == 'REGULAR', 'score.duration REGULAR expected' )
124
+ ft = "#{score['fullTime']['home']}-#{score['fullTime']['away']}"
125
+ ht = "#{score['halfTime']['home']}-#{score['halfTime']['away']}"
126
+ when 'AWARDED'
127
+ ## todo/fix: assert duration == "REGULAR"
128
+ assert( score['duration'] == 'REGULAR', 'score.duration REGULAR expected' )
129
+ ft = "#{score['fullTime']['home']}-#{score['fullTime']['away']}"
130
+ ft << ' (*)'
131
+ ht = ''
132
+ comments = 'awarded'
133
+ when 'CANCELLED'
134
+ ## note cancelled might have scores!!
135
+ ## ht only or ft+ht!!! (see fr 2021/22)
136
+ ft = '(*)'
137
+ ht = ''
138
+ comments = 'canceled' ## us eng ? -> canceled, british eng. cancelled ?
139
+ when 'POSTPONED'
140
+ ft = '(*)'
141
+ ht = ''
142
+ comments = 'postponed'
143
+ else
144
+ puts "!! ERROR: unsupported match status >#{m['status']}< - sorry:"
145
+ pp m
146
+ exit 1
147
+ end
148
+
149
+
150
+ ##
151
+ ## add time, timezone(tz)
152
+ ## 2023-08-18T18:30:00Z
153
+ ## e.g. "utcDate": "2020-05-09T00:00:00Z",
154
+ ## "utcDate": "2023-08-18T18:30:00Z",
155
+
156
+ ## -- todo - make sure / assert it's always utc - how???
157
+ ## utc = ## tz_utc.strptime( m['utcDate'], '%Y-%m-%dT%H:%M:%SZ' )
158
+ ## note: DateTime.strptime is supposed to be unaware of timezones!!!
159
+ ## use to parse utc
160
+ utc = DateTime.strptime( m['utcDate'], '%Y-%m-%dT%H:%M:%SZ' ).to_time.utc
161
+ assert( utc.strftime( '%Y-%m-%dT%H:%M:%SZ' ) == m['utcDate'], 'utc time mismatch' )
162
+
163
+ local = tz.to_local( utc )
164
+
165
+
166
+ ## do NOT add time if status is SCHEDULED
167
+ ## or POSTPONED for now
168
+ ## otherwise assume time always present - why? why not?
169
+
170
+
171
+ ## todo/fix: assert matchday is a number e.g. 1,2,3, etc.!!!
172
+ recs << [m['matchday'].to_s, ## note: convert integer to string!!!
173
+ local.strftime( '%Y-%m-%d' ),
174
+ ['SCHEDULED','POSTPONED'].include?( m['status'] ) ? '' : local.strftime( '%H:%M' ),
175
+ local.strftime( '%Z / UTC%z' ),
176
+ team1,
177
+ ft,
178
+ ht,
179
+ team2,
180
+ comments,
181
+ ## add more columns e.g. utc date, status
182
+ m['status'], # e.g. FINISHED, TIMED, etc.
183
+ m['utcDate'],
184
+ ]
185
+
186
+
187
+ print '%2s' % m['matchday']
188
+ print ' - '
189
+ print '%-26s' % team1
190
+ print ' '
191
+ print ft
192
+ print ' '
193
+ print "(#{ht})" unless ht.empty?
194
+ print ' '
195
+ print '%-26s' % team2
196
+ print ' '
197
+ print comments
198
+ print ' | '
199
+ ## print date.to_date ## strip time
200
+ print utc.strftime( '%a %b %-d %Y' )
201
+ print ' -- '
202
+ print utc
203
+ print "\n"
204
+ else
205
+ puts "!!! unexpected stage:"
206
+ puts "-- skipping #{m['stage']}"
207
+ # exit 1
208
+ end
209
+ end # each match
210
+
211
+
212
+
213
+ ## note: get season from first match
214
+ ## assert - all other matches include the same season
215
+ ## e.g.
216
+ # "season": {
217
+ # "id": 154,
218
+ # "startDate": "2018-08-03",
219
+ # "endDate": "2019-05-05",
220
+ # "currentMatchday": 46
221
+ # }
222
+
223
+ start_date = Date.strptime( matches[0]['season']['startDate'], '%Y-%m-%d' )
224
+ end_date = Date.strptime( matches[0]['season']['endDate'], '%Y-%m-%d' )
225
+
226
+ dates = "#{start_date.strftime('%b %-d')} - #{end_date.strftime('%b %-d')}"
227
+
228
+ buf = ''
229
+ buf << "#{season.key} (#{dates}) - "
230
+ buf << "#{teams.keys.size} clubs, "
231
+ # buf << "#{stat[:regular_season][:matches]} matches, "
232
+ # buf << "#{stat[:regular_season][:goals]} goals"
233
+ buf << "\n"
234
+
235
+ puts buf
236
+
237
+
238
+ =begin
239
+ ## note: warn if stage is greater one and not regular season!!
240
+ File.open( './errors.txt' , 'a:utf-8' ) do |f|
241
+ if stat[:all][:stage].keys != ['REGULAR_SEASON']
242
+ f.write "!! WARN - league: #{league}, season: #{season.key} includes non-regular stage(s):\n"
243
+ f.write " #{stat[:all][:stage].keys.inspect}\n"
244
+ end
245
+ end
246
+ =end
247
+
248
+
249
+ File.open( './logs.txt', 'a:utf-8' ) do |f|
250
+ f.write "==== #{league} #{season.key} =============\n"
251
+ f.write " match stati: #{stati.inspect}\n"
252
+ end
253
+
254
+ =begin
255
+ f.write "\n================================\n"
256
+ f.write "==== #{league} =============\n"
257
+ f.write buf
258
+ f.write " match status: #{stat[:regular_season][:status].inspect}\n"
259
+ f.write " match duration: #{stat[:regular_season][:duration].inspect}\n"
260
+
261
+ f.write "#{teams.keys.size} teams:\n"
262
+ teams.each do |name, count|
263
+ rec = teams_by_name[ name ]
264
+ f.write " #{count}x #{name}"
265
+ if rec
266
+ f.write " | #{rec['shortName']} " if name != rec['shortName']
267
+ f.write " › #{rec['area']['name']}"
268
+ f.write " - #{rec['address']}"
269
+ else
270
+ puts "!! ERROR - no team record found in teams.json for >#{name}<"
271
+ exit 1
272
+ end
273
+ f.write "\n"
274
+ end
275
+ end
276
+ =end
277
+
278
+
279
+ ##
280
+ ## sort buy utc date ??? - why? why not?
281
+
282
+ # recs = recs.sort { |l,r| l[1] <=> r[1] }
283
+
284
+
285
+ ## reformat date / beautify e.g. Sat Aug 7 1993
286
+ recs = recs.map do |rec|
287
+ rec[1] = Date.strptime( rec[1], '%Y-%m-%d' ).strftime( '%a %b %-d %Y' )
288
+ rec
289
+ end
290
+
291
+
292
+ headers = [
293
+ 'Matchday',
294
+ 'Date',
295
+ 'Time',
296
+ 'Timezone', ## move back column - why? why not?
297
+ 'Team 1',
298
+ 'FT',
299
+ 'HT',
300
+ 'Team 2',
301
+ 'Comments',
302
+ ##
303
+ 'Status', # e.g.
304
+ 'UTC', # date utc
305
+ ]
306
+
307
+ ## note: change season_key from 2019/20 to 2019-20 (for path/directory!!!!)
308
+ write_csv( "#{config.convert.out_dir}/#{season.to_path}/#{league.downcase}.csv",
309
+ recs,
310
+ headers: headers )
311
+
312
+
313
+ teams.each do |name, count|
314
+ rec = teams_by_name[ name ]
315
+ print " #{count}x "
316
+ print name
317
+ if rec
318
+ print " | #{rec['shortName']} " if name != rec['shortName']
319
+ print " › #{rec['area']['name']}"
320
+ print " - #{rec['address']}"
321
+ else
322
+ puts "!! ERROR - no team record found in teams.json for #{name}"
323
+ exit 1
324
+ end
325
+ print "\n"
326
+ end
327
+
328
+ ## pp stat
329
+ end # method convert
330
+ end # module Footballdata
331
+
332
+
@@ -0,0 +1,131 @@
1
+ module Footballdata
2
+
3
+
4
+ #################
5
+ ## porcelain "api"
6
+ def self.schedule( league:, season: )
7
+ season = Season( season ) ## cast (ensure) season class (NOT string, integer, etc.)
8
+
9
+ league_code = LEAGUES[ league.downcase ]
10
+ puts " mapping league >#{league}< to >#{league_code}<"
11
+
12
+ Metal.teams( league_code, season.start_year )
13
+ Metal.matches( league_code, season.start_year )
14
+ end
15
+
16
+
17
+ def self.matches( league:, season: )
18
+ season = Season( season ) ## cast (ensure) season class (NOT string, integer, etc.)
19
+
20
+ league_code = LEAGUES[ league.downcase ]
21
+ puts " mapping league >#{league}< to >#{league_code}<"
22
+ Metal.matches( league_code, season.start_year )
23
+ end
24
+
25
+
26
+ def self.teams( league:, season: )
27
+ season = Season( season ) ## cast (ensure) season class (NOT string, integer, etc.)
28
+
29
+ league_code = LEAGUES[ league.downcase ]
30
+ puts " mapping league >#{league}< to >#{league_code}<"
31
+ Metal.teams( league_code, season.start_year )
32
+ end
33
+
34
+
35
+
36
+ ##################
37
+ ## plumbing metal "helpers"
38
+
39
+ class Metal
40
+
41
+ def self.get( url,
42
+ auth: true,
43
+ headers: {} )
44
+
45
+ token = ENV['FOOTBALLDATA']
46
+ ## note: because of public workflow log - do NOT output token
47
+ ## puts token
48
+
49
+ request_headers = {}
50
+ request_headers['X-Auth-Token'] = token if auth && token
51
+ request_headers['User-Agent'] = 'ruby'
52
+ request_headers['Accept'] = '*/*'
53
+
54
+ request_headers = request_headers.merge( headers ) unless headers.empty?
55
+
56
+
57
+ ## note: add format: 'json' for pretty printing json (before) save in cache
58
+ response = Webget.call( url, headers: request_headers )
59
+
60
+ ## for debugging print pretty printed json first 400 chars
61
+ puts response.json.pretty_inspect[0..400]
62
+
63
+ exit 1 if response.status.nok? # e.g. HTTP status code != 200
64
+
65
+ response.json
66
+ end
67
+
68
+
69
+
70
+ BASE_URL = 'http://api.football-data.org/v4'
71
+
72
+
73
+ def self.competitions_url
74
+ "#{BASE_URL}/competitions"
75
+ end
76
+
77
+ def self.competitions( auth: false )
78
+ get( competitions_url, auth: auth )
79
+ end
80
+
81
+
82
+ ## just use matches_url - why? why not?
83
+ def self.competition_matches_url( code, year ) "#{BASE_URL}/competitions/#{code}/matches?season=#{year}"; end
84
+ def self.competition_teams_url( code, year ) "#{BASE_URL}/competitions/#{code}/teams?season=#{year}"; end
85
+ def self.competition_standings_url( code, year ) "#{BASE_URL}/competitions/#{code}/standings?season=#{year}"; end
86
+ def self.competition_scorers_url( code, year ) "#{BASE_URL}/competitions/#{code}/scorers?season=#{year}"; end
87
+
88
+ def self.matches( code, year,
89
+ headers: {} )
90
+ get( competition_matches_url( code, year ),
91
+ headers: headers )
92
+ end
93
+
94
+ def self.todays_matches_url( date=Date.today )
95
+ "#{BASE_URL}/matches?"+
96
+ "dateFrom=#{date.strftime('%Y-%m-%d')}&"+
97
+ "dateTo=#{(date+1).strftime('%Y-%m-%d')}"
98
+ end
99
+ def self.todays_matches( date=Date.today ) ## use/rename to matches_today or such - why? why not?
100
+ get( todays_matches_url( date ) )
101
+ end
102
+
103
+
104
+ def self.teams( code, year ) get( competition_teams_url( code, year )); end
105
+ def self.standings( code, year ) get( competition_standings_url( code, year )); end
106
+ def self.scorers( code, year ) get( competition_scorers_url( code, year )); end
107
+
108
+ ################
109
+ ## more
110
+ def self.competition( code )
111
+ get( "#{BASE_URL}/competitions/#{code}" )
112
+ end
113
+
114
+ def self.team( id )
115
+ get( "#{BASE_URL}/teams/#{id}" )
116
+ end
117
+
118
+ def self.match( id )
119
+ get( "#{BASE_URL}/matches/#{id}" )
120
+ end
121
+
122
+ def self.person( id )
123
+ get( "#{BASE_URL}/persons/#{id}" )
124
+ end
125
+
126
+ def self.areas
127
+ get( "#{BASE_URL}/areas" )
128
+ end
129
+ end # class Metal
130
+ end # module Footballdata
131
+
@@ -0,0 +1,33 @@
1
+
2
+ ##
3
+ ### check - change Generator to Writer
4
+ ## and write( league:, season: ) - why? why not?
5
+
6
+ module Footballdata
7
+ class Generator
8
+
9
+
10
+
11
+ ###########
12
+ ## always download for now - why? why not?
13
+ ## support cache - why? why not?
14
+ def generate( league:, season: )
15
+
16
+ ## for testing use cached version always - why? why not?
17
+ ## step 1 - download
18
+ Footballdata.schedule( league: league, season: season )
19
+
20
+ ## step 2 - convert (to .csv)
21
+
22
+ ## todo/fix - convert in-memory and return matches
23
+ Footballdata.convert( league: league, season: season )
24
+
25
+ source_dir = Footballdata.config.convert.out_dir
26
+
27
+ Writer.write( league: league,
28
+ season: season,
29
+ source: source_dir )
30
+ end # def generate
31
+
32
+ end # class Generator
33
+ end # module Footballdata
@@ -0,0 +1,59 @@
1
+ module Footballdata
2
+
3
+ LEAGUES = {
4
+ 'eng.1' => 'PL', # incl. team(s) from wales
5
+ 'eng.2' => 'ELC',
6
+ # PL - Premier League , England 27 seasons | 2019-08-09 - 2020-07-25 / matchday 31
7
+ # ELC - Championship , England 3 seasons | 2019-08-02 - 2020-07-22 / matchday 38
8
+ #
9
+ # 2019 => 2019/20
10
+ # 2018 => 2018/19
11
+ # 2017 => xxx 2017-18 - requires subscription !!!
12
+
13
+ 'es.1' => 'PD',
14
+ # PD - Primera Division , Spain 27 seasons | 2019-08-16 - 2020-07-19 / matchday 31
15
+
16
+ 'pt.1' => 'PPL',
17
+ # PPL - Primeira Liga , Portugal 9 seasons | 2019-08-10 - 2020-07-26 / matchday 28
18
+
19
+ 'de.1' => 'BL1',
20
+ # BL1 - Bundesliga , Germany 24 seasons | 2019-08-16 - 2020-06-27 / matchday 34
21
+
22
+ 'nl.1' => 'DED',
23
+ # DED - Eredivisie , Netherlands 10 seasons | 2019-08-09 - 2020-03-08 / matchday 34
24
+
25
+ 'fr.1' => 'FL1', # incl. team(s) monaco
26
+ # FL1 - Ligue 1, France
27
+ # 9 seasons | 2019-08-09 - 2020-05-31 / matchday 38
28
+ #
29
+ # 2019 => 2019/20
30
+ # 2018 => 2018/19
31
+ # 2017 => xxx 2017-18 - requires subscription !!!
32
+
33
+ 'it.1' => 'SA',
34
+ # SA - Serie A , Italy 15 seasons | 2019-08-24 - 2020-08-02 / matchday 27
35
+
36
+ 'br.1' => 'BSA',
37
+ # BSA - Série A, Brazil
38
+ # 4 seasons | 2020-05-03 - 2020-12-06 / matchday 10
39
+ #
40
+ # 2020 => 2020
41
+ # 2019 => 2019
42
+ # 2018 => 2018
43
+ # 2017 => xxx 2017 - requires subscription !!!
44
+
45
+ ## todo/check: use champs and NOT cl - why? why not?
46
+ 'uefa.cl' => 'CL', ## note: cl is country code for chile!! - use champs - why? why not?
47
+ ## was europe.cl / cl
48
+
49
+ ## Copa Libertadores
50
+ 'copa.l' => 'CLI',
51
+
52
+ ############
53
+ ## national teams
54
+ 'euro' => 'EC',
55
+ 'world' => 'WC',
56
+
57
+ }
58
+ end # module Footballdata
59
+
@@ -0,0 +1,22 @@
1
+ module Footballdata
2
+
3
+ #########
4
+ ## Mods
5
+ # e.g.
6
+ # Cardiff City FC | Cardiff › Wales - Cardiff City Stadium, Leckwith Road Cardiff CF11 8AZ
7
+ # AS Monaco FC | Monaco › Monaco - Avenue des Castellans Monaco 98000
8
+
9
+ MODS = {
10
+ 'br.1' => {
11
+ 'América FC' => 'América Mineiro', # in year 2018 ??
12
+ },
13
+ 'pt.1' => {
14
+ 'Vitória SC' => 'Vitória Guimarães', ## avoid easy confusion with Vitória SC <=> Vitória FC
15
+ 'Vitória FC' => 'Vitória Setúbal',
16
+ },
17
+ }
18
+
19
+ end # module Footballdata
20
+
21
+
22
+