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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +605 -0
- data/cbf_calendario.gemspec +27 -0
- data/lib/cbf_calendario/client.rb +349 -0
- data/lib/cbf_calendario/partida_stats.rb +39 -0
- data/lib/cbf_calendario/urls.rb +60 -0
- data/lib/cbf_calendario/version.rb +5 -0
- data/lib/cbf_calendario.rb +49 -0
- metadata +53 -0
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,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: []
|