cbf_calendario 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e9f80f797d23bf64dad179c1baf17f12c3bada78eb661eeda597bd121d0567ac
4
+ data.tar.gz: d5a7130a8c70bb594e014b0d82f557ab3f87e14025b9cafcec3b6db269844b0d
5
+ SHA512:
6
+ metadata.gz: e161e22aaf7d4445367436f72dedc23a16b3fd4bc5adf0fdc53bf79ed818eca3481aa8f6c2a8780a201b5322b0b184ce22c84771d428ffbab0e85e029b7cbb80
7
+ data.tar.gz: 403ce1fcccb386a7507e2740df54dd7c008f8088005cba73cc92eb0015bc0177ad36b8f4ffd388ef0fe3b1845b468452c07f4adb0a024d5629c5a645ea8dcfd9
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0] - 2026-05-11
4
+
5
+ - `Client#atletas_do_clube` e atalho `CbfCalendario.atletas_do_clube`
6
+ - `Client#atleta_por_id` e atalho `CbfCalendario.atleta_por_id`
7
+ - `Client#clube_por_id` e atalho `CbfCalendario.clube_por_id`
8
+ - Novos erros de validação: `InvalidClubIdError` e `InvalidAthleteIdError`
9
+ - README simplificado e atualizado com as novas funções e exemplos de retorno
10
+
11
+ ## [0.2.0] - 2026-05-10
12
+
13
+ - `Client#partida_completa` / `Client#jogo_partida` — API `/api/cbf/jogos/:id` (mesmo escopo de dados que `show_game.rb`)
14
+ - `CbfCalendario.estatisticas_agregadas(jogo)` e `CbfCalendario::PartidaStats`
15
+ - `CbfCalendario::Urls` — paths e URLs da página da partida e dos times
16
+ - `InvalidGameIdError`; cliente com `open_timeout`
17
+
18
+ ## [0.1.0] - 2026-05-10
19
+
20
+ - Primeira publicação no RubyGems
21
+ - `CbfCalendario::Client` com `jogos_pendentes_no_dia` e `calendario_json`
22
+ - Atalhos no módulo `CbfCalendario`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,605 @@
1
+ # cbf_calendario
2
+
3
+ Gem Ruby para consultar a API pública da CBF e retornar dados prontos para uso.
4
+
5
+ ## O que a gem faz
6
+
7
+ - Lista jogos pendentes (sem placar) por dia.
8
+ - Busca a partida completa por `id_jogo`.
9
+ - Busca atletas por `id_clube`.
10
+ - Busca atleta por `id_atleta`.
11
+ - Busca clube por `id_clube`.
12
+ - Gera estatísticas agregadas da súmula.
13
+ - Monta URLs públicas da CBF (partida e times).
14
+ - Retorna hashes Ruby para facilitar integração.
15
+
16
+ ## Instalação
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem 'cbf_calendario', '~> 0.2'
21
+ ```
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ Ou em script Ruby:
28
+
29
+ ```bash
30
+ gem install cbf_calendario
31
+ ```
32
+
33
+ ```ruby
34
+ require 'cbf_calendario'
35
+ ```
36
+
37
+ ## Uso rápido
38
+
39
+ ```ruby
40
+ require 'cbf_calendario'
41
+
42
+ jogos = CbfCalendario.jogos_pendentes_no_dia('10/05/2026')
43
+ partida = CbfCalendario.partida_completa('832031')
44
+ jogo = CbfCalendario.jogo_partida('832031')
45
+ atletas = CbfCalendario.atletas_do_clube('20001')
46
+ atleta = CbfCalendario.atleta_por_id('12345')
47
+ clube = CbfCalendario.clube_por_id('20001')
48
+ stats = CbfCalendario.estatisticas_agregadas(jogo)
49
+ ```
50
+
51
+ ## Funcionalidades principais
52
+
53
+ ### 1) Jogos pendentes por data
54
+
55
+ Retorna apenas partidas ainda sem placar no dia informado.
56
+
57
+ ### 2) Partida completa por ID
58
+
59
+ Consulta `GET /api/cbf/jogos/:id` e devolve o payload completo da API.
60
+
61
+ ### 3) Estatísticas da súmula
62
+
63
+ Agrega eventos como gols, penalidades/cartões e substituições.
64
+
65
+ ### 4) URLs públicas da CBF
66
+
67
+ Monta links da página da partida e das páginas de times.
68
+
69
+ ## Referência completa de funções públicas
70
+
71
+ ### Módulo `CbfCalendario` (atalhos)
72
+
73
+ - `CbfCalendario.parse_data_br!(str)`
74
+ - `CbfCalendario.jogos_pendentes_no_dia(data, **opts)`
75
+ - `CbfCalendario.partida_completa(id_jogo, **opts)`
76
+ - `CbfCalendario.jogo_partida(id_jogo, **opts)`
77
+ - `CbfCalendario.atletas_do_clube(id_clube, **opts)`
78
+ - `CbfCalendario.atleta_por_id(id_atleta, **opts)`
79
+ - `CbfCalendario.clube_por_id(id_clube, **opts)`
80
+ - `CbfCalendario.estatisticas_agregadas(jogo)`
81
+
82
+ ### Classe `CbfCalendario::Client`
83
+
84
+ - `CbfCalendario::Client.new(base_url: ..., read_timeout: ..., open_timeout: ...)`
85
+ - `client.jogos_pendentes_no_dia(data)`
86
+ - `client.calendario_json(data)`
87
+ - `client.partida_completa(id_jogo)`
88
+ - `client.jogo_partida(id_jogo)`
89
+ - `client.atletas_do_clube(id_clube)`
90
+ - `client.atleta_por_id(id_atleta)`
91
+ - `client.clube_por_id(id_clube)`
92
+ - `CbfCalendario::Client.parse_data_br!(str)`
93
+ - `CbfCalendario::Client.coerce_date!(data)`
94
+ - `CbfCalendario::Client.normalize_id_jogo!(id_jogo)`
95
+ - `CbfCalendario::Client.normalize_id_clube!(id_clube)`
96
+ - `CbfCalendario::Client.normalize_id_atleta!(id_atleta)`
97
+
98
+ ### Módulo `CbfCalendario::PartidaStats`
99
+
100
+ - `CbfCalendario::PartidaStats.agregadas(jogo)`
101
+
102
+ ### Módulo `CbfCalendario::Urls`
103
+
104
+ - `CbfCalendario::Urls.slug_segment(str)`
105
+ - `CbfCalendario::Urls.segmento_campeonato(nome)`
106
+ - `CbfCalendario::Urls.segmento_categoria(nome_serie)`
107
+ - `CbfCalendario::Urls.path_pagina_jogo(jogo)`
108
+ - `CbfCalendario::Urls.url_time(campeonato_nome, categoria_nome, ano, clube_id, base: ...)`
109
+ - `CbfCalendario::Urls.url_pagina_partida(jogo, base: ...)`
110
+
111
+ ### Constante
112
+
113
+ - `CbfCalendario::VERSION`
114
+
115
+ ## Exemplos de respostas (retornos)
116
+
117
+ ### `CbfCalendario.parse_data_br!('25/12/2026')`
118
+
119
+ ```ruby
120
+ # => #<Date: 2026-12-25 ...>
121
+ ```
122
+
123
+ ### `CbfCalendario.jogos_pendentes_no_dia('10/05/2026')`
124
+
125
+ ```ruby
126
+ # => [
127
+ # {
128
+ # campeonato: "Campeonato Brasileiro",
129
+ # serie: "Série A",
130
+ # mandante: "Flamengo",
131
+ # visitante: "Bahia",
132
+ # placar_ou_horario: "16:00",
133
+ # data: "10/05/2026",
134
+ # data_iso: "2026-05-10",
135
+ # local: "Maracanã",
136
+ # rodada: "6",
137
+ # id_jogo: "832031"
138
+ # }
139
+ # ]
140
+ ```
141
+
142
+ ### `client.calendario_json(Date.today)`
143
+
144
+ ```ruby
145
+ # => {
146
+ # "jogos" => {
147
+ # "Campeonato Brasileiro" => {
148
+ # "Série A" => [
149
+ # { "id_jogo" => 832031, "mandante" => {...}, "visitante" => {...}, ... }
150
+ # ]
151
+ # }
152
+ # }
153
+ # }
154
+ ```
155
+
156
+ ### `CbfCalendario.partida_completa('832031')`
157
+
158
+ ```ruby
159
+ # => {
160
+ # "jogo" => {
161
+ # "id_jogo" => 832031,
162
+ # "mandante" => { "id" => 1, "nome" => "Flamengo", "gols" => 2, ... },
163
+ # "visitante" => { "id" => 2, "nome" => "Bahia", "gols" => 1, ... },
164
+ # "registros" => [ ... ],
165
+ # ...
166
+ # }
167
+ # }
168
+ ```
169
+
170
+ ### `CbfCalendario.jogo_partida('832031')`
171
+
172
+ ```ruby
173
+ # => {
174
+ # "id_jogo" => 832031,
175
+ # "campeonato" => { "nome" => "Campeonato Brasileiro", "nome_categoria" => "Série A", "ano" => 2026 },
176
+ # "mandante" => { "id" => 1, "nome" => "Flamengo", ... },
177
+ # "visitante" => { "id" => 2, "nome" => "Bahia", ... },
178
+ # "registros" => [ ... ]
179
+ # }
180
+ ```
181
+
182
+ ### `CbfCalendario.atletas_do_clube('20001')`
183
+
184
+ ```ruby
185
+ # => {
186
+ # clube_id: "20001",
187
+ # atletas: [
188
+ # {
189
+ # "id_atleta" => 12345,
190
+ # "nome_popular" => "Atleta Exemplo",
191
+ # "nome_completo" => "Atleta Exemplo da Silva",
192
+ # "posicao" => "MEI",
193
+ # ...
194
+ # }
195
+ # ]
196
+ # }
197
+ ```
198
+
199
+ ### `CbfCalendario.atleta_por_id('12345')`
200
+
201
+ ```ruby
202
+ # => {
203
+ # atleta_id: "12345",
204
+ # atleta: {
205
+ # "id_atleta" => 12345,
206
+ # "nome_popular" => "Atleta Exemplo",
207
+ # "nome_completo" => "Atleta Exemplo da Silva",
208
+ # "posicao" => "ATA",
209
+ # ...
210
+ # }
211
+ # }
212
+ ```
213
+
214
+ ### `CbfCalendario.clube_por_id('20001')`
215
+
216
+ ```ruby
217
+ # => {
218
+ # clube_id: "20001",
219
+ # clube: {
220
+ # "id_clube" => 20001,
221
+ # "nome" => "Time Exemplo",
222
+ # "sigla" => "TEX",
223
+ # "escudo" => "https://...",
224
+ # ...
225
+ # }
226
+ # }
227
+ ```
228
+
229
+ ### `CbfCalendario.estatisticas_agregadas(jogo)`
230
+
231
+ ```ruby
232
+ # => {
233
+ # por_tipo_evento: { "GOL" => 3, "PENALIDADE" => 5 },
234
+ # gols_por_classificacao_sumula: { "NORMAL" => 2, "CONTRA" => 1 },
235
+ # gols_mandante_em_eventos: 2,
236
+ # gols_visitante_em_eventos: 1,
237
+ # cartoes_por_resultado: { "AMARELO" => 4, "VERMELHO" => 1 },
238
+ # total_substituicoes_mandante: 5,
239
+ # total_substituicoes_visitante: 4
240
+ # }
241
+ ```
242
+
243
+ ### `CbfCalendario::Urls.url_pagina_partida(jogo)`
244
+
245
+ ```ruby
246
+ # => "https://www.cbf.com.br/futebol-brasileiro/jogos/campeonato-brasileiro/serie-a/2026/flamengo-x-bahia/832031"
247
+ ```
248
+
249
+ ### `CbfCalendario::Urls.url_time(...)`
250
+
251
+ ```ruby
252
+ # => "https://www.cbf.com.br/futebol-brasileiro/times/campeonato-brasileiro/serie-a/2026/1"
253
+ ```
254
+
255
+ ## Tratamento de erros
256
+
257
+ - `CbfCalendario::InvalidDateError`: data inválida (formato esperado: `dd/mm/aaaa`).
258
+ - `CbfCalendario::InvalidGameIdError`: `id_jogo` inválido (somente dígitos).
259
+ - `CbfCalendario::InvalidClubIdError`: `id_clube` inválido (somente dígitos).
260
+ - `CbfCalendario::InvalidAthleteIdError`: `id_atleta` inválido (somente dígitos).
261
+ - `CbfCalendario::HttpError`: erro HTTP ou payload inválido da API.
262
+ - `CbfCalendario::Error`: classe base.
263
+
264
+ ## Uso em Rails (recomendado)
265
+
266
+ Para produção, use em background com Active Job (ex.: Solid Queue), evitando bloquear requests web:
267
+
268
+ ```ruby
269
+ class CbfCalendarioSyncJob < ApplicationJob
270
+ queue_as :default
271
+
272
+ def perform(data_iso: nil)
273
+ data = data_iso ? Date.iso8601(data_iso) : Date.current
274
+ CbfCalendario.jogos_pendentes_no_dia(data)
275
+ end
276
+ end
277
+ ```
278
+
279
+ ## Requisitos
280
+
281
+ - Ruby >= 3.0
282
+
283
+ ## Licença
284
+
285
+ MIT. Veja `LICENSE.txt`.
286
+ # cbf_calendario
287
+
288
+ Cliente Ruby para a API pública da [CBF](https://www.cbf.com.br): **calendário** (jogos pendentes por dia), **partida** (`/api/cbf/jogos/:id`), **estatísticas derivadas** da súmula e **montagem de URLs** do site. Retornos em **hashes** Ruby para uso em **Rails** ou scripts.
289
+
290
+ ## Instalação
291
+
292
+ ```ruby
293
+ # Gemfile
294
+ gem 'cbf_calendario', '~> 0.2'
295
+ ```
296
+
297
+ ```bash
298
+ bundle install
299
+ ```
300
+
301
+ Em qualquer script Ruby (sem Bundler no load path):
302
+
303
+ ```bash
304
+ gem install cbf_calendario
305
+ ```
306
+
307
+ ```ruby
308
+ require 'cbf_calendario'
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Uso direto (mesmo processo da requisição)
314
+
315
+ Ideal só para testes ou endpoints muito leves. Para produção, prefira **enfileirar um job** (seção seguinte).
316
+
317
+ ```ruby
318
+ require 'cbf_calendario'
319
+
320
+ # Data como String dd/mm/aaaa, Date ou Time
321
+ jogos = CbfCalendario.jogos_pendentes_no_dia('10/05/2026')
322
+ # => [{ campeonato: "...", serie: "...", mandante: "...", ... }, ...]
323
+
324
+ jogos = CbfCalendario.jogos_pendentes_no_dia(Date.current)
325
+
326
+ # Cliente com opções (timeout HTTP, etc.)
327
+ client = CbfCalendario::Client.new(read_timeout: 45)
328
+ client.jogos_pendentes_no_dia('01/06/2026')
329
+
330
+ # Payload bruto da API (Hash) para o dia
331
+ client.calendario_json(Date.today)
332
+
333
+ # Parsing de data brasileira
334
+ CbfCalendario.parse_data_br!('25/12/2026') # => Date
335
+ ```
336
+
337
+ ### Partida (resultado / súmula completa da API)
338
+
339
+ Mesmo endpoint usado em `show_game.rb`: **`GET /api/cbf/jogos/:id`**. O retorno é o **JSON completo** como `Hash` em Ruby (chaves **string**, igual ao JSON da CBF).
340
+
341
+ ```ruby
342
+ # Payload inteiro: tipicamente { "jogo" => { ... registros, atletas, árbitros, ... } }
343
+ payload = CbfCalendario.partida_completa('832031')
344
+
345
+ jogo = payload['jogo']
346
+ puts jogo.dig('mandante', 'nome')
347
+ puts jogo.dig('mandante', 'gols')
348
+ puts jogo.dig('visitante', 'gols')
349
+
350
+ # Só o objeto jogo (atalho)
351
+ jogo = CbfCalendario.jogo_partida(832031)
352
+
353
+ # Estatísticas derivadas dos registros (gols por tipo, cartões, substituições…)
354
+ stats = CbfCalendario.estatisticas_agregadas(jogo)
355
+ # => { por_tipo_evento: {...}, gols_mandante_em_eventos: N, ... }
356
+
357
+ # URLs públicas (mandante/visitante/página da partida), mesma regra do show_game
358
+ cp = jogo['campeonato']
359
+ ano = (cp['ano'] || jogo['ano']).to_s
360
+ pagina = CbfCalendario::Urls.url_pagina_partida(jogo)
361
+ mandante_url = CbfCalendario::Urls.url_time(cp['nome'], cp['nome_categoria'], ano, jogo.dig('mandante', 'id'))
362
+ ```
363
+
364
+ Timeouts maiores (como no script original):
365
+
366
+ ```ruby
367
+ CbfCalendario.partida_completa('832031', read_timeout: 45, open_timeout: 15)
368
+ ```
369
+
370
+ Erros extras: `CbfCalendario::InvalidGameIdError` (ID inválido), `CbfCalendario::HttpError` (HTTP ou resposta sem `jogo`).
371
+
372
+ ---
373
+
374
+ ## Referência das funções públicas
375
+
376
+ ### Módulo `CbfCalendario` (atalhos)
377
+
378
+ | Função | Descrição |
379
+ |--------|-----------|
380
+ | `parse_data_br!(str)` | Converte `"dd/mm/aaaa"` em `Date`. Lança `InvalidDateError`. |
381
+ | `jogos_pendentes_no_dia(data, **opts)` | Jogos do dia **ainda sem placar** na API. `data`: `String` BR, `Date` ou `Time`. Retorna `Array<Hash>` com **chaves símbolo**. `**opts` repassa para `Client.new`. |
382
+ | `partida_completa(id_jogo, **opts)` | `GET /api/cbf/jogos/:id` — payload JSON completo. Retorna `Hash` com **chaves string** (como o JSON). |
383
+ | `jogo_partida(id_jogo, **opts)` | Mesmo endpoint; retorna só `payload["jogo"]` (`Hash` ou levanta erro se ausente). |
384
+ | `estatisticas_agregadas(jogo)` | Agrega eventos da súmula (`registros`, substituições, etc.). Espera o **Hash `jogo`** vindo da API. Retorna `Hash` com **chaves símbolo**. |
385
+
386
+ ### Classe `CbfCalendario::Client`
387
+
388
+ Construtor: `Client.new(base_url: "https://www.cbf.com.br", read_timeout: 30, open_timeout: 15)`.
389
+
390
+ | Método | Descrição |
391
+ |--------|-----------|
392
+ | `jogos_pendentes_no_dia(data)` | Igual ao atalho do módulo. |
393
+ | `calendario_json(data)` | Payload bruto do calendário: `GET /api/cbf/calendario/jogos/AAAA/MM/DD` (`Hash`, chaves string). |
394
+ | `partida_completa(id_jogo)` | Payload bruto da partida (`GET /api/cbf/jogos/:id`). |
395
+ | `jogo_partida(id_jogo)` | Somente o objeto `jogo`. |
396
+ | `Client.parse_data_br!(str)` | Igual `CbfCalendario.parse_data_br!`. |
397
+ | `Client.coerce_date!(data)` | Normaliza `Date` / `Time` / string `dd/mm/aaaa`. |
398
+ | `Client.normalize_id_jogo!(id)` | Valida ID numérico (`String` de dígitos) ou levanta `InvalidGameIdError`. |
399
+
400
+ ### `CbfCalendario::PartidaStats`
401
+
402
+ | Método | Descrição |
403
+ |--------|-----------|
404
+ | `agregadas(jogo)` | Implementação central das agregações; mesmo resultado que `CbfCalendario.estatisticas_agregadas(jogo)`. |
405
+
406
+ Chaves típicas no **retorno** de `estatisticas_agregadas` / `PartidaStats.agregadas`:
407
+
408
+ | Chave (símbolo) | Conteúdo |
409
+ |-----------------|----------|
410
+ | `:por_tipo_evento` | Contagem por `tipo` em `registros` (ex.: `GOL`, `PENALIDADE`). |
411
+ | `:gols_por_classificacao_sumula` | Contagem por campo `resultado` nos eventos de gol. |
412
+ | `:gols_mandante_em_eventos` | Gols do mandante contados nos registros `GOL`. |
413
+ | `:gols_visitante_em_eventos` | Idem visitante. |
414
+ | `:cartoes_por_resultado` | Contagem por `resultado` em eventos `PENALIDADE`. |
415
+ | `:total_substituicoes_mandante` | Quantidade de substituições no mandante. |
416
+ | `:total_substituicoes_visitante` | Idem visitante. |
417
+
418
+ ### `CbfCalendario::Urls` (links do site, sem HTTP)
419
+
420
+ Constante: `CbfCalendario::Urls::SITE_ROOT` (`"https://www.cbf.com.br"`).
421
+
422
+ | Método | Descrição |
423
+ |--------|-----------|
424
+ | `slug_segment(str)` | Slug à URL (remove acentos, minúsculas, hífens). |
425
+ | `segmento_campeonato(nome)` | Segmento do campeonato na URL (ex.: `campeonato-brasileiro`). |
426
+ | `segmento_categoria(nome_serie)` | Segmento da categoria/série (`serie-a`, `sub-20`, etc.). |
427
+ | `path_pagina_jogo(jogo)` | Path relativo da página da partida no site. |
428
+ | `url_pagina_partida(jogo, base: SITE_ROOT)` | URL absoluta da partida. |
429
+ | `url_time(campeonato_nome, categoria_nome, ano, clube_id, base: SITE_ROOT)` | URL da página do clube na temporada. |
430
+
431
+ O objeto `jogo`/`payload` usado em `Urls` deve ser o **Hash da API** (como em `jogo_partida`), com `campeonato`, `mandante`, `visitante`, `id_jogo`, etc.
432
+
433
+ ---
434
+
435
+ ## Rails: Solid Queue e jobs em background (recomendado)
436
+
437
+ A API da CBF é chamada via rede; **não bloqueie** requisições HTTP longas no processo web (ex.: Puma). Use **[Solid Queue](https://github.com/rails/solid_queue)** (Rails 8+):
438
+
439
+ 1. Inclua `cbf_calendario` no `Gemfile`.
440
+ 2. Configure o adapter **`solid_queue`** para o Active Job.
441
+ 3. Implemente um **Active Job** que chama `CbfCalendario`.
442
+ 4. Rode o worker **`bin/jobs`** (processo separado do servidor web).
443
+
444
+ ### 1. Adapter Solid Queue
445
+
446
+ No `Gemfile`, garanta a gem (no Rails 8 costuma vir por padrão; confira o guia do seu app):
447
+
448
+ ```ruby
449
+ gem 'cbf_calendario'
450
+ gem 'solid_queue'
451
+ ```
452
+
453
+ `config/application.rb` (ou ambiente de produção):
454
+
455
+ ```ruby
456
+ config.active_job.queue_adapter = :solid_queue
457
+ ```
458
+
459
+ Em desenvolvimento e produção, mantenha `config.active_job.queue_adapter = :solid_queue` e rode `bin/jobs` quando precisar processar filas fora do ciclo de request.
460
+
461
+ ### 2. Job com Active Job
462
+
463
+ `app/jobs/cbf_calendario_sync_job.rb`:
464
+
465
+ ```ruby
466
+ # frozen_string_literal: true
467
+
468
+ class CbfCalendarioSyncJob < ApplicationJob
469
+ queue_as :default
470
+
471
+ # data_iso: "2026-05-10" ou nil para usar Date.current
472
+ def perform(data_iso: nil)
473
+ data =
474
+ if data_iso.present?
475
+ Date.iso8601(data_iso.to_s)
476
+ else
477
+ Date.current
478
+ end
479
+
480
+ jogos = CbfCalendario.jogos_pendentes_no_dia(data)
481
+
482
+ # Exemplo: persistir, notificar, cachear — adapte ao seu domínio
483
+ Rails.logger.info("[CBF] #{jogos.size} jogos pendentes em #{data}")
484
+
485
+ jogos.each do |jogo|
486
+ ExternalFixtureUpsertService.call(jogo) # seu serviço
487
+ end
488
+
489
+ jogos
490
+ rescue CbfCalendario::HttpError => e
491
+ Rails.logger.error("[CBF] HTTP: #{e.message}")
492
+ raise # deixa o Active Job aplicar retry conforme configuração
493
+ rescue CbfCalendario::InvalidDateError => e
494
+ Rails.logger.warn("[CBF] data inválida: #{e.message}")
495
+ raise
496
+ end
497
+ end
498
+ ```
499
+
500
+ Enfileirar (controller, outro job, scheduler):
501
+
502
+ ```ruby
503
+ # data específica
504
+ CbfCalendarioSyncJob.perform_later(data_iso: '2026-05-10')
505
+
506
+ # “hoje” no servidor
507
+ CbfCalendarioSyncJob.perform_later
508
+
509
+ # com delay
510
+ CbfCalendarioSyncJob.set(wait: 5.minutes).perform_later(data_iso: Date.tomorrow.iso8601)
511
+ ```
512
+
513
+ ### 3. Processar a fila (`bin/jobs`)
514
+
515
+ Com Solid Queue, o worker padrão processa os jobs enfileirados:
516
+
517
+ ```bash
518
+ bin/jobs
519
+ ```
520
+
521
+ Rode em **processo separado** do `rails server` / Puma. Em produção, trate `bin/jobs` como serviço (systemd, container, Plataforma como serviço, etc.).
522
+
523
+ ### 4. Agendamento periódico (Solid Queue)
524
+
525
+ Use o **recurring** do Solid Queue (ex.: `config/recurring.yml` no seu app) para enfileirar `CbfCalendarioSyncJob.perform_later` no horário desejado. A sintaxe e os arquivos exatos dependem da versão do Solid Queue — veja a [documentação do Solid Queue](https://github.com/rails/solid_queue) (seção *Recurring / cron*).
526
+
527
+ ### 5. Serviço systemd (exemplo): `bin/jobs` no ar
528
+
529
+ Ajuste usuário, path e caminho do Ruby:
530
+
531
+ ```ini
532
+ [Unit]
533
+ Description=Solid Queue — processador de jobs (Rails + cbf_calendario)
534
+ After=network.target
535
+
536
+ [Service]
537
+ Type=simple
538
+ User=deploy
539
+ WorkingDirectory=/var/www/seu_app/current
540
+ Environment=RAILS_ENV=production
541
+ ExecStart=/home/deploy/.rbenv/shims/bundle exec bin/jobs
542
+ Restart=always
543
+ RestartSec=10
544
+
545
+ [Install]
546
+ WantedBy=multi-user.target
547
+ ```
548
+
549
+ ```bash
550
+ sudo systemctl daemon-reload
551
+ sudo systemctl enable --now solid-queue-jobs.service
552
+ ```
553
+
554
+ (O nome do arquivo `.service` pode ser o que preferir; o importante é executar `bundle exec bin/jobs`.)
555
+
556
+ ---
557
+
558
+ ## API dos hashes retornados (calendário / pendentes)
559
+
560
+ A tabela abaixo vale para **cada item** de `jogos_pendentes_no_dia` (chaves **símbolo**). O objeto **`jogo`** de `partida_completa` / `jogo_partida` segue o JSON da CBF (chaves **string**: `"mandante"`, `"registros"`, `"arbitros"`, etc.) — veja a referência completa acima.
561
+
562
+ | Chave | Significado |
563
+ |-------|-------------|
564
+ | `:campeonato` | Nome do campeonato |
565
+ | `:serie` | Série / divisão |
566
+ | `:mandante` | Time mandante |
567
+ | `:visitante` | Time visitante |
568
+ | `:placar_ou_horario` | Horário previsto ou placar, se houver |
569
+ | `:data` | Data como string na API |
570
+ | `:data_iso` | Data no formato `YYYY-MM-DD` |
571
+ | `:local` | Local do jogo |
572
+ | `:rodada` | Rodada |
573
+ | `:id_jogo` | Identificador do jogo na CBF |
574
+
575
+ ### Erros
576
+
577
+ | Classe | Quando |
578
+ |--------|--------|
579
+ | `CbfCalendario::InvalidDateError` | Data em formato inválido |
580
+ | `CbfCalendario::InvalidGameIdError` | ID de jogo não numérico |
581
+ | `CbfCalendario::HttpError` | Falha HTTP ao chamar a API |
582
+ | `CbfCalendario::Error` | Classe base |
583
+
584
+ Em jobs, costuma-se **relançar** `HttpError` para retry exponencial; datas inválidas podem ser descartadas sem retry.
585
+
586
+ ### Rails: responder JSON a partir dos hashes
587
+
588
+ ```ruby
589
+ def index
590
+ jogos = CbfCalendario.jogos_pendentes_no_dia(params.require(:data))
591
+ render json: jogos.map(&:stringify_keys)
592
+ end
593
+ ```
594
+
595
+ Para não bloquear o request em produção, prefira só enfileirar o job e devolver `202 Accepted`, ou ler resultado de cache/banco preenchido pelo job.
596
+
597
+ ---
598
+
599
+ ## Requisitos
600
+
601
+ - Ruby >= 3.0
602
+
603
+ ## Licença
604
+
605
+ MIT — veja [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/cbf_calendario/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'cbf_calendario'
7
+ spec.version = CbfCalendario::VERSION
8
+ spec.authors = ['Betbrothers']
9
+ spec.summary = 'Cliente Ruby para o calendário de jogos da CBF (hashes, uso em Rails)'
10
+ spec.description = <<~DESC
11
+ Consulta a API pública de calendário da CBF e devolve jogos pendentes (sem placar)
12
+ para uma data, como Array de hashes Ruby — adequado para uso em Ruby on Rails.
13
+ DESC
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.0'
16
+
17
+ spec.homepage = 'https://github.com/betbrothers/cbf_calendario'
18
+ spec.metadata['rubygems_mfa_required'] = 'true'
19
+ spec.metadata['source_code_uri'] = spec.homepage
20
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata['documentation_uri'] = 'https://rubydoc.info/gems/cbf_calendario'
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ %w[LICENSE.txt README.md CHANGELOG.md cbf_calendario.gemspec] + Dir['lib/**/*.rb']
25
+ end
26
+ spec.require_paths = ['lib']
27
+ end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'openssl'
7
+ require 'open-uri'
8
+ require 'set'
9
+ require 'uri'
10
+
11
+ module CbfCalendario
12
+ class Error < StandardError; end
13
+ class HttpError < Error; end
14
+ class InvalidDateError < Error; end
15
+ class InvalidGameIdError < Error; end
16
+ class InvalidClubIdError < Error; end
17
+ class InvalidAthleteIdError < Error; end
18
+
19
+ class Client
20
+ DEFAULT_BASE = 'https://www.cbf.com.br'
21
+
22
+ attr_reader :base_url, :read_timeout, :open_timeout
23
+
24
+ def initialize(base_url: DEFAULT_BASE, read_timeout: 30, open_timeout: 15)
25
+ @base_url = base_url.to_s.chomp('/')
26
+ @read_timeout = read_timeout
27
+ @open_timeout = open_timeout
28
+ end
29
+
30
+ # GET /api/cbf/jogos/:id — resposta completa da API (mesmo conteúdo que +show_game.rb+ grava em JSON).
31
+ # Chaves são +String+ como no JSON original.
32
+ def partida_completa(id_jogo)
33
+ jid = Client.normalize_id_jogo!(id_jogo)
34
+ payload = get_json(api_path_jogo(jid))
35
+ raise HttpError, 'Resposta sem objeto "jogo"' unless payload['jogo'].is_a?(Hash)
36
+
37
+ payload
38
+ end
39
+
40
+ # Apenas +payload['jogo']+ (Hash com string keys).
41
+ def jogo_partida(id_jogo)
42
+ partida_completa(id_jogo)['jogo']
43
+ end
44
+
45
+ # GET /api/cbf/atletas/:id_clube — retorna hash com dados dos atletas.
46
+ # Saída: { clube_id: "123", atletas: [ ... ] }
47
+ def atletas_do_clube(id_clube)
48
+ cid = Client.normalize_id_clube!(id_clube)
49
+ payload = get_json(api_path_atletas_clube(cid))
50
+ atletas =
51
+ if payload.is_a?(Array)
52
+ payload
53
+ elsif payload.is_a?(Hash) && payload['atletas'].is_a?(Array)
54
+ payload['atletas']
55
+ else
56
+ raise HttpError, 'Resposta sem lista de atletas'
57
+ end
58
+
59
+ { clube_id: cid, atletas: atletas }
60
+ end
61
+
62
+ # Busca dados completos de um clube por ID.
63
+ # Saída: { clube_id: "123", clube: { ... } }
64
+ def clube_por_id(id_clube)
65
+ cid = Client.normalize_id_clube!(id_clube)
66
+ payload = buscar_clube_payload(cid)
67
+ clube = extrair_clube_do_payload(payload, cid)
68
+ raise HttpError, 'Resposta sem dados do clube' unless clube.is_a?(Hash) && !clube.empty?
69
+
70
+ { clube_id: cid, clube: clube }
71
+ end
72
+
73
+ # Busca dados completos de um atleta por ID.
74
+ # Saída: { atleta_id: "123", atleta: { ... } }
75
+ def atleta_por_id(id_atleta)
76
+ aid = Client.normalize_id_atleta!(id_atleta)
77
+ payload = buscar_atleta_payload(aid)
78
+ atleta = extrair_atleta_do_payload(payload, aid)
79
+ raise HttpError, 'Resposta sem dados do atleta' unless atleta.is_a?(Hash) && !atleta.empty?
80
+
81
+ { atleta_id: aid, atleta: atleta }
82
+ end
83
+
84
+ def self.normalize_id_jogo!(id_jogo)
85
+ s = id_jogo.to_s.strip
86
+ raise InvalidGameIdError, 'id_jogo deve conter só dígitos (ex.: 832024)' unless s.match?(/\A\d+\z/)
87
+
88
+ s
89
+ end
90
+
91
+ def self.normalize_id_clube!(id_clube)
92
+ s = id_clube.to_s.strip
93
+ raise InvalidClubIdError, 'id_clube deve conter só dígitos (ex.: 20001)' unless s.match?(/\A\d+\z/)
94
+
95
+ s
96
+ end
97
+
98
+ def self.normalize_id_atleta!(id_atleta)
99
+ s = id_atleta.to_s.strip
100
+ raise InvalidAthleteIdError, 'id_atleta deve conter só dígitos (ex.: 12345)' unless s.match?(/\A\d+\z/)
101
+
102
+ s
103
+ end
104
+
105
+ # Aceita Date, Time ou String "dd/mm/aaaa".
106
+ # Retorna Array<Hash> com símbolos como chaves, ordenado e sem IDs duplicados.
107
+ # Cada hash: +campeonato+, +serie+, +mandante+, +visitante+, +placar_ou_horario+,
108
+ # +data+, +data_iso+, +local+, +rodada+, +id_jogo+.
109
+ def jogos_pendentes_no_dia(data)
110
+ date = Client.coerce_date!(data)
111
+ payload = get_json(api_path_calendario(date))
112
+ jogos = extrair_jogos_pendentes(payload, date)
113
+ dedup_and_sort(jogos)
114
+ end
115
+
116
+ # Payload bruto da API para o dia (Hash).
117
+ def calendario_json(data)
118
+ date = Client.coerce_date!(data)
119
+ get_json(api_path_calendario(date))
120
+ end
121
+
122
+ def self.coerce_date!(data)
123
+ case data
124
+ when Date then data
125
+ when Time then data.to_date
126
+ else
127
+ parse_data_br!(data)
128
+ end
129
+ end
130
+
131
+ def self.parse_data_br!(str)
132
+ m = str.to_s.strip.match(/\A(\d{2})\/(\d{2})\/(\d{4})\z/)
133
+ raise InvalidDateError, 'Use dd/mm/aaaa (ex.: 15/05/2026)' unless m
134
+
135
+ day = m[1].to_i
136
+ month = m[2].to_i
137
+ year = m[3].to_i
138
+ Date.new(year, month, day)
139
+ rescue ArgumentError
140
+ raise InvalidDateError, 'Data inválida'
141
+ end
142
+
143
+ private
144
+
145
+ def api_path_calendario(data)
146
+ format('/api/cbf/calendario/jogos/%04d/%02d/%02d', data.year, data.month, data.day)
147
+ end
148
+
149
+ def api_path_jogo(id_jogo)
150
+ "/api/cbf/jogos/#{id_jogo}"
151
+ end
152
+
153
+ def api_path_atletas_clube(id_clube)
154
+ "/api/cbf/atletas/#{id_clube}"
155
+ end
156
+
157
+ def api_path_atleta(id_atleta)
158
+ "/api/cbf/atleta/#{id_atleta}"
159
+ end
160
+
161
+ def api_path_atletas_detalhes(id_atleta)
162
+ "/api/cbf/atletas/detalhes/#{id_atleta}"
163
+ end
164
+
165
+ def api_path_clube(id_clube)
166
+ "/api/cbf/clubes/#{id_clube}"
167
+ end
168
+
169
+ def api_path_time(id_clube)
170
+ "/api/cbf/times/#{id_clube}"
171
+ end
172
+
173
+ def api_path_clube_detalhes(id_clube)
174
+ "/api/cbf/clubes/detalhes/#{id_clube}"
175
+ end
176
+
177
+ def get_json(path)
178
+ uri = URI.join(base_url, path)
179
+ request_json(uri)
180
+ end
181
+
182
+ def request_json(uri)
183
+ Net::HTTP.start(
184
+ uri.host,
185
+ uri.port,
186
+ read_timeout: read_timeout,
187
+ open_timeout: open_timeout,
188
+ use_ssl: true,
189
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
190
+ cert_store: ssl_cert_store
191
+ ) do |http|
192
+ req = Net::HTTP::Get.new(uri)
193
+ req['Accept'] = 'application/json'
194
+ res = http.request(req)
195
+ unless res.is_a?(Net::HTTPSuccess)
196
+ raise HttpError, "HTTP #{res.code}: #{res.message}"
197
+ end
198
+
199
+ JSON.parse(res.body)
200
+ end
201
+ end
202
+
203
+ def ssl_cert_store
204
+ @ssl_cert_store ||= OpenSSL::X509::Store.new.tap do |store|
205
+ root_pkcs7 = URI('http://crt.sectigo.com/SectigoPublicServerAuthenticationRootR46.p7c').open.read
206
+ OpenSSL::PKCS7.new(root_pkcs7).certificates.uniq { |c| c.to_der }.each { |c| store.add_cert(c) }
207
+
208
+ intermediate_der = URI('http://crt.sectigo.com/SectigoPublicServerAuthenticationCAOVR36.crt').open.read
209
+ store.add_cert(OpenSSL::X509::Certificate.new(intermediate_der))
210
+ end
211
+ end
212
+
213
+ def placar_ou_horario(jogo)
214
+ gm = jogo.dig('mandante', 'gols')
215
+ gv = jogo.dig('visitante', 'gols')
216
+
217
+ return "#{gm} x #{gv}" unless gm.nil? || gv.nil?
218
+
219
+ jogo['hora'].to_s.strip
220
+ end
221
+
222
+ def jogo_sem_placar?(jogo)
223
+ gm = jogo.dig('mandante', 'gols')
224
+ gv = jogo.dig('visitante', 'gols')
225
+ gm.nil? || gv.nil?
226
+ end
227
+
228
+ def extrair_jogos_pendentes(payload, data_calendario)
229
+ raiz = payload['jogos'] || {}
230
+ linhas = []
231
+
232
+ raiz.each do |campeonato, por_serie|
233
+ next unless por_serie.is_a?(Hash)
234
+
235
+ por_serie.each do |serie, lista|
236
+ next unless lista.is_a?(Array)
237
+
238
+ lista.each do |jogo|
239
+ next unless jogo_sem_placar?(jogo)
240
+
241
+ linhas << {
242
+ campeonato: campeonato.to_s.strip,
243
+ serie: serie.to_s.strip,
244
+ mandante: jogo.dig('mandante', 'nome').to_s.strip,
245
+ visitante: jogo.dig('visitante', 'nome').to_s.strip,
246
+ placar_ou_horario: placar_ou_horario(jogo),
247
+ data: jogo['data'].to_s.strip,
248
+ data_iso: data_calendario.strftime('%Y-%m-%d'),
249
+ local: jogo['local'].to_s.strip,
250
+ rodada: jogo['rodada'].to_s.strip,
251
+ id_jogo: jogo['id_jogo'].to_s.strip
252
+ }
253
+ end
254
+ end
255
+ end
256
+
257
+ linhas
258
+ end
259
+
260
+ def dedup_and_sort(linhas)
261
+ seen = Set.new
262
+ out = []
263
+ linhas.each do |row|
264
+ id = row[:id_jogo]
265
+ next if id.empty? || seen.include?(id)
266
+
267
+ seen.add(id)
268
+ out << row
269
+ end
270
+ out.sort_by { |j| [j[:campeonato], j[:serie], j[:rodada].to_i, j[:id_jogo]] }
271
+ end
272
+
273
+ def buscar_atleta_payload(id_atleta)
274
+ paths = [
275
+ api_path_atleta(id_atleta),
276
+ api_path_atletas_detalhes(id_atleta),
277
+ api_path_atletas_clube(id_atleta)
278
+ ]
279
+
280
+ errors = []
281
+ paths.each do |path|
282
+ begin
283
+ return get_json(path)
284
+ rescue HttpError => e
285
+ errors << "#{path}: #{e.message}"
286
+ end
287
+ end
288
+
289
+ raise HttpError, "Não foi possível buscar o atleta #{id_atleta}. Tentativas: #{errors.join(' | ')}"
290
+ end
291
+
292
+ def extrair_atleta_do_payload(payload, id_atleta)
293
+ if payload.is_a?(Hash)
294
+ return payload if campo_id_atleta(payload) == id_atleta
295
+ return payload['atleta'] if payload['atleta'].is_a?(Hash)
296
+ return payload
297
+ end
298
+
299
+ return {} unless payload.is_a?(Array)
300
+
301
+ payload.find { |item| item.is_a?(Hash) && campo_id_atleta(item) == id_atleta } ||
302
+ payload.find { |item| item.is_a?(Hash) } || {}
303
+ end
304
+
305
+ def campo_id_atleta(hash)
306
+ hash['id_atleta'].to_s.strip.empty? ? hash['id'].to_s.strip : hash['id_atleta'].to_s.strip
307
+ end
308
+
309
+ def buscar_clube_payload(id_clube)
310
+ paths = [
311
+ api_path_clube(id_clube),
312
+ api_path_time(id_clube),
313
+ api_path_clube_detalhes(id_clube)
314
+ ]
315
+
316
+ errors = []
317
+ paths.each do |path|
318
+ begin
319
+ return get_json(path)
320
+ rescue HttpError => e
321
+ errors << "#{path}: #{e.message}"
322
+ end
323
+ end
324
+
325
+ raise HttpError, "Não foi possível buscar o clube #{id_clube}. Tentativas: #{errors.join(' | ')}"
326
+ end
327
+
328
+ def extrair_clube_do_payload(payload, id_clube)
329
+ if payload.is_a?(Hash)
330
+ return payload if campo_id_clube(payload) == id_clube
331
+ return payload['clube'] if payload['clube'].is_a?(Hash)
332
+ return payload['time'] if payload['time'].is_a?(Hash)
333
+ return payload
334
+ end
335
+
336
+ return {} unless payload.is_a?(Array)
337
+
338
+ payload.find { |item| item.is_a?(Hash) && campo_id_clube(item) == id_clube } ||
339
+ payload.find { |item| item.is_a?(Hash) } || {}
340
+ end
341
+
342
+ def campo_id_clube(hash)
343
+ return hash['id_clube'].to_s.strip unless hash['id_clube'].to_s.strip.empty?
344
+ return hash['id_time'].to_s.strip unless hash['id_time'].to_s.strip.empty?
345
+
346
+ hash['id'].to_s.strip
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CbfCalendario
4
+ # Estatísticas derivadas dos registros da súmula (mesma lógica de +show_game.rb+).
5
+ module PartidaStats
6
+ module_function
7
+
8
+ # @param jogo [Hash] objeto +jogo+ retornado pela API (+payload['jogo']+)
9
+ # @return [Hash] chaves em símbolo
10
+ def agregadas(jogo)
11
+ regs = jogo['registros']
12
+ regs = [] unless regs.is_a?(Array)
13
+
14
+ por_tipo = regs.each_with_object(Hash.new(0)) { |r, h| h[r['tipo']] += 1 }
15
+
16
+ mid = jogo.dig('mandante', 'id').to_s
17
+ vid = jogo.dig('visitante', 'id').to_s
18
+
19
+ gols = regs.select { |r| r['tipo'] == 'GOL' }
20
+ gols_tipo = gols.each_with_object(Hash.new(0)) { |r, h| h[r['resultado'].to_s] += 1 }
21
+
22
+ gols_m = gols.count { |r| r['clube_id'].to_s == mid }
23
+ gols_v = gols.count { |r| r['clube_id'].to_s == vid }
24
+
25
+ pens = regs.select { |r| r['tipo'] == 'PENALIDADE' }
26
+ cartoes = pens.each_with_object(Hash.new(0)) { |r, h| h[r['resultado'].to_s] += 1 }
27
+
28
+ {
29
+ por_tipo_evento: por_tipo,
30
+ gols_por_classificacao_sumula: gols_tipo,
31
+ gols_mandante_em_eventos: gols_m,
32
+ gols_visitante_em_eventos: gols_v,
33
+ cartoes_por_resultado: cartoes,
34
+ total_substituicoes_mandante: (jogo.dig('mandante', 'alteracoes') || []).size,
35
+ total_substituicoes_visitante: (jogo.dig('visitante', 'alteracoes') || []).size
36
+ }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CbfCalendario
4
+ # Montagem de paths e URLs públicas da CBF (mesma lógica de +show_game.rb+).
5
+ module Urls
6
+ SITE_ROOT = 'https://www.cbf.com.br'
7
+
8
+ module_function
9
+
10
+ def slug_segment(str)
11
+ t = str.to_s.unicode_normalize(:nfd).gsub(/\p{M}/u, '').downcase
12
+ t.gsub(/[^a-z0-9]+/, '-').gsub(/-+/, '-').delete_prefix('-').delete_suffix('-')
13
+ end
14
+
15
+ def segmento_campeonato(nome)
16
+ n = nome.to_s.strip
17
+ return 'campeonato-brasileiro' if n.match?(/\ACampeonato Brasileiro\z/i)
18
+ return 'copa-do-brasil' if n.match?(/\ACopa do Brasil\z/i)
19
+ return 'brasileiro-feminino' if n.match?(/\ABrasileiro Feminino\z/i)
20
+
21
+ slug_segment(n)
22
+ end
23
+
24
+ def segmento_categoria(nome_serie)
25
+ s = nome_serie.to_s.strip
26
+
27
+ serie = s.match(/\ASérie\s+([ABCD])\z/i)
28
+ return "serie-#{serie[1].downcase}" if serie
29
+
30
+ sub = s.match(/\ASub\s*-\s*(\d+)\z/i)
31
+ return "sub-#{sub[1]}" if sub
32
+
33
+ ax = s.match(/\A(A[12])\z/i)
34
+ return ax[1].downcase if ax
35
+
36
+ slug_segment(s)
37
+ end
38
+
39
+ # Path relativo à raiz do site (ex.: +/futebol-brasileiro/jogos/...+).
40
+ def path_pagina_jogo(jogo)
41
+ camp = jogo.dig('campeonato', 'nome')
42
+ serie = jogo.dig('campeonato', 'nome_categoria')
43
+ ano = (jogo.dig('campeonato', 'ano') || jogo['ano']).to_s
44
+ sm = slug_segment(jogo.dig('mandante', 'nome'))
45
+ sv = slug_segment(jogo.dig('visitante', 'nome'))
46
+ id = jogo['id_jogo']
47
+
48
+ "/futebol-brasileiro/jogos/#{segmento_campeonato(camp)}/#{segmento_categoria(serie)}/#{ano}/#{sm}-x-#{sv}/#{id}"
49
+ end
50
+
51
+ def url_time(campeonato_nome, categoria_nome, ano, clube_id, base: SITE_ROOT)
52
+ base = base.to_s.chomp('/')
53
+ "#{base}/futebol-brasileiro/times/#{segmento_campeonato(campeonato_nome)}/#{segmento_categoria(categoria_nome)}/#{ano}/#{clube_id}"
54
+ end
55
+
56
+ def url_pagina_partida(jogo, base: SITE_ROOT)
57
+ "#{base.to_s.chomp('/')}#{path_pagina_jogo(jogo)}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CbfCalendario
4
+ VERSION = '0.3.0'
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cbf_calendario/version'
4
+ require_relative 'cbf_calendario/client'
5
+ require_relative 'cbf_calendario/partida_stats'
6
+ require_relative 'cbf_calendario/urls'
7
+
8
+ module CbfCalendario
9
+ module_function
10
+
11
+ def parse_data_br!(str)
12
+ Client.parse_data_br!(str)
13
+ end
14
+
15
+ # Atalho sem instanciar +Client+.
16
+ def jogos_pendentes_no_dia(data, **client_options)
17
+ Client.new(**client_options).jogos_pendentes_no_dia(data)
18
+ end
19
+
20
+ # GET /api/cbf/jogos/:id — Hash completo da API (chaves string).
21
+ def partida_completa(id_jogo, **client_options)
22
+ Client.new(**client_options).partida_completa(id_jogo)
23
+ end
24
+
25
+ # Somente o objeto +jogo+ do payload.
26
+ def jogo_partida(id_jogo, **client_options)
27
+ Client.new(**client_options).jogo_partida(id_jogo)
28
+ end
29
+
30
+ # Hash com todos os atletas do clube.
31
+ def atletas_do_clube(id_clube, **client_options)
32
+ Client.new(**client_options).atletas_do_clube(id_clube)
33
+ end
34
+
35
+ # Hash com os dados de um atleta específico.
36
+ def atleta_por_id(id_atleta, **client_options)
37
+ Client.new(**client_options).atleta_por_id(id_atleta)
38
+ end
39
+
40
+ # Hash com os dados de um clube específico.
41
+ def clube_por_id(id_clube, **client_options)
42
+ Client.new(**client_options).clube_por_id(id_clube)
43
+ end
44
+
45
+ # Estatísticas derivadas dos registros (+show_game.rb+).
46
+ def estatisticas_agregadas(jogo)
47
+ PartidaStats.agregadas(jogo)
48
+ end
49
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cbf_calendario
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Betbrothers
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Consulta a API pública de calendário da CBF e devolve jogos pendentes (sem placar)
14
+ para uma data, como Array de hashes Ruby — adequado para uso em Ruby on Rails.
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - cbf_calendario.gemspec
23
+ - lib/cbf_calendario.rb
24
+ - lib/cbf_calendario/client.rb
25
+ - lib/cbf_calendario/partida_stats.rb
26
+ - lib/cbf_calendario/urls.rb
27
+ - lib/cbf_calendario/version.rb
28
+ homepage: https://github.com/betbrothers/cbf_calendario
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ rubygems_mfa_required: 'true'
33
+ source_code_uri: https://github.com/betbrothers/cbf_calendario
34
+ changelog_uri: https://github.com/betbrothers/cbf_calendario/blob/main/CHANGELOG.md
35
+ documentation_uri: https://rubydoc.info/gems/cbf_calendario
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Cliente Ruby para o calendário de jogos da CBF (hashes, uso em Rails)
53
+ test_files: []