ConcursoHub

Gem Ruby para busca e extração de dados de concursos públicos brasileiros a partir do PCI Concursos.

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.


Sumário


Instalação

Nota: A gem ainda não foi publicada no RubyGems. Para uso local ou como dependência privada, adicione ao seu Gemfile:

gem 'concurso_hub', path: '/caminho/para/concurso_test'
# ou via git:
# gem 'concurso_hub', github: 'seu-usuario/concurso_hub'

Para instalar as dependências do projeto:

bundle install

Uso via CLI

O projeto inclui uma interface de linha de comando para uso rápido e testes.

# Listar concursos abertos (padrão)
ruby main.rb

# Filtrar por estado
ruby main.rb --estado SP

# Filtrar por nível de escolaridade
ruby main.rb --nivel Superior

# Busca por texto (instituição ou cargo)
ruby main.rb --busca "analista"

# Limitar número de resultados
ruby main.rb --limite 10

# Filtrar por ano de inscrição
ruby main.rb --ano 2026

# Combinar filtros
ruby main.rb --estado RJ --nivel Medio --limite 20

# Listar concursos encerrados (requer --busca)
ruby main.rb --encerrados --busca "policia federal"

# Ver o edital completo de um concurso
ruby main.rb --ver https://pciconcursos.com.br/concurso/...

# Baixar os PDFs do edital de um concurso
ruby main.rb --baixar https://pciconcursos.com.br/concurso/...

# Baixar provas e gabaritos anteriores
ruby main.rb --baixar-provas https://pciconcursos.com.br/concurso/...

# Especificar pasta de destino para downloads
ruby main.rb --baixar https://... --dir ~/Downloads/concursos

# Ajuda
ruby main.rb --help

Uso como gem em uma API backend

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.

require 'concurso_hub'

Busca concursos com filtros opcionais. Retorna um hash com concursos (array) e metadata.

# Busca simples — 1 requisição HTTP
resultado = ConcursoHub.search(estado: 'SP', nivel: 'Superior', limite: 10)

resultado[:concursos]
# => [
#   { instituicao: "TRF-3", estado: "SP", vagas: "50", salario: "R$ 8.529",
#     cargos: "Analista Judiciário", nivel: "Superior",
#     prazo: "10/06/2026", url: "https://pciconcursos.com.br/concurso/..." },
#   ...
# ]

resultado[:metadata]
# => { total_scraped: 120, modo: :abertos }

Com dados do edital — inclui PDFs e descrição para cada concurso (+1 req por concurso):

resultado = ConcursoHub.search(estado: 'RJ', limite: 5, with_edital: true)

resultado[:concursos].first[:edital]
# => {
#   titulo:          "Edital nº 1/2026 — TRF-2",
#   descricao:       "...",
#   data_publicacao: "15/04/2026",
#   pdfs: [
#     { titulo: "Edital completo", url: "https://..." },
#     { titulo: "Retificação nº 1", url: "https://..." }
#   ],
#   provas_url: "https://pciconcursos.com.br/provas/...",  # nil se não houver
#   blocos: [...],
#   url: "https://pciconcursos.com.br/concurso/..."
# }

Com provas e gabaritos — inclui tudo acima mais a listagem de provas anteriores (+N reqs por concurso — use limite):

resultado = ConcursoHub.search(estado: 'SP', limite: 2, with_provas: true)

resultado[:concursos].first[:provas]
# => [
#   {
#     cargo: "Analista Judiciário — Área Administrativa",
#     pdfs: [
#       { titulo: "Prova Objetiva — 2023", url: "https://..." },
#       { titulo: "Gabarito Preliminar — 2023", url: "https://..." }
#     ]
#   },
#   ...
# ]

Parâmetros disponíveis:

Parâmetro Tipo Padrão Descrição
estado `String \ nil` nil
nivel `String \ nil` nil
busca `String \ nil` nil
limite `Integer \ nil` nil
ano `String \ nil` nil
modo Symbol :abertos :abertos ou :encerrados
with_edital Boolean false Inclui edital com PDFs (+1 req/concurso)
with_provas Boolean false Inclui provas/gabaritos — implica with_edital

ConcursoHub.edital

Retorna o edital completo de um concurso a partir da URL.

edital = ConcursoHub.edital('https://pciconcursos.com.br/concurso/...')

edital[:titulo]          # => "Edital nº 1/2026"
edital[:data_publicacao] # => "15/04/2026"
edital[:descricao]       # => "..."
edital[:pdfs]            # => [{ titulo:, url: }, ...]
edital[:blocos]          # => [{ tipo: :secao|:paragrafo|:item, texto: }, ...]
edital[:url]             # => URL original

ConcursoHub.provas

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.

# url vem diretamente de search()
concurso_url = resultado[:concursos].first[:url]

provas = ConcursoHub.provas(concurso_url)

provas
# => [
#   {
#     cargo: "Analista Judiciário — Área Administrativa",
#     pdfs: [
#       { titulo: "Prova Objetiva — 2023", url: "https://..." },
#       { titulo: "Gabarito Preliminar — 2023", url: "https://..." }
#     ]
#   }
# ]

Exemplo completo em Rails

Gemfile

gem 'concurso_hub', path: '../concurso_test'
# ou após publicar: gem 'concurso_hub', '~> 1.0'

app/controllers/concursos_controller.rb

require 'concurso_hub'

class ConcursosController < ApplicationController
  # GET /concursos?estado=SP&nivel=Superior&limite=10
  def index
    resultado = ConcursoHub.search(
      estado: params[:estado],
      nivel:  params[:nivel],
      busca:  params[:busca],
      limite: params[:limite]&.to_i,
      ano:    params[:ano]
    )
    render json: resultado
  rescue => e
    render json: { error: e.message }, status: :bad_gateway
  end

  # GET /concursos/edital?url=https://pciconcursos.com.br/concurso/...
  def edital
    render json: ConcursoHub.edital(params[:url])
  rescue => e
    render json: { error: e.message }, status: :bad_gateway
  end

  # GET /concursos/provas?url=https://pciconcursos.com.br/concurso/...
  # (mesma url retornada pelo /concursos)
  def provas
    render json: ConcursoHub.provas(params[:url])
  rescue => e
    render json: { error: e.message }, status: :bad_gateway
  end
end

config/routes.rb

get '/concursos',        to: 'concursos#index'
get '/concursos/edital', to: 'concursos#edital'
get '/concursos/provas', to: 'concursos#provas'  # ?url= é a URL do concurso

Uso avançado (injeção de dependências)

Se precisar de mais controle — mock para testes, presenter customizado, troca de repositório — você pode instanciar os use cases diretamente.

Listar concursos

require 'concurso_hub'  # ou os requires individuais de src/

# Presenter que coleta os dados em vez de imprimir
class MeuPresenter < Application::Ports::Presenter
  attr_reader :concursos, :metadata

  def show(concursos, metadata: {}) = (@concursos = concursos) && (@metadata = )
  def error(msg)                    = raise msg
  def show_loading                  = nil
  def show_edital(_)                = nil
  def show_provas(_)                = nil
  def show_download_start(*)        = nil
  def show_download_done(_)         = nil
end

repository = Infrastructure::Repositories::PciConcursoRepository.new
presenter  = MeuPresenter.new

Application::UseCases::ListarConcursos.new(
  repository: repository,
  presenter:  presenter
).execute(
  Application::FiltrosConcurso.new(estado: 'SP', nivel: 'Superior', limite: 10, modo: :abertos)
)

presenter.concursos  # Array<Domain::Entities::Concurso>
presenter.   # Hash

Ver edital completo

class EditalPresenter < Application::Ports::Presenter
  attr_reader :edital

  def show_edital(edital) = @edital = edital
  def error(msg)          = raise msg
  def show(*)             = nil
  def show_loading        = nil
  def show_provas(_)      = nil
  def show_download_start(*) = nil
  def show_download_done(_)  = nil
end

presenter = EditalPresenter.new
Application::UseCases::VerEdital.new(
  repository: Infrastructure::Repositories::PciConcursoRepository.new,
  presenter:  presenter
).execute(Application::VerEditalRequest.new(url: 'https://pciconcursos.com.br/concurso/...'))

presenter.edital.titulo    # => String
presenter.edital.pdfs      # => [{ titulo:, url: }]
presenter.edital.provas_url # => String | nil

Baixar PDFs de um edital

require 'application/use_cases/baixar_edital'
require 'application/baixar_edital_request'
require 'infrastructure/http/http_file_downloader'

Application::UseCases::BaixarEdital.new(
  repository: Infrastructure::Repositories::PciConcursoRepository.new,
  downloader: Infrastructure::Http::HttpFileDownloader.new,
  presenter:  presenter  # qualquer presenter com show_download_start/done
).execute(
  Application::BaixarEditalRequest.new(
    url:      'https://pciconcursos.com.br/concurso/...',
    dest_dir: '/tmp/editais'
  )
)
# PDFs salvos em dest_dir

Baixar provas e gabaritos

require 'application/use_cases/baixar_provas'
require 'application/baixar_provas_request'

Application::UseCases::BaixarProvas.new(
  repository: Infrastructure::Repositories::PciConcursoRepository.new,
  downloader: Infrastructure::Http::HttpFileDownloader.new,
  presenter:  presenter
).execute(
  Application::BaixarProvasRequest.new(
    url:      'https://pciconcursos.com.br/concurso/...',  # mesma URL do concurso
    dest_dir: '/tmp/provas'
  )
)
# PDFs salvos em dest_dir organizados por cargo

Arquitetura

O projeto segue a arquitetura hexagonal (Ports & Adapters) com separação estrita de camadas:

┌─────────────────────────────────────────────────────┐
│  Apresentação (Adaptadores de entrada)              │
│  CLI: CliOptionsParser → CliController              │
│  API: Controller Rails/Sinatra/etc. (você implementa)│
│  TerminalPresenter  ←→  JsonPresenter (custom)      │
└──────────────────────┬──────────────────────────────┘
                       │ Request objects
┌──────────────────────▼──────────────────────────────┐
│  Aplicação (Núcleo — sem dependências de framework) │
│  Use Cases: ListarConcursos, VerEdital,             │
│             BaixarEdital, BaixarProvas              │
│  Ports (interfaces): ConcursoRepository,            │
│                      FileDownloader, Presenter      │
│  Value Objects: FiltrosConcurso, *Request           │
└──────────────────────┬──────────────────────────────┘
                       │ Entidades de domínio
┌──────────────────────▼──────────────────────────────┐
│  Domínio (Puro, sem dependências externas)          │
│  Entities: Concurso, Edital (imutáveis/frozen)      │
└──────────────────────┬──────────────────────────────┘
                       │ implementa ports
┌──────────────────────▼──────────────────────────────┐
│  Infraestrutura (Adaptadores de saída)              │
│  PciConcursoRepository (implementa ConcursoRepository)│
│  HttpClient, HttpFileDownloader                     │
│  PciHtmlParser (Nokogiri)                           │
└─────────────────────────────────────────────────────┘

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.


Entidades de Domínio

Domain::Entities::Concurso

Atributo Tipo Descrição
instituicao String Nome do órgão/instituição
estado String UF (ex: SP, RJ)
vagas String Número de vagas
salario String Faixa salarial
cargos String Cargos disponíveis
nivel String Nível de escolaridade exigido
prazo String Data-limite de inscrição
url String URL do edital no PCI Concursos

Domain::Entities::Edital

Atributo Tipo Descrição
titulo String Título do edital
descricao String Descrição resumida
data_publicacao String Data de publicação
blocos Array<Hash> Conteúdo estruturado: { tipo:, texto: }
pdfs Array<Hash> Lista de PDFs: { titulo:, url: }
provas_url `String \ nil`
url String URL canônica do edital

Os tipos de bloco em blocos são: :secao, :paragrafo, :item.


Filtros disponíveis

Application::FiltrosConcurso é um Struct com os seguintes campos:

Campo Tipo Descrição Exemplo
estado `String \ nil` Filtrar por UF
nivel `String \ nil` Nível de escolaridade
busca `String \ nil` Busca textual (obrigatório para :encerrados)
limite `Integer \ nil` Máximo de resultados retornados
modo Symbol :abertos (padrão) ou :encerrados :abertos
ano `String \ nil` Ano do prazo de inscrição

Customizando adaptadores

A arquitetura foi pensada para facilitar extensão. Você pode substituir qualquer adaptador sem tocar no núcleo.

Repositório mock para testes

require 'application/ports/concurso_repository'
require 'domain/entities/concurso'

class MockConcursoRepository < Application::Ports::ConcursoRepository
  def fetch_abertos
    concursos = [
      Domain::Entities::Concurso.new(
        instituicao: 'TRF-1',
        estado:      'DF',
        vagas:       '50',
        salario:     'R$ 8.000',
        cargos:      'Analista Judiciário',
        nivel:       'Superior',
        prazo:       '30/06/2026',
        url:         'https://example.com/concurso'
      )
    ]
    [concursos, { total_scraped: 1, modo: :abertos }]
  end

  # Implementar outros métodos conforme necessário para os testes...
end

# Injetar na ConcursoHub ou diretamente nos use cases
resultado = ConcursoHub.search(estado: 'DF')
# Para usar o mock, injete via uso direto dos use cases (seção anterior)

Dependências

Gem Versão Uso
nokogiri ~> 1.16 Parsing de HTML

Todo o restante utiliza apenas a biblioteca padrão do Ruby: net/http, uri, optparse.

Versão mínima de Ruby: 3.1 (usa pattern matching e hash shorthand syntax).


Contribuindo

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.