concurso_hub 0.1.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/README.md +511 -0
- data/lib/concurso_hub/version.rb +5 -0
- data/lib/concurso_hub.rb +143 -0
- data/src/application/baixar_edital_request.rb +5 -0
- data/src/application/baixar_provas_request.rb +5 -0
- data/src/application/filtros_concurso.rb +16 -0
- data/src/application/ports/concurso_repository.rb +27 -0
- data/src/application/ports/file_downloader.rb +11 -0
- data/src/application/ports/presenter.rb +35 -0
- data/src/application/use_cases/baixar_edital.rb +43 -0
- data/src/application/use_cases/baixar_provas.rb +59 -0
- data/src/application/use_cases/listar_concursos.rb +89 -0
- data/src/application/use_cases/listar_provas.rb +32 -0
- data/src/application/use_cases/ver_edital.rb +21 -0
- data/src/application/ver_edital_request.rb +5 -0
- data/src/domain/entities/concurso.rb +24 -0
- data/src/domain/entities/edital.rb +21 -0
- data/src/infrastructure/http/http_client.rb +41 -0
- data/src/infrastructure/http/http_file_downloader.rb +44 -0
- data/src/infrastructure/parsers/pci_html_parser.rb +208 -0
- data/src/infrastructure/repositories/pci_concurso_repository.rb +49 -0
- data/src/presentation/cli/cli_controller.rb +35 -0
- data/src/presentation/cli/cli_options_parser.rb +121 -0
- data/src/presentation/formatters/terminal_presenter.rb +203 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8c7322f931071bf6109d00a6359faab176c639da4073bf3147807c0a04860262
|
|
4
|
+
data.tar.gz: bb8af9915024a3bb6134f401dd1d60e8a5b5256e3abe54888d24e20375001f12
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0294a2295e608584c7c92b425d809b53e61acd1c6a752e98a69d0027590a1198c8183d3e772a0e9ec186b92c752a752a090cd9d3fa89c9a09f5c9f298bfb469b'
|
|
7
|
+
data.tar.gz: 37d8ea5a7e53692a5cedbc905abc0a6157709da4d6d16dac3f55d3f26b9d1648b8fb12918b874ede0f3580c99cf37b5091ba0036afb537c2a9eb6806281f8005
|
data/README.md
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# ConcursoHub
|
|
2
|
+
|
|
3
|
+
Gem Ruby para busca e extração de dados de concursos públicos brasileiros a partir do [PCI Concursos](https://pciconcursos.com.br).
|
|
4
|
+
|
|
5
|
+
Projetada com **arquitetura hexagonal (Ports & Adapters)**, a biblioteca mantém o núcleo de negócio completamente desacoplado de I/O — tornando-a ideal para uso em **APIs backend** (Rails, Sinatra, Grape, etc.) que precisam expor dados de concursos para um frontend consumir.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Sumário
|
|
10
|
+
|
|
11
|
+
- [Instalação](#instalação)
|
|
12
|
+
- [Uso via CLI](#uso-via-cli)
|
|
13
|
+
- [Uso como gem em uma API backend](#uso-como-gem-em-uma-api-backend)
|
|
14
|
+
- [ConcursoHub.search](#concursoscrapersearch)
|
|
15
|
+
- [ConcursoHub.edital](#concursoscraperedital)
|
|
16
|
+
- [ConcursoHub.provas](#concursoscraperprovas)
|
|
17
|
+
- [Exemplo completo em Rails](#exemplo-completo-em-rails)
|
|
18
|
+
- [Uso avançado (injeção de dependências)](#uso-avançado-injeção-de-dependências)
|
|
19
|
+
- [Listar concursos](#listar-concursos)
|
|
20
|
+
- [Ver edital completo](#ver-edital-completo)
|
|
21
|
+
- [Baixar PDFs de um edital](#baixar-pdfs-de-um-edital)
|
|
22
|
+
- [Baixar provas e gabaritos](#baixar-provas-e-gabaritos)
|
|
23
|
+
- [Arquitetura](#arquitetura)
|
|
24
|
+
- [Entidades de Domínio](#entidades-de-domínio)
|
|
25
|
+
- [Filtros disponíveis](#filtros-disponíveis)
|
|
26
|
+
- [Customizando adaptadores](#customizando-adaptadores)
|
|
27
|
+
- [Dependências](#dependências)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Instalação
|
|
32
|
+
|
|
33
|
+
> **Nota:** A gem ainda não foi publicada no RubyGems. Para uso local ou como dependência privada, adicione ao seu `Gemfile`:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
gem 'concurso_hub', path: '/caminho/para/concurso_test'
|
|
37
|
+
# ou via git:
|
|
38
|
+
# gem 'concurso_hub', github: 'seu-usuario/concurso_hub'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Para instalar as dependências do projeto:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Uso via CLI
|
|
50
|
+
|
|
51
|
+
O projeto inclui uma interface de linha de comando para uso rápido e testes.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Listar concursos abertos (padrão)
|
|
55
|
+
ruby main.rb
|
|
56
|
+
|
|
57
|
+
# Filtrar por estado
|
|
58
|
+
ruby main.rb --estado SP
|
|
59
|
+
|
|
60
|
+
# Filtrar por nível de escolaridade
|
|
61
|
+
ruby main.rb --nivel Superior
|
|
62
|
+
|
|
63
|
+
# Busca por texto (instituição ou cargo)
|
|
64
|
+
ruby main.rb --busca "analista"
|
|
65
|
+
|
|
66
|
+
# Limitar número de resultados
|
|
67
|
+
ruby main.rb --limite 10
|
|
68
|
+
|
|
69
|
+
# Filtrar por ano de inscrição
|
|
70
|
+
ruby main.rb --ano 2026
|
|
71
|
+
|
|
72
|
+
# Combinar filtros
|
|
73
|
+
ruby main.rb --estado RJ --nivel Medio --limite 20
|
|
74
|
+
|
|
75
|
+
# Listar concursos encerrados (requer --busca)
|
|
76
|
+
ruby main.rb --encerrados --busca "policia federal"
|
|
77
|
+
|
|
78
|
+
# Ver o edital completo de um concurso
|
|
79
|
+
ruby main.rb --ver https://pciconcursos.com.br/concurso/...
|
|
80
|
+
|
|
81
|
+
# Baixar os PDFs do edital de um concurso
|
|
82
|
+
ruby main.rb --baixar https://pciconcursos.com.br/concurso/...
|
|
83
|
+
|
|
84
|
+
# Baixar provas e gabaritos anteriores
|
|
85
|
+
ruby main.rb --baixar-provas https://pciconcursos.com.br/concurso/...
|
|
86
|
+
|
|
87
|
+
# Especificar pasta de destino para downloads
|
|
88
|
+
ruby main.rb --baixar https://... --dir ~/Downloads/concursos
|
|
89
|
+
|
|
90
|
+
# Ajuda
|
|
91
|
+
ruby main.rb --help
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Uso como gem em uma API backend
|
|
97
|
+
|
|
98
|
+
A maneira mais simples de usar a gem é através do módulo `ConcursoHub`, que expõe uma API de alto nível. Você só precisa adicionar o `require` e chamar os métodos — sem precisar saber nada sobre a arquitetura interna.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
require 'concurso_hub'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### ConcursoHub.search
|
|
107
|
+
|
|
108
|
+
Busca concursos com filtros opcionais. Retorna um hash com `concursos` (array) e `metadata`.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Busca simples — 1 requisição HTTP
|
|
112
|
+
resultado = ConcursoHub.search(estado: 'SP', nivel: 'Superior', limite: 10)
|
|
113
|
+
|
|
114
|
+
resultado[:concursos]
|
|
115
|
+
# => [
|
|
116
|
+
# { instituicao: "TRF-3", estado: "SP", vagas: "50", salario: "R$ 8.529",
|
|
117
|
+
# cargos: "Analista Judiciário", nivel: "Superior",
|
|
118
|
+
# prazo: "10/06/2026", url: "https://pciconcursos.com.br/concurso/..." },
|
|
119
|
+
# ...
|
|
120
|
+
# ]
|
|
121
|
+
|
|
122
|
+
resultado[:metadata]
|
|
123
|
+
# => { total_scraped: 120, modo: :abertos }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Com dados do edital** — inclui PDFs e descrição para cada concurso (`+1 req por concurso`):
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
resultado = ConcursoHub.search(estado: 'RJ', limite: 5, with_edital: true)
|
|
130
|
+
|
|
131
|
+
resultado[:concursos].first[:edital]
|
|
132
|
+
# => {
|
|
133
|
+
# titulo: "Edital nº 1/2026 — TRF-2",
|
|
134
|
+
# descricao: "...",
|
|
135
|
+
# data_publicacao: "15/04/2026",
|
|
136
|
+
# pdfs: [
|
|
137
|
+
# { titulo: "Edital completo", url: "https://..." },
|
|
138
|
+
# { titulo: "Retificação nº 1", url: "https://..." }
|
|
139
|
+
# ],
|
|
140
|
+
# provas_url: "https://pciconcursos.com.br/provas/...", # nil se não houver
|
|
141
|
+
# blocos: [...],
|
|
142
|
+
# url: "https://pciconcursos.com.br/concurso/..."
|
|
143
|
+
# }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Com provas e gabaritos** — inclui tudo acima mais a listagem de provas anteriores (`+N reqs por concurso` — use `limite`):
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
resultado = ConcursoHub.search(estado: 'SP', limite: 2, with_provas: true)
|
|
150
|
+
|
|
151
|
+
resultado[:concursos].first[:provas]
|
|
152
|
+
# => [
|
|
153
|
+
# {
|
|
154
|
+
# cargo: "Analista Judiciário — Área Administrativa",
|
|
155
|
+
# pdfs: [
|
|
156
|
+
# { titulo: "Prova Objetiva — 2023", url: "https://..." },
|
|
157
|
+
# { titulo: "Gabarito Preliminar — 2023", url: "https://..." }
|
|
158
|
+
# ]
|
|
159
|
+
# },
|
|
160
|
+
# ...
|
|
161
|
+
# ]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Parâmetros disponíveis:**
|
|
165
|
+
|
|
166
|
+
| Parâmetro | Tipo | Padrão | Descrição |
|
|
167
|
+
|---------------|-------------------|------------|---------------------------------------------------------|
|
|
168
|
+
| `estado` | `String \| nil` | `nil` | UF (ex: `'SP'`, `'MG'`) |
|
|
169
|
+
| `nivel` | `String \| nil` | `nil` | Escolaridade (ex: `'Superior'`, `'Médio'`) |
|
|
170
|
+
| `busca` | `String \| nil` | `nil` | Texto livre — obrigatório para `modo: :encerrados` |
|
|
171
|
+
| `limite` | `Integer \| nil` | `nil` | Máximo de resultados |
|
|
172
|
+
| `ano` | `String \| nil` | `nil` | Ano do prazo de inscrição (ex: `'2026'`) |
|
|
173
|
+
| `modo` | `Symbol` | `:abertos` | `:abertos` ou `:encerrados` |
|
|
174
|
+
| `with_edital` | `Boolean` | `false` | Inclui edital com PDFs (+1 req/concurso) |
|
|
175
|
+
| `with_provas` | `Boolean` | `false` | Inclui provas/gabaritos — implica `with_edital` |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### ConcursoHub.edital
|
|
180
|
+
|
|
181
|
+
Retorna o edital completo de um concurso a partir da URL.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
edital = ConcursoHub.edital('https://pciconcursos.com.br/concurso/...')
|
|
185
|
+
|
|
186
|
+
edital[:titulo] # => "Edital nº 1/2026"
|
|
187
|
+
edital[:data_publicacao] # => "15/04/2026"
|
|
188
|
+
edital[:descricao] # => "..."
|
|
189
|
+
edital[:pdfs] # => [{ titulo:, url: }, ...]
|
|
190
|
+
edital[:blocos] # => [{ tipo: :secao|:paragrafo|:item, texto: }, ...]
|
|
191
|
+
edital[:url] # => URL original
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### ConcursoHub.provas
|
|
197
|
+
|
|
198
|
+
Retorna a listagem de provas e gabaritos anteriores. Recebe a mesma URL do concurso retornada pelo `search` — não precisa passar nenhuma URL intermediária.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# url vem diretamente de search()
|
|
202
|
+
concurso_url = resultado[:concursos].first[:url]
|
|
203
|
+
|
|
204
|
+
provas = ConcursoHub.provas(concurso_url)
|
|
205
|
+
|
|
206
|
+
provas
|
|
207
|
+
# => [
|
|
208
|
+
# {
|
|
209
|
+
# cargo: "Analista Judiciário — Área Administrativa",
|
|
210
|
+
# pdfs: [
|
|
211
|
+
# { titulo: "Prova Objetiva — 2023", url: "https://..." },
|
|
212
|
+
# { titulo: "Gabarito Preliminar — 2023", url: "https://..." }
|
|
213
|
+
# ]
|
|
214
|
+
# }
|
|
215
|
+
# ]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
### Exemplo completo em Rails
|
|
221
|
+
|
|
222
|
+
**`Gemfile`**
|
|
223
|
+
```ruby
|
|
224
|
+
gem 'concurso_hub', path: '../concurso_test'
|
|
225
|
+
# ou após publicar: gem 'concurso_hub', '~> 1.0'
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**`app/controllers/concursos_controller.rb`**
|
|
229
|
+
```ruby
|
|
230
|
+
require 'concurso_hub'
|
|
231
|
+
|
|
232
|
+
class ConcursosController < ApplicationController
|
|
233
|
+
# GET /concursos?estado=SP&nivel=Superior&limite=10
|
|
234
|
+
def index
|
|
235
|
+
resultado = ConcursoHub.search(
|
|
236
|
+
estado: params[:estado],
|
|
237
|
+
nivel: params[:nivel],
|
|
238
|
+
busca: params[:busca],
|
|
239
|
+
limite: params[:limite]&.to_i,
|
|
240
|
+
ano: params[:ano]
|
|
241
|
+
)
|
|
242
|
+
render json: resultado
|
|
243
|
+
rescue => e
|
|
244
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# GET /concursos/edital?url=https://pciconcursos.com.br/concurso/...
|
|
248
|
+
def edital
|
|
249
|
+
render json: ConcursoHub.edital(params[:url])
|
|
250
|
+
rescue => e
|
|
251
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# GET /concursos/provas?url=https://pciconcursos.com.br/concurso/...
|
|
255
|
+
# (mesma url retornada pelo /concursos)
|
|
256
|
+
def provas
|
|
257
|
+
render json: ConcursoHub.provas(params[:url])
|
|
258
|
+
rescue => e
|
|
259
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**`config/routes.rb`**
|
|
265
|
+
```ruby
|
|
266
|
+
get '/concursos', to: 'concursos#index'
|
|
267
|
+
get '/concursos/edital', to: 'concursos#edital'
|
|
268
|
+
get '/concursos/provas', to: 'concursos#provas' # ?url= é a URL do concurso
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Uso avançado (injeção de dependências)
|
|
274
|
+
|
|
275
|
+
Se precisar de mais controle — mock para testes, presenter customizado, troca de repositório — você pode instanciar os use cases diretamente.
|
|
276
|
+
|
|
277
|
+
### Listar concursos
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
require 'concurso_hub' # ou os requires individuais de src/
|
|
281
|
+
|
|
282
|
+
# Presenter que coleta os dados em vez de imprimir
|
|
283
|
+
class MeuPresenter < Application::Ports::Presenter
|
|
284
|
+
attr_reader :concursos, :metadata
|
|
285
|
+
|
|
286
|
+
def show(concursos, metadata: {}) = (@concursos = concursos) && (@metadata = metadata)
|
|
287
|
+
def error(msg) = raise msg
|
|
288
|
+
def show_loading = nil
|
|
289
|
+
def show_edital(_) = nil
|
|
290
|
+
def show_provas(_) = nil
|
|
291
|
+
def show_download_start(*) = nil
|
|
292
|
+
def show_download_done(_) = nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
repository = Infrastructure::Repositories::PciConcursoRepository.new
|
|
296
|
+
presenter = MeuPresenter.new
|
|
297
|
+
|
|
298
|
+
Application::UseCases::ListarConcursos.new(
|
|
299
|
+
repository: repository,
|
|
300
|
+
presenter: presenter
|
|
301
|
+
).execute(
|
|
302
|
+
Application::FiltrosConcurso.new(estado: 'SP', nivel: 'Superior', limite: 10, modo: :abertos)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
presenter.concursos # Array<Domain::Entities::Concurso>
|
|
306
|
+
presenter.metadata # Hash
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Ver edital completo
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
class EditalPresenter < Application::Ports::Presenter
|
|
313
|
+
attr_reader :edital
|
|
314
|
+
|
|
315
|
+
def show_edital(edital) = @edital = edital
|
|
316
|
+
def error(msg) = raise msg
|
|
317
|
+
def show(*) = nil
|
|
318
|
+
def show_loading = nil
|
|
319
|
+
def show_provas(_) = nil
|
|
320
|
+
def show_download_start(*) = nil
|
|
321
|
+
def show_download_done(_) = nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
presenter = EditalPresenter.new
|
|
325
|
+
Application::UseCases::VerEdital.new(
|
|
326
|
+
repository: Infrastructure::Repositories::PciConcursoRepository.new,
|
|
327
|
+
presenter: presenter
|
|
328
|
+
).execute(Application::VerEditalRequest.new(url: 'https://pciconcursos.com.br/concurso/...'))
|
|
329
|
+
|
|
330
|
+
presenter.edital.titulo # => String
|
|
331
|
+
presenter.edital.pdfs # => [{ titulo:, url: }]
|
|
332
|
+
presenter.edital.provas_url # => String | nil
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Baixar PDFs de um edital
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
require 'application/use_cases/baixar_edital'
|
|
339
|
+
require 'application/baixar_edital_request'
|
|
340
|
+
require 'infrastructure/http/http_file_downloader'
|
|
341
|
+
|
|
342
|
+
Application::UseCases::BaixarEdital.new(
|
|
343
|
+
repository: Infrastructure::Repositories::PciConcursoRepository.new,
|
|
344
|
+
downloader: Infrastructure::Http::HttpFileDownloader.new,
|
|
345
|
+
presenter: presenter # qualquer presenter com show_download_start/done
|
|
346
|
+
).execute(
|
|
347
|
+
Application::BaixarEditalRequest.new(
|
|
348
|
+
url: 'https://pciconcursos.com.br/concurso/...',
|
|
349
|
+
dest_dir: '/tmp/editais'
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
# PDFs salvos em dest_dir
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Baixar provas e gabaritos
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
require 'application/use_cases/baixar_provas'
|
|
359
|
+
require 'application/baixar_provas_request'
|
|
360
|
+
|
|
361
|
+
Application::UseCases::BaixarProvas.new(
|
|
362
|
+
repository: Infrastructure::Repositories::PciConcursoRepository.new,
|
|
363
|
+
downloader: Infrastructure::Http::HttpFileDownloader.new,
|
|
364
|
+
presenter: presenter
|
|
365
|
+
).execute(
|
|
366
|
+
Application::BaixarProvasRequest.new(
|
|
367
|
+
url: 'https://pciconcursos.com.br/concurso/...', # mesma URL do concurso
|
|
368
|
+
dest_dir: '/tmp/provas'
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
# PDFs salvos em dest_dir organizados por cargo
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Arquitetura
|
|
377
|
+
|
|
378
|
+
O projeto segue a **arquitetura hexagonal (Ports & Adapters)** com separação estrita de camadas:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
┌─────────────────────────────────────────────────────┐
|
|
382
|
+
│ Apresentação (Adaptadores de entrada) │
|
|
383
|
+
│ CLI: CliOptionsParser → CliController │
|
|
384
|
+
│ API: Controller Rails/Sinatra/etc. (você implementa)│
|
|
385
|
+
│ TerminalPresenter ←→ JsonPresenter (custom) │
|
|
386
|
+
└──────────────────────┬──────────────────────────────┘
|
|
387
|
+
│ Request objects
|
|
388
|
+
┌──────────────────────▼──────────────────────────────┐
|
|
389
|
+
│ Aplicação (Núcleo — sem dependências de framework) │
|
|
390
|
+
│ Use Cases: ListarConcursos, VerEdital, │
|
|
391
|
+
│ BaixarEdital, BaixarProvas │
|
|
392
|
+
│ Ports (interfaces): ConcursoRepository, │
|
|
393
|
+
│ FileDownloader, Presenter │
|
|
394
|
+
│ Value Objects: FiltrosConcurso, *Request │
|
|
395
|
+
└──────────────────────┬──────────────────────────────┘
|
|
396
|
+
│ Entidades de domínio
|
|
397
|
+
┌──────────────────────▼──────────────────────────────┐
|
|
398
|
+
│ Domínio (Puro, sem dependências externas) │
|
|
399
|
+
│ Entities: Concurso, Edital (imutáveis/frozen) │
|
|
400
|
+
└──────────────────────┬──────────────────────────────┘
|
|
401
|
+
│ implementa ports
|
|
402
|
+
┌──────────────────────▼──────────────────────────────┐
|
|
403
|
+
│ Infraestrutura (Adaptadores de saída) │
|
|
404
|
+
│ PciConcursoRepository (implementa ConcursoRepository)│
|
|
405
|
+
│ HttpClient, HttpFileDownloader │
|
|
406
|
+
│ PciHtmlParser (Nokogiri) │
|
|
407
|
+
└─────────────────────────────────────────────────────┘
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Princípio central:** os use cases dependem apenas das interfaces (ports) — nunca de infraestrutura diretamente. Isso permite trocar a fonte de dados (outro site, banco de dados, mock) sem alterar o núcleo.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Entidades de Domínio
|
|
415
|
+
|
|
416
|
+
### `Domain::Entities::Concurso`
|
|
417
|
+
|
|
418
|
+
| Atributo | Tipo | Descrição |
|
|
419
|
+
|--------------|----------|--------------------------------------------|
|
|
420
|
+
| `instituicao`| `String` | Nome do órgão/instituição |
|
|
421
|
+
| `estado` | `String` | UF (ex: `SP`, `RJ`) |
|
|
422
|
+
| `vagas` | `String` | Número de vagas |
|
|
423
|
+
| `salario` | `String` | Faixa salarial |
|
|
424
|
+
| `cargos` | `String` | Cargos disponíveis |
|
|
425
|
+
| `nivel` | `String` | Nível de escolaridade exigido |
|
|
426
|
+
| `prazo` | `String` | Data-limite de inscrição |
|
|
427
|
+
| `url` | `String` | URL do edital no PCI Concursos |
|
|
428
|
+
|
|
429
|
+
### `Domain::Entities::Edital`
|
|
430
|
+
|
|
431
|
+
| Atributo | Tipo | Descrição |
|
|
432
|
+
|-------------------|------------------|------------------------------------------------------|
|
|
433
|
+
| `titulo` | `String` | Título do edital |
|
|
434
|
+
| `descricao` | `String` | Descrição resumida |
|
|
435
|
+
| `data_publicacao` | `String` | Data de publicação |
|
|
436
|
+
| `blocos` | `Array<Hash>` | Conteúdo estruturado: `{ tipo:, texto: }` |
|
|
437
|
+
| `pdfs` | `Array<Hash>` | Lista de PDFs: `{ titulo:, url: }` |
|
|
438
|
+
| `provas_url` | `String \| nil` | URL da página de provas anteriores (se disponível) |
|
|
439
|
+
| `url` | `String` | URL canônica do edital |
|
|
440
|
+
|
|
441
|
+
Os tipos de bloco em `blocos` são: `:secao`, `:paragrafo`, `:item`.
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## Filtros disponíveis
|
|
446
|
+
|
|
447
|
+
`Application::FiltrosConcurso` é um `Struct` com os seguintes campos:
|
|
448
|
+
|
|
449
|
+
| Campo | Tipo | Descrição | Exemplo |
|
|
450
|
+
|----------|-------------------|--------------------------------------------------|-----------------|
|
|
451
|
+
| `estado` | `String \| nil` | Filtrar por UF | `'SP'`, `'MG'` |
|
|
452
|
+
| `nivel` | `String \| nil` | Nível de escolaridade | `'Superior'` |
|
|
453
|
+
| `busca` | `String \| nil` | Busca textual (obrigatório para `:encerrados`) | `'analista'` |
|
|
454
|
+
| `limite` | `Integer \| nil` | Máximo de resultados retornados | `10` |
|
|
455
|
+
| `modo` | `Symbol` | `:abertos` (padrão) ou `:encerrados` | `:abertos` |
|
|
456
|
+
| `ano` | `String \| nil` | Ano do prazo de inscrição | `'2026'` |
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Customizando adaptadores
|
|
461
|
+
|
|
462
|
+
A arquitetura foi pensada para facilitar extensão. Você pode substituir qualquer adaptador sem tocar no núcleo.
|
|
463
|
+
|
|
464
|
+
### Repositório mock para testes
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
require 'application/ports/concurso_repository'
|
|
468
|
+
require 'domain/entities/concurso'
|
|
469
|
+
|
|
470
|
+
class MockConcursoRepository < Application::Ports::ConcursoRepository
|
|
471
|
+
def fetch_abertos
|
|
472
|
+
concursos = [
|
|
473
|
+
Domain::Entities::Concurso.new(
|
|
474
|
+
instituicao: 'TRF-1',
|
|
475
|
+
estado: 'DF',
|
|
476
|
+
vagas: '50',
|
|
477
|
+
salario: 'R$ 8.000',
|
|
478
|
+
cargos: 'Analista Judiciário',
|
|
479
|
+
nivel: 'Superior',
|
|
480
|
+
prazo: '30/06/2026',
|
|
481
|
+
url: 'https://example.com/concurso'
|
|
482
|
+
)
|
|
483
|
+
]
|
|
484
|
+
[concursos, { total_scraped: 1, modo: :abertos }]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Implementar outros métodos conforme necessário para os testes...
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Injetar na ConcursoHub ou diretamente nos use cases
|
|
491
|
+
resultado = ConcursoHub.search(estado: 'DF')
|
|
492
|
+
# Para usar o mock, injete via uso direto dos use cases (seção anterior)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Dependências
|
|
498
|
+
|
|
499
|
+
| Gem | Versão | Uso |
|
|
500
|
+
|------------|----------|------------------------------|
|
|
501
|
+
| `nokogiri` | `~> 1.16`| Parsing de HTML |
|
|
502
|
+
|
|
503
|
+
Todo o restante utiliza apenas a biblioteca padrão do Ruby: `net/http`, `uri`, `optparse`.
|
|
504
|
+
|
|
505
|
+
**Versão mínima de Ruby:** 3.1 (usa pattern matching e hash shorthand syntax).
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Contribuindo
|
|
510
|
+
|
|
511
|
+
Pull requests são bem-vindos. Ao adicionar suporte a uma nova fonte de dados (além do PCI Concursos), implemente a interface `Application::Ports::ConcursoRepository` e injete o novo adaptador — o núcleo não precisa ser alterado.
|
data/lib/concurso_hub.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'concurso_hub/version'
|
|
4
|
+
|
|
5
|
+
$LOAD_PATH.unshift(File.join(__dir__, '..', 'src'))
|
|
6
|
+
|
|
7
|
+
require 'application/filtros_concurso'
|
|
8
|
+
require 'application/ver_edital_request'
|
|
9
|
+
require 'application/ports/presenter'
|
|
10
|
+
require 'application/use_cases/listar_concursos'
|
|
11
|
+
require 'application/use_cases/ver_edital'
|
|
12
|
+
require 'application/use_cases/listar_provas'
|
|
13
|
+
require 'infrastructure/repositories/pci_concurso_repository'
|
|
14
|
+
|
|
15
|
+
module ConcursoHub
|
|
16
|
+
def self.search(
|
|
17
|
+
estado: nil,
|
|
18
|
+
nivel: nil,
|
|
19
|
+
busca: nil,
|
|
20
|
+
limite: nil,
|
|
21
|
+
ano: nil,
|
|
22
|
+
modo: :abertos,
|
|
23
|
+
with_edital: false,
|
|
24
|
+
with_provas: false
|
|
25
|
+
)
|
|
26
|
+
repository = build_repository
|
|
27
|
+
presenter = SilentPresenter.new
|
|
28
|
+
|
|
29
|
+
Application::UseCases::ListarConcursos.new(
|
|
30
|
+
repository: repository,
|
|
31
|
+
presenter: presenter
|
|
32
|
+
).execute(
|
|
33
|
+
Application::FiltrosConcurso.new(
|
|
34
|
+
estado: estado, nivel: nivel, busca: busca,
|
|
35
|
+
limite: limite, ano: ano, modo: modo
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
raise presenter.erro if presenter.erro
|
|
40
|
+
|
|
41
|
+
concursos = presenter.concursos.map { |c| serialize_concurso(c) }
|
|
42
|
+
|
|
43
|
+
if with_edital || with_provas
|
|
44
|
+
concursos.each do |c|
|
|
45
|
+
edital_data = fetch_edital_hash(c[:url], repository)
|
|
46
|
+
c[:edital] = edital_data
|
|
47
|
+
|
|
48
|
+
if with_provas && edital_data[:provas_url]
|
|
49
|
+
c[:provas] = fetch_provas_array(edital_data[:provas_url], repository)
|
|
50
|
+
else
|
|
51
|
+
c[:provas] = [] if with_provas
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{ concursos: concursos, metadata: presenter.metadata }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.edital(url)
|
|
60
|
+
fetch_edital_hash(url, build_repository)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.provas(url)
|
|
64
|
+
repository = build_repository
|
|
65
|
+
edital = fetch_edital_hash(url, repository)
|
|
66
|
+
|
|
67
|
+
raise "Nenhuma prova disponível para este concurso." unless edital[:provas_url]
|
|
68
|
+
|
|
69
|
+
fetch_provas_array(edital[:provas_url], repository)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method def self.build_repository
|
|
73
|
+
Infrastructure::Repositories::PciConcursoRepository.new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method def self.fetch_edital_hash(url, repository)
|
|
77
|
+
presenter = SilentPresenter.new
|
|
78
|
+
Application::UseCases::VerEdital.new(
|
|
79
|
+
repository: repository,
|
|
80
|
+
presenter: presenter
|
|
81
|
+
).execute(Application::VerEditalRequest.new(url: url))
|
|
82
|
+
|
|
83
|
+
raise presenter.erro if presenter.erro
|
|
84
|
+
|
|
85
|
+
serialize_edital(presenter.edital)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private_class_method def self.fetch_provas_array(url, repository)
|
|
89
|
+
presenter = SilentPresenter.new
|
|
90
|
+
Application::UseCases::ListarProvas.new(
|
|
91
|
+
repository: repository,
|
|
92
|
+
presenter: presenter
|
|
93
|
+
).execute(Application::VerEditalRequest.new(url: url))
|
|
94
|
+
|
|
95
|
+
return [] if presenter.erro
|
|
96
|
+
|
|
97
|
+
presenter.provas
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method def self.serialize_concurso(c)
|
|
101
|
+
{
|
|
102
|
+
instituicao: c.instituicao,
|
|
103
|
+
estado: c.estado,
|
|
104
|
+
vagas: c.vagas,
|
|
105
|
+
salario: c.salario,
|
|
106
|
+
cargos: c.cargos,
|
|
107
|
+
nivel: c.nivel,
|
|
108
|
+
prazo: c.prazo,
|
|
109
|
+
url: c.url
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private_class_method def self.serialize_edital(e)
|
|
114
|
+
{
|
|
115
|
+
titulo: e.titulo,
|
|
116
|
+
descricao: e.descricao,
|
|
117
|
+
data_publicacao: e.data_publicacao,
|
|
118
|
+
pdfs: e.pdfs,
|
|
119
|
+
provas_url: e.provas_url,
|
|
120
|
+
blocos: e.blocos,
|
|
121
|
+
url: e.url
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class SilentPresenter < Application::Ports::Presenter
|
|
126
|
+
attr_reader :concursos, :edital, :provas, :metadata, :erro
|
|
127
|
+
|
|
128
|
+
def show(concursos, metadata: {})
|
|
129
|
+
@concursos = concursos
|
|
130
|
+
@metadata = metadata
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def show_edital(edital) = @edital = edital
|
|
134
|
+
def show_provas(provas) = @provas = provas
|
|
135
|
+
def error(message) = @erro = message
|
|
136
|
+
|
|
137
|
+
def show_loading = nil
|
|
138
|
+
def show_download_start(*) = nil
|
|
139
|
+
def show_download_done(_) = nil
|
|
140
|
+
end
|
|
141
|
+
private_constant :SilentPresenter
|
|
142
|
+
end
|
|
143
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Application
|
|
4
|
+
FiltrosConcurso = Struct.new(
|
|
5
|
+
:estado,
|
|
6
|
+
:nivel,
|
|
7
|
+
:busca,
|
|
8
|
+
:limite,
|
|
9
|
+
:modo,
|
|
10
|
+
:ano,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
) do
|
|
13
|
+
def abertos? = modo != :encerrados
|
|
14
|
+
def encerrados? = modo == :encerrados
|
|
15
|
+
end
|
|
16
|
+
end
|