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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d3c35af1cd394db7f3addc01c36daf6213ef41cad01f7698012fa133fd138ea
4
- data.tar.gz: b3560b5c754af23223c6057cb09b101d3a9b111aeda8c9e4182e66bff317c3cc
3
+ metadata.gz: f99628e279ee08c6946acef5af57235d817ad60f21c34ea0c479f8d613ea72ca
4
+ data.tar.gz: fe45e6e0222417e0668f5919bb7cd489b1948a19a5ddae842636cc45c2e1e4b5
5
5
  SHA512:
6
- metadata.gz: e36c6535eda410ed7ad844df0a12c6b62115ca399a1fbf3fb6fb0c0460b8616708a37bacd974571c507d80bdfc0f60aa7bc2341cc380d18934408bf251f4ffdc
7
- data.tar.gz: 820b978859be06ee266300016d1324b36bb4b5ab9576a054e2d679d177db5d2a5d5147484bae866f117011479591d1c17481e1c1fa4851d5f15523840b962059
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', to: 'concursos#index'
267
- get '/concursos/edital', to: 'concursos#edital'
268
- get '/concursos/provas', to: 'concursos#provas' # ?url= é a URL do concurso
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
  ---
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConcursoHub
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.1'
5
5
  end
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.each do |c|
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} #{current_state}#{RESET}"
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
- puts " #{BOLD}#{WHITE}#{concurso.instituicao}#{RESET}"
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.1.1
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-22 00:00:00.000000000 Z
11
+ date: 2026-05-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri