concurso_hub 0.1.1 → 0.2.1
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 +4 -4
- data/README.md +44 -3
- data/lib/concurso_hub/version.rb +1 -1
- data/lib/concurso_hub.rb +5 -0
- data/src/infrastructure/http/http_file_downloader.rb +30 -0
- data/src/infrastructure/parsers/pci_html_parser.rb +2 -2
- data/src/presentation/cli/cli_controller.rb +42 -1
- data/src/presentation/formatters/terminal_presenter.rb +9 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f99628e279ee08c6946acef5af57235d817ad60f21c34ea0c479f8d613ea72ca
|
|
4
|
+
data.tar.gz: fe45e6e0222417e0668f5919bb7cd489b1948a19a5ddae842636cc45c2e1e4b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4ab516996fcb6e9db83f9c7cad653fcfe9bf1debf5afcc9da77631a512a779f90404749238a0392b6370ea0710478131be21f2881c5218f329b3d1d5bc12de7
|
|
7
|
+
data.tar.gz: 35f768dd828eb2d0adb259fb3e91896855c50f3fc82f4125a372224e607ff0cd2522f4e8a9be8070d8e9619b1da717bf829d9ade7424eaa3c7e9a06bcf824ebe
|
data/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Projetada com **arquitetura hexagonal (Ports & Adapters)**, a biblioteca mantém
|
|
|
14
14
|
- [ConcursoHub.search](#concursoscrapersearch)
|
|
15
15
|
- [ConcursoHub.edital](#concursoscraperedital)
|
|
16
16
|
- [ConcursoHub.provas](#concursoscraperprovas)
|
|
17
|
+
- [ConcursoHub.download](#concursoscraperdownload)
|
|
17
18
|
- [Exemplo completo em Rails](#exemplo-completo-em-rails)
|
|
18
19
|
- [Uso avançado (injeção de dependências)](#uso-avançado-injeção-de-dependências)
|
|
19
20
|
- [Listar concursos](#listar-concursos)
|
|
@@ -217,6 +218,36 @@ provas
|
|
|
217
218
|
|
|
218
219
|
---
|
|
219
220
|
|
|
221
|
+
### ConcursoHub.download
|
|
222
|
+
|
|
223
|
+
Busca um PDF do PCI Concursos e retorna os **bytes brutos** (`String` binária) — sem salvar nada em disco. A gem é apenas a ponte; o backend decide o que fazer com os bytes.
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# url vem de edital[:pdfs].first[:url] ou provas[0][:pdfs].first[:url]
|
|
227
|
+
bytes = ConcursoHub.download(pdf_url)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Casos de uso típicos em backends multi-usuário:**
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
# 1. Stream direto para o cliente (Rails) — sem tocar no disco
|
|
234
|
+
bytes = ConcursoHub.download(params[:pdf_url])
|
|
235
|
+
send_data bytes, type: 'application/pdf', filename: 'documento.pdf', disposition: 'inline'
|
|
236
|
+
|
|
237
|
+
# 2. Salvar no S3 / object storage
|
|
238
|
+
bytes = ConcursoHub.download(pdf_url)
|
|
239
|
+
S3.put_object(bucket: 'meu-bucket', key: "provas/#{cargo}.pdf", body: bytes)
|
|
240
|
+
|
|
241
|
+
# 3. Encodar em Base64 para resposta JSON
|
|
242
|
+
require 'base64'
|
|
243
|
+
bytes = ConcursoHub.download(pdf_url)
|
|
244
|
+
render json: { filename: 'prova.pdf', content: Base64.strict_encode64(bytes) }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
> **Nota:** Não utilize este método para salvar arquivos em disco em ambientes multi-usuário — prefira object storage (S3, GCS) ou stream direto ao cliente. O método `download` faz sentido em disco apenas na CLI (uso local).
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
220
251
|
### Exemplo completo em Rails
|
|
221
252
|
|
|
222
253
|
**`Gemfile`**
|
|
@@ -258,14 +289,24 @@ class ConcursosController < ApplicationController
|
|
|
258
289
|
rescue => e
|
|
259
290
|
render json: { error: e.message }, status: :bad_gateway
|
|
260
291
|
end
|
|
292
|
+
|
|
293
|
+
# GET /concursos/download?url=https://pciconcursos.com.br/.../edital.pdf
|
|
294
|
+
# Faz o stream do PDF direto ao cliente — sem salvar em disco
|
|
295
|
+
def download
|
|
296
|
+
bytes = ConcursoHub.download(params[:url])
|
|
297
|
+
send_data bytes, type: 'application/pdf', disposition: 'inline'
|
|
298
|
+
rescue => e
|
|
299
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
300
|
+
end
|
|
261
301
|
end
|
|
262
302
|
```
|
|
263
303
|
|
|
264
304
|
**`config/routes.rb`**
|
|
265
305
|
```ruby
|
|
266
|
-
get '/concursos',
|
|
267
|
-
get '/concursos/edital',
|
|
268
|
-
get '/concursos/provas',
|
|
306
|
+
get '/concursos', to: 'concursos#index'
|
|
307
|
+
get '/concursos/edital', to: 'concursos#edital'
|
|
308
|
+
get '/concursos/provas', to: 'concursos#provas' # ?url= é a URL do concurso
|
|
309
|
+
get '/concursos/download', to: 'concursos#download' # ?url= é a URL do PDF
|
|
269
310
|
```
|
|
270
311
|
|
|
271
312
|
---
|
data/lib/concurso_hub/version.rb
CHANGED
data/lib/concurso_hub.rb
CHANGED
|
@@ -11,6 +11,7 @@ require 'application/use_cases/listar_concursos'
|
|
|
11
11
|
require 'application/use_cases/ver_edital'
|
|
12
12
|
require 'application/use_cases/listar_provas'
|
|
13
13
|
require 'infrastructure/repositories/pci_concurso_repository'
|
|
14
|
+
require 'infrastructure/http/http_file_downloader'
|
|
14
15
|
|
|
15
16
|
module ConcursoHub
|
|
16
17
|
def self.search(
|
|
@@ -60,6 +61,10 @@ module ConcursoHub
|
|
|
60
61
|
fetch_edital_hash(url, build_repository)
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
def self.download(url)
|
|
65
|
+
Infrastructure::Http::HttpFileDownloader.new.fetch_bytes(url)
|
|
66
|
+
end
|
|
67
|
+
|
|
63
68
|
def self.provas(url)
|
|
64
69
|
repository = build_repository
|
|
65
70
|
edital = fetch_edital_hash(url, repository)
|
|
@@ -10,6 +10,36 @@ module Infrastructure
|
|
|
10
10
|
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
|
|
11
11
|
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
|
12
12
|
|
|
13
|
+
def fetch_bytes(url, redirect_limit: 5)
|
|
14
|
+
raise 'Muitos redirecionamentos' if redirect_limit.zero?
|
|
15
|
+
|
|
16
|
+
uri = URI.parse(url)
|
|
17
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
18
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
19
|
+
http.open_timeout = 15
|
|
20
|
+
http.read_timeout = 120
|
|
21
|
+
|
|
22
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
23
|
+
request['User-Agent'] = USER_AGENT
|
|
24
|
+
|
|
25
|
+
http.start do |h|
|
|
26
|
+
h.request(request) do |response|
|
|
27
|
+
case response
|
|
28
|
+
when Net::HTTPSuccess
|
|
29
|
+
buffer = +''
|
|
30
|
+
response.read_body { |chunk| buffer << chunk }
|
|
31
|
+
buffer
|
|
32
|
+
when Net::HTTPRedirection
|
|
33
|
+
new_url = response['location']
|
|
34
|
+
new_url = URI.join(url, new_url).to_s unless new_url.start_with?('http')
|
|
35
|
+
fetch_bytes(new_url, redirect_limit: redirect_limit - 1)
|
|
36
|
+
else
|
|
37
|
+
raise "Erro HTTP: #{response.code} #{response.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
13
43
|
def download(url, dest_path, redirect_limit: 5)
|
|
14
44
|
raise 'Muitos redirecionamentos' if redirect_limit.zero?
|
|
15
45
|
|
|
@@ -87,7 +87,7 @@ module Infrastructure
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def build_concurso_encerrado(el)
|
|
90
|
-
link = el.css('div.ca > a').first
|
|
90
|
+
link = el.css('div.ca > a[href^="/concurso/"]').first
|
|
91
91
|
return nil unless link
|
|
92
92
|
|
|
93
93
|
state = el.css('div.cc').first&.text&.strip || ''
|
|
@@ -106,7 +106,7 @@ module Infrastructure
|
|
|
106
106
|
)
|
|
107
107
|
end
|
|
108
108
|
def build_concurso(el, state)
|
|
109
|
-
link = el.css('div.ca > a').first
|
|
109
|
+
link = el.css('div.ca > a[href^="/concurso/"]').first
|
|
110
110
|
return nil unless link
|
|
111
111
|
|
|
112
112
|
vagas, salario = parse_vagas_salario(el)
|
|
@@ -8,11 +8,13 @@ require_relative '../../application/baixar_provas_request'
|
|
|
8
8
|
module Presentation
|
|
9
9
|
module Cli
|
|
10
10
|
class CliController
|
|
11
|
-
def initialize(use_case:, ver_edital:, baixar_edital:, baixar_provas:, options_parser: CliOptionsParser.new)
|
|
11
|
+
def initialize(use_case:, ver_edital:, baixar_edital:, baixar_provas:, presenter:, repository:, options_parser: CliOptionsParser.new)
|
|
12
12
|
@use_case = use_case
|
|
13
13
|
@ver_edital = ver_edital
|
|
14
14
|
@baixar_edital = baixar_edital
|
|
15
15
|
@baixar_provas = baixar_provas
|
|
16
|
+
@presenter = presenter
|
|
17
|
+
@repository = repository
|
|
16
18
|
@options_parser = options_parser
|
|
17
19
|
end
|
|
18
20
|
|
|
@@ -28,8 +30,47 @@ module Presentation
|
|
|
28
30
|
@ver_edital.execute(request)
|
|
29
31
|
else
|
|
30
32
|
@use_case.execute(request)
|
|
33
|
+
interactive_menu
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def interactive_menu
|
|
40
|
+
concursos = @presenter.last_concursos
|
|
41
|
+
return if concursos.nil? || concursos.empty?
|
|
42
|
+
|
|
43
|
+
print "\nSelecione um concurso (1-#{concursos.size}) ou Enter para sair: "
|
|
44
|
+
input = $stdin.gets&.chomp
|
|
45
|
+
return if input.nil? || input.empty?
|
|
46
|
+
|
|
47
|
+
index = input.to_i - 1
|
|
48
|
+
return unless index.between?(0, concursos.size - 1)
|
|
49
|
+
|
|
50
|
+
concurso = concursos[index]
|
|
51
|
+
|
|
52
|
+
puts "\n \e[1m\e[97m#{concurso.instituicao}\e[0m — #{concurso.estado}"
|
|
53
|
+
puts
|
|
54
|
+
puts " [1] Ver edital completo"
|
|
55
|
+
puts " [2] Baixar PDFs do edital"
|
|
56
|
+
puts " [3] Baixar provas e gabaritos"
|
|
57
|
+
print "\n Opção (Enter para cancelar): "
|
|
58
|
+
|
|
59
|
+
case $stdin.gets&.chomp
|
|
60
|
+
when '1'
|
|
61
|
+
@ver_edital.execute(Application::VerEditalRequest.new(url: concurso.url))
|
|
62
|
+
when '2'
|
|
63
|
+
@baixar_edital.execute(Application::BaixarEditalRequest.new(url: concurso.url, dest_dir: nil))
|
|
64
|
+
when '3'
|
|
65
|
+
edital = @repository.fetch_edital(concurso.url)
|
|
66
|
+
unless edital.provas_url
|
|
67
|
+
puts "\n \e[31mNenhuma prova disponível para este concurso.\e[0m"
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
@baixar_provas.execute(Application::BaixarProvasRequest.new(url: edital.provas_url, dest_dir: nil))
|
|
31
71
|
end
|
|
32
72
|
end
|
|
33
73
|
end
|
|
34
74
|
end
|
|
35
75
|
end
|
|
76
|
+
|
|
@@ -15,11 +15,14 @@ module Presentation
|
|
|
15
15
|
CYAN = "\e[36m"
|
|
16
16
|
WHITE = "\e[97m"
|
|
17
17
|
|
|
18
|
+
attr_reader :last_concursos
|
|
19
|
+
|
|
18
20
|
def show_loading
|
|
19
21
|
puts "#{CYAN}Conectando ao pciconcursos.com.br…#{RESET}"
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def show(concursos, metadata: {})
|
|
25
|
+
@last_concursos = concursos
|
|
23
26
|
if concursos.empty?
|
|
24
27
|
puts "\n#{RED}Nenhum concurso encontrado com os filtros informados.#{RESET}\n"
|
|
25
28
|
return
|
|
@@ -141,19 +144,20 @@ module Presentation
|
|
|
141
144
|
puts
|
|
142
145
|
|
|
143
146
|
current_state = nil
|
|
144
|
-
concursos.
|
|
147
|
+
concursos.each_with_index do |c, i|
|
|
145
148
|
if c.estado != current_state
|
|
146
149
|
current_state = c.estado
|
|
147
|
-
puts "\n#{BOLD}#{BLUE}
|
|
150
|
+
puts "\n#{BOLD}#{BLUE}\u25b6 #{current_state}#{RESET}"
|
|
148
151
|
puts "#{DIM}#{'-' * 62}#{RESET}"
|
|
149
152
|
end
|
|
150
|
-
render_entry(c)
|
|
153
|
+
render_entry(c, i + 1)
|
|
151
154
|
end
|
|
152
155
|
end
|
|
153
156
|
|
|
154
|
-
def render_entry(concurso)
|
|
157
|
+
def render_entry(concurso, numero = nil)
|
|
155
158
|
puts
|
|
156
|
-
|
|
159
|
+
num_prefix = numero ? "#{BOLD}#{CYAN}[#{numero}]#{RESET} " : ' '
|
|
160
|
+
puts " #{num_prefix}#{BOLD}#{WHITE}#{concurso.instituicao}#{RESET}"
|
|
157
161
|
|
|
158
162
|
vagas_line = if concurso.salario.empty?
|
|
159
163
|
concurso.vagas
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concurso_hub
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Felipe Longo
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|