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.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../../application/ports/concurso_repository'
5
+ require_relative '../http/http_client'
6
+ require_relative '../parsers/pci_html_parser'
7
+
8
+ module Infrastructure
9
+ module Repositories
10
+ class PciConcursoRepository < Application::Ports::ConcursoRepository
11
+ ABERTOS_URL = 'https://www.pciconcursos.com.br/concursos/'
12
+ ENCERRADOS_URL = 'https://www.pciconcursos.com.br/pesquisa/'
13
+
14
+ def initialize(
15
+ http_client: Http::HttpClient.new,
16
+ parser: Parsers::PciHtmlParser.new
17
+ )
18
+ @http_client = http_client
19
+ @parser = parser
20
+ end
21
+
22
+ def fetch_abertos
23
+ html = @http_client.get(ABERTOS_URL)
24
+ @parser.parse_abertos(html)
25
+ end
26
+
27
+ def fetch_encerrados(busca)
28
+ url = "#{ENCERRADOS_URL}?p=#{URI.encode_www_form_component(busca)}&tipopesquisa=1"
29
+ html = @http_client.get(url)
30
+ @parser.parse_encerrados(html)
31
+ end
32
+
33
+ def fetch_edital(url)
34
+ html = @http_client.get(url)
35
+ @parser.parse_edital(html, url)
36
+ end
37
+
38
+ def fetch_provas_listing(provas_url)
39
+ html = @http_client.get(provas_url)
40
+ @parser.parse_provas_listing(html)
41
+ end
42
+
43
+ def fetch_prova_pdfs(download_url)
44
+ html = @http_client.get(download_url)
45
+ @parser.parse_prova_download_page(html)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli_options_parser'
4
+ require_relative '../../application/ver_edital_request'
5
+ require_relative '../../application/baixar_edital_request'
6
+ require_relative '../../application/baixar_provas_request'
7
+
8
+ module Presentation
9
+ module Cli
10
+ class CliController
11
+ def initialize(use_case:, ver_edital:, baixar_edital:, baixar_provas:, options_parser: CliOptionsParser.new)
12
+ @use_case = use_case
13
+ @ver_edital = ver_edital
14
+ @baixar_edital = baixar_edital
15
+ @baixar_provas = baixar_provas
16
+ @options_parser = options_parser
17
+ end
18
+
19
+ def run(args)
20
+ request = @options_parser.parse(args)
21
+
22
+ case request
23
+ when Application::BaixarProvasRequest
24
+ @baixar_provas.execute(request)
25
+ when Application::BaixarEditalRequest
26
+ @baixar_edital.execute(request)
27
+ when Application::VerEditalRequest
28
+ @ver_edital.execute(request)
29
+ else
30
+ @use_case.execute(request)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative '../../application/filtros_concurso'
5
+ require_relative '../../application/ver_edital_request'
6
+ require_relative '../../application/baixar_edital_request'
7
+ require_relative '../../application/baixar_provas_request'
8
+
9
+ module Presentation
10
+ module Cli
11
+ class CliOptionsParser
12
+ ESTADOS_VALIDOS = %w[
13
+ NACIONAL AC AL AM AP BA CE DF ES GO MA MG MS MT PA PB PE PI PR
14
+ RJ RN RO RR RS SC SE SP TO
15
+ ].freeze
16
+
17
+ def parse(args)
18
+ options = {}
19
+ ver_url = nil
20
+ baixar_url = nil
21
+ baixar_provas_url = nil
22
+ dest_dir = nil
23
+
24
+ parser = OptionParser.new do |opts|
25
+ opts.banner = build_banner
26
+ opts.separator 'Opções:'
27
+
28
+ opts.on('--ver URL',
29
+ 'Exibir o edital completo de um concurso pela URL') do |v|
30
+ ver_url = v
31
+ end
32
+
33
+ opts.on('--baixar URL',
34
+ 'Baixar os PDFs do edital de um concurso pela URL') do |v|
35
+ baixar_url = v
36
+ end
37
+
38
+ opts.on('--baixar-provas URL',
39
+ 'Baixar provas/gabaritos de uma página de provas do pciconcursos') do |v|
40
+ baixar_provas_url = v
41
+ end
42
+
43
+ opts.on('--dir PASTA',
44
+ 'Pasta de destino para --baixar (padrão: ./editais/)') do |v|
45
+ dest_dir = v
46
+ end
47
+
48
+ opts.on('--estado ESTADO',
49
+ "Filtrar por estado/UF (#{ESTADOS_VALIDOS.join(', ')})") do |v|
50
+ options[:estado] = v.upcase
51
+ end
52
+
53
+ opts.on('--nivel NIVEL',
54
+ 'Filtrar por escolaridade (ex: Superior, Médio, Técnico)') do |v|
55
+ options[:nivel] = v
56
+ end
57
+
58
+ opts.on('--busca TEXTO',
59
+ 'Buscar texto no nome da instituição ou cargo') do |v|
60
+ options[:busca] = v
61
+ end
62
+
63
+ opts.on('--limite N', Integer,
64
+ 'Limitar o número de resultados exibidos') do |v|
65
+ options[:limite] = v
66
+ end
67
+
68
+ opts.on('--ano ANO', Integer,
69
+ 'Filtrar pelo ano no prazo de inscrição (ex: 2025, 2026)') do |v|
70
+ options[:ano] = v
71
+ end
72
+
73
+ opts.on('--abertos',
74
+ 'Mostrar concursos com inscrições abertas (padrão)') do
75
+ options[:modo] = :abertos
76
+ end
77
+
78
+ opts.on('--encerrados',
79
+ 'Mostrar concursos encerrados (requer --busca)') do
80
+ options[:modo] = :encerrados
81
+ end
82
+
83
+ opts.on('-h', '--help', 'Exibir esta ajuda') do
84
+ puts opts
85
+ exit
86
+ end
87
+ end
88
+
89
+ parser.parse!(args)
90
+ return Application::BaixarProvasRequest.new(url: baixar_provas_url, dest_dir: dest_dir) if baixar_provas_url
91
+ return Application::BaixarEditalRequest.new(url: baixar_url, dest_dir: dest_dir) if baixar_url
92
+ return Application::VerEditalRequest.new(url: ver_url) if ver_url
93
+
94
+ Application::FiltrosConcurso.new(**options)
95
+ end
96
+
97
+ private
98
+
99
+ def build_banner
100
+ <<~BANNER
101
+
102
+ Uso:
103
+ ruby main.rb [opções]
104
+
105
+ Exemplos:
106
+ ruby main.rb # todos os abertos
107
+ ruby main.rb --estado SP # apenas SP
108
+ ruby main.rb --nivel Superior # nível superior
109
+ ruby main.rb --busca analista # busca por texto
110
+ ruby main.rb --estado MG --limite 10 # 10 primeiros de MG
111
+ ruby main.rb --encerrados --busca policia # encerrados sobre polícia
112
+ ruby main.rb --ver URL # edital completo de um concurso
113
+ ruby main.rb --baixar URL # baixar PDFs do edital
114
+ ruby main.rb --baixar URL --dir ~/Downloads
115
+ ruby main.rb --baixar-provas URL # baixar provas/gabaritos anteriores
116
+
117
+ BANNER
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../application/ports/presenter'
4
+
5
+ module Presentation
6
+ module Formatters
7
+ class TerminalPresenter < Application::Ports::Presenter
8
+ RESET = "\e[0m"
9
+ BOLD = "\e[1m"
10
+ DIM = "\e[2m"
11
+ RED = "\e[31m"
12
+ GREEN = "\e[32m"
13
+ YELLOW = "\e[33m"
14
+ BLUE = "\e[34m"
15
+ CYAN = "\e[36m"
16
+ WHITE = "\e[97m"
17
+
18
+ def show_loading
19
+ puts "#{CYAN}Conectando ao pciconcursos.com.br…#{RESET}"
20
+ end
21
+
22
+ def show(concursos, metadata: {})
23
+ if concursos.empty?
24
+ puts "\n#{RED}Nenhum concurso encontrado com os filtros informados.#{RESET}\n"
25
+ return
26
+ end
27
+
28
+ render_header(metadata)
29
+ render_concursos(concursos)
30
+ render_footer(concursos.size)
31
+ end
32
+
33
+ def error(message)
34
+ warn "#{RED}#{message}#{RESET}"
35
+ end
36
+
37
+ def show_edital(edital)
38
+ puts
39
+ puts "#{BOLD}#{CYAN}╔══════════════════════════════════════════════════════════╗#{RESET}"
40
+ puts "#{BOLD}#{CYAN}║ EDITAL COMPLETO ║#{RESET}"
41
+ puts "#{BOLD}#{CYAN}╚══════════════════════════════════════════════════════════╝#{RESET}"
42
+ puts
43
+ puts "#{BOLD}#{WHITE}#{edital.titulo}#{RESET}"
44
+ puts
45
+ puts "#{DIM}#{edital.descricao}#{RESET}" unless edital.descricao.empty?
46
+ puts "#{DIM}Publicado em: #{edital.data_publicacao}#{RESET}" unless edital.data_publicacao.empty?
47
+ puts "#{DIM}Fonte: #{edital.url}#{RESET}"
48
+ puts
49
+ puts "#{DIM}#{'─' * 70}#{RESET}"
50
+ puts
51
+
52
+ edital.blocos.each do |bloco|
53
+ case bloco[:tipo]
54
+ when :secao
55
+ puts "\n#{BOLD}#{YELLOW}#{bloco[:texto]}#{RESET}\n"
56
+ when :paragrafo
57
+ puts wrap_text(bloco[:texto])
58
+ puts
59
+ when :item
60
+ puts " #{CYAN}•#{RESET} #{wrap_text(bloco[:texto], indent: 4)}"
61
+ end
62
+ end
63
+
64
+ unless edital.pdfs.empty?
65
+ puts
66
+ puts "#{BOLD}#{YELLOW}PDFs disponíveis:#{RESET}"
67
+ edital.pdfs.each_with_index do |pdf, i|
68
+ puts " #{CYAN}[#{i + 1}]#{RESET} #{pdf[:titulo]}"
69
+ puts " #{DIM}#{pdf[:url]}#{RESET}"
70
+ end
71
+ puts
72
+ puts "#{DIM}Use --baixar URL para fazer download dos PDFs.#{RESET}"
73
+ end
74
+
75
+ if edital.provas_url
76
+ puts
77
+ puts "#{BOLD}#{YELLOW}Provas anteriores disponíveis:#{RESET}"
78
+ puts " #{DIM}#{edital.provas_url}#{RESET}"
79
+ puts "#{DIM}Use --baixar-provas #{edital.provas_url} para baixar todas as provas.#{RESET}"
80
+ end
81
+ end
82
+
83
+ def show_provas(provas)
84
+ if provas.empty?
85
+ puts "\n#{RED}Nenhuma prova encontrada.#{RESET}\n"
86
+ return
87
+ end
88
+
89
+ puts
90
+ puts "#{BOLD}#{CYAN}Provas e gabaritos disponíveis:#{RESET}"
91
+ puts
92
+ provas.each do |prova|
93
+ puts "#{BOLD}#{YELLOW}#{prova[:cargo]}#{RESET}"
94
+ prova[:pdfs].each_with_index do |pdf, i|
95
+ puts " #{CYAN}[#{i + 1}]#{RESET} #{pdf[:titulo]}"
96
+ puts " #{DIM}#{pdf[:url]}#{RESET}"
97
+ end
98
+ puts
99
+ end
100
+
101
+ puts
102
+ puts "#{DIM}#{'═' * 70}#{RESET}"
103
+ puts
104
+ end
105
+
106
+ def show_download_start(titulo, index, total)
107
+ puts " #{CYAN}[#{index}/#{total}]#{RESET} #{titulo}…"
108
+ end
109
+
110
+ def show_download_done(paths)
111
+ puts
112
+ puts "#{GREEN}#{BOLD}#{paths.size} arquivo(s) baixado(s) com sucesso:#{RESET}"
113
+ paths.each { |p| puts " #{DIM}#{p}#{RESET}" }
114
+ puts
115
+ end
116
+
117
+ private
118
+
119
+ def render_header(metadata)
120
+ total_vagas = metadata[:total_vagas] || ''
121
+ total_scraped = metadata[:total_scraped]
122
+ encerrados = metadata[:modo] == :encerrados
123
+ busca = metadata[:busca]
124
+
125
+ titulo = encerrados ? 'CONCURSOS ENCERRADOS' : 'CONCURSOS PÚBLICOS ABERTOS'
126
+
127
+ puts
128
+ puts "#{BOLD}#{CYAN}╔══════════════════════════════════════════════════════════╗#{RESET}"
129
+ puts "#{BOLD}#{CYAN}║ #{titulo.ljust(54)} ║#{RESET}"
130
+ puts "#{BOLD}#{CYAN}║ #{Time.now.strftime('%d/%m/%Y').ljust(54)} ║#{RESET}"
131
+ puts "#{BOLD}#{CYAN}╚══════════════════════════════════════════════════════════╝#{RESET}"
132
+ puts
133
+ puts "#{DIM}Busca: \"#{busca}\"#{RESET}" if busca
134
+ puts "#{DIM}Fonte: pciconcursos.com.br | #{total_vagas}#{RESET}" unless total_vagas.empty?
135
+ puts "#{DIM}#{total_scraped} resultado(s) encontrado(s).#{RESET}" if total_scraped
136
+ puts
137
+ end
138
+
139
+ def render_concursos(concursos)
140
+ puts "#{GREEN}#{BOLD}Exibindo #{concursos.size} concurso(s)#{RESET}"
141
+ puts
142
+
143
+ current_state = nil
144
+ concursos.each do |c|
145
+ if c.estado != current_state
146
+ current_state = c.estado
147
+ puts "\n#{BOLD}#{BLUE}▶ #{current_state}#{RESET}"
148
+ puts "#{DIM}#{'-' * 62}#{RESET}"
149
+ end
150
+ render_entry(c)
151
+ end
152
+ end
153
+
154
+ def render_entry(concurso)
155
+ puts
156
+ puts " #{BOLD}#{WHITE}#{concurso.instituicao}#{RESET}"
157
+
158
+ vagas_line = if concurso.salario.empty?
159
+ concurso.vagas
160
+ else
161
+ "#{concurso.vagas} #{YELLOW}#{concurso.salario}#{RESET}"
162
+ end
163
+
164
+ puts " #{YELLOW}Vagas :#{RESET} #{vagas_line}"
165
+ puts " #{YELLOW}Cargo :#{RESET} #{concurso.cargos}" unless concurso.cargos.empty?
166
+ puts " #{YELLOW}Nível :#{RESET} #{concurso.nivel}" unless concurso.nivel.empty?
167
+ puts " #{YELLOW}Prazo :#{RESET} #{RED}#{concurso.prazo}#{RESET}" unless concurso.prazo.empty?
168
+ puts " #{DIM}#{concurso.url}#{RESET}" if concurso.url
169
+ end
170
+
171
+ def render_footer(count)
172
+ puts
173
+ puts "#{DIM}#{'═' * 62}#{RESET}"
174
+ puts "#{GREEN}#{BOLD}Total exibido: #{count} concurso(s)#{RESET}"
175
+ puts
176
+ end
177
+
178
+ def wrap_text(text, width: 72, indent: 0)
179
+ words = text.split(' ')
180
+ prefix = ' ' * indent
181
+ lines = []
182
+ line = ''
183
+
184
+ words.each do |word|
185
+ if line.empty?
186
+ line = word
187
+ elsif (line.length + 1 + word.length) <= (width - indent)
188
+ line += " #{word}"
189
+ else
190
+ lines << line
191
+ line = word
192
+ end
193
+ end
194
+ lines << line unless line.empty?
195
+
196
+ first = lines.shift || ''
197
+ return first if lines.empty?
198
+
199
+ ([first] + lines.map { |l| prefix + l }).join("\n")
200
+ end
201
+ end
202
+ end
203
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: concurso_hub
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Felipe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ description: Gem Ruby para buscar concursos públicos brasileiros a partir do PCI Concursos.
28
+ Retorna listagens, editais e provas em estruturas de dados prontas para APIs backend.
29
+ email: ''
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - lib/concurso_hub.rb
36
+ - lib/concurso_hub/version.rb
37
+ - src/application/baixar_edital_request.rb
38
+ - src/application/baixar_provas_request.rb
39
+ - src/application/filtros_concurso.rb
40
+ - src/application/ports/concurso_repository.rb
41
+ - src/application/ports/file_downloader.rb
42
+ - src/application/ports/presenter.rb
43
+ - src/application/use_cases/baixar_edital.rb
44
+ - src/application/use_cases/baixar_provas.rb
45
+ - src/application/use_cases/listar_concursos.rb
46
+ - src/application/use_cases/listar_provas.rb
47
+ - src/application/use_cases/ver_edital.rb
48
+ - src/application/ver_edital_request.rb
49
+ - src/domain/entities/concurso.rb
50
+ - src/domain/entities/edital.rb
51
+ - src/infrastructure/http/http_client.rb
52
+ - src/infrastructure/http/http_file_downloader.rb
53
+ - src/infrastructure/parsers/pci_html_parser.rb
54
+ - src/infrastructure/repositories/pci_concurso_repository.rb
55
+ - src/presentation/cli/cli_controller.rb
56
+ - src/presentation/cli/cli_options_parser.rb
57
+ - src/presentation/formatters/terminal_presenter.rb
58
+ homepage: https://github.com/seu-usuario/concurso_hub
59
+ licenses:
60
+ - MIT
61
+ metadata: {}
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.1'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.5.11
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Busca e extração de dados de concursos públicos brasileiros
81
+ test_files: []