footballdata-api 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+