Buscador multicanal de vagas com rotação automática de proxies.
Raxy consulta LinkedIn, Gupy e Glassdoor em paralelo, rotaciona proxies automaticamente via bridges Xray/V2Ray, e aplica filtros inteligentes nas vagas encontradas — via CLI, SDK Python, API REST ou UI web.
pip install botasaurus dependency-injector beautifulsoup4 requests urllib3 rich prompt_toolkit PyYAML loguru redis
pip install /caminho/para/proxy_framework/
pip install -e .Requer Python 3.10+ e o binário do Xray (ou V2Ray) no PATH ou apontado por $XRAY_PATH.
docker build -t raxy .A imagem já empacota Python, dependências, Redis e o Xray-core. Use:
docker run --rm -it -v "$PWD/output:/app/output" raxy --keywords "Python" --portal linkedinnix-shell
python buscador_vagas/buscador.pyO shell.nix configura o ambiente Python, baixa dependências faltantes e detecta automaticamente o Xray ou V2Ray do nixpkgs.
# Buscar vagas de Python no LinkedIn (padrão)
python buscador_vagas/buscador.py --keywords "Python" --location "São Paulo"
# Resultados salvos em:
# output/linkedin/vagas.json — vagas básicas no schema canônico
# output/linkedin/detalhadas.json — vagas com descrição completaA API FastAPI expõe o SDK por HTTP e a UI web consome essa API em tempo real. O jeito mais simples de subir tudo é com Docker Compose:
docker compose up --buildServiços:
| Serviço | URL | Descrição |
|---|---|---|
| UI | http://localhost:3000 |
Interface web estática em ui/index.html |
| API | http://localhost:8000 |
API REST/SSE FastAPI |
| Docs | http://localhost:8000/docs |
Swagger/OpenAPI automático |
| Redis | localhost:6379 |
Pub/sub de eventos da busca |
Endpoints principais:
| Método | Rota | Uso |
|---|---|---|
GET |
/health |
Status da API |
POST |
/search |
Busca síncrona; responde só no fim |
POST |
/search/stream |
Busca com eventos SSE em tempo real |
GET |
/output/{portal}?kind=detalhadas |
Lê último JSON salvo (vagas ou detalhadas) |
Exemplo de busca pela API:
curl -X POST http://localhost:8000/search \
-H 'Content-Type: application/json' \
-d '{
"portal": "linkedin",
"keywords": "Python",
"location": "São Paulo",
"details_limit": 10,
"max_jobs": 20
}'Para rodar a API sem Docker Compose, mantenha Redis disponível e execute:
pip install -r api/requirements.txt
uvicorn api.main:app --host 0.0.0.0 --port 8000A UI aponta por padrão para http://localhost:8000 (API_BASE em ui/index.html). Se a API rodar em outro host/porta, ajuste esse valor.
Além da CLI, o Raxy oferece um SDK para uso diretamente em código Python. Importe JobFinder e obtenha os resultados como objetos tipados.
pip install -e . # ou pip install buscador_vagas/from buscador_vagas import JobFinder
finder = JobFinder(portal="linkedin", keywords="Python", location="São Paulo")
jobs = finder.search(max_jobs=180) # pagina ate 180 vagas; 0 = so primeira pagina
for job in jobs:
print(job.summary.title, job.summary.company)finder = JobFinder(
portal="linkedin",
keywords="Python",
filters="filters/python.json",
)
jobs = finder.search()from buscador_vagas import JobFinder, JobFilterSet
filtro = JobFilterSet.from_dict({
"all": [
{"fields": ["title"], "contains": "Python"},
{"fields": ["criteria.Tipo"], "in": ["Remoto", "Híbrido"]},
]
})
finder = JobFinder(filters=filtro)
jobs = finder.search()finder = JobFinder()
# só nesta busca aplica o filtro
jobs = finder.search(filters=filtro)
# próxima busca volta ao comportamento padrão (sem filtro)
jobs2 = finder.search()finder = JobFinder(
portal="gupy",
keywords="Data Science",
location="Rio de Janeiro",
proxy_provider="brazil", # proxies brasileiros
proxy_sources=["https://meu-proxy.txt"], # ou lista manual
valid_count=25, # pool de bridges
jobs_per_proxy=3, # rotaciona a cada 3 detalhes
max_jobs=120, # paginação: até 120 vagas
detail_threads=10, # 10 threads para detalhes
details_limit=20, # detalha só as 20 primeiras
timeout=15.0, # timeout do proxy
detail_timeout=8.0, # timeout do detalhe
silent=True, # sem output no terminal
)
jobs = finder.search(
jobs_output="resultados/vagas.json",
details_output="resultados/detalhadas.json",
max_jobs=180, # override por chamada
details_limit=40, # detalha ate 40; 0 = todas
)search() retorna list[JobPosting], onde cada JobPosting contém:
| Atributo | Tipo | Descrição |
|---|---|---|
summary |
JobSummary |
Dados básicos da vaga (título, empresa, local, URL) |
details |
JobDetails | None |
Descrição completa, critérios, texto de candidatura |
detail_error |
str |
Mensagem de erro se o detalhamento falhou |
Use job.to_dict() para serializar em dicionário (útil para JSON).
| Parâmetro | Padrão | Descrição |
|---|---|---|
portal |
"linkedin" |
linkedin, gupy ou glassdoor |
keywords |
"Vagas" |
Termo de busca |
location |
"Brasil" |
Localização |
location_id |
None |
ID da localização (pula typeahead) |
location_choice |
None |
Índice 1-based no typeahead |
work_type |
"normal" |
Modelo de trabalho, exclusivo do LinkedIn: normal, remote/remoto ou hybrid/hibrido/híbrido |
under_10_applicants |
False |
True para filtrar vagas do LinkedIn com menos de 10 candidaturas |
recent_period |
"any" |
Periodo de publicacao, exclusivo do LinkedIn: any, day/24h, week ou month |
proxy_sources |
None |
Lista de URLs/arquivos de proxy |
proxy_provider |
"united-states" |
brazil, united-states, canada, etc. |
valid_count |
25 |
Bridges no pool |
jobs_per_proxy |
5 |
Vagas por proxy antes de rotacionar |
max_count |
177 |
Máximo de configs de proxy a carregar |
threads |
8 |
Workers para testar proxies |
timeout |
12.0 |
Timeout do proxy (segundos) |
detail_timeout |
15.0 |
Timeout do detalhe (segundos) |
filters |
None |
JobFilterSet, caminho de arquivo, ou None |
filters_path |
None |
Caminho do arquivo de filtro (alternativo) |
details_limit |
0 |
Máx. de vagas para detalhar (0 = todas) |
start |
0 |
Offset da paginação |
max_jobs |
0 |
Máx. de vagas via paginação (0 = só 1ª página) |
detail_threads |
5 |
Threads paralelas para detalhes |
gd_cookie |
"" |
Cookie do Glassdoor |
silent |
True |
False para mostrar output no terminal |
view |
None |
Objeto view customizado (substitui SilentView quando fornecido) |
filter_by_keywords |
False |
True para filtrar vagas sem a keyword no titulo antes do detalhamento |
| Portal | Autenticação | Como obter |
|---|---|---|
| Pública (sem login) | — | |
| Gupy | Pública (REST API) | — |
| Glassdoor | Cookie de sessão (Opcional) | Faça login no Glassdoor pelo navegador, copie o cookie e passe com --gd-cookie |
# LinkedIn (padrão)
python buscador_vagas/buscador.py --keywords "Desenvolvedor"
# Gupy
python buscador_vagas/buscador.py --portal gupy --keywords "Python" --location "São Paulo"
# Glassdoor (requer cookie autenticado)
python buscador_vagas/buscador.py --portal glassdoor --gd-cookie "seu_cookie_aqui" --keywords "Python"Raxy descobre, testa e mantém um pool de bridges HTTP via Xray/V2Ray. Os proxies são rotacionados a cada N vagas detalhadas para evitar bloqueios.
# Pool de 25 bridges, troca de proxy a cada 5 detalhes
python buscador_vagas/buscador.py --valid-count 25 --jobs-per-proxy 5Os proxies testados são salvos em proxy_cache.json para reúso. Remova o arquivo para forçar um novo teste.
Colete centenas de vagas com paginação automática:
python buscador_vagas/buscador.py --keywords Desenvolvedor --max-jobs 180 --start 0Regras de filtragem em JSON. O motor suporta operadores lógicos (all, any, not) e operadores de campo (contains, not_contains, equals, not_equals, regex, in, exists).
python buscador_vagas/buscador.py --filters filtros/python.jsonAlém dos filtros JSON opcionais, a keyword da busca também é aplicada no resultado final: para --keywords "Python", a vaga só é salva se Python aparecer no título ou na descrição detalhada. O valor padrão Vagas é tratado como placeholder e não aplica esse corte.
Use --work-type para pedir vagas remotas ou híbridas somente no LinkedIn. O filtro é enviado para a busca pública com f_WT=2 para remoto e f_WT=3 para híbrido. Se --work-type diferente de normal for usado na Gupy ou no Glassdoor, a busca é recusada.
python buscador_vagas/buscador.py --portal linkedin --keywords Python --work-type remote
python buscador_vagas/buscador.py --portal linkedin --keywords Python --work-type híbridoAliases aceitos: remote/remoto, hybrid/hibrido/híbrido e normal.
Em todos os portais, os resultados salvos recebem o campo canônico modalidade com remoto, híbrido ou presencial. Quando o portal não entrega a modalidade diretamente, o Raxy tenta inferir a partir de campos como workplaceType, isRemoteWork, critérios da vaga e textos de localização/detalhes. Na Gupy, vagas sem cidade/local explícito são tratadas como REMOTO e recebem modalidade remota quando aplicável.
Use --under-10-applicants para filtrar vagas com menos de 10 candidaturas no LinkedIn. Sem essa flag, a busca normal é usada.
python buscador_vagas/buscador.py --portal linkedin
python buscador_vagas/buscador.py --portal linkedin --under-10-applicantsQuando --under-10-applicants é usado, o Raxy envia f_EA=true na URL do LinkedIn. Se esse filtro for usado na Gupy ou no Glassdoor, a busca é recusada.
Use --recent-period para filtrar vagas por periodo de publicacao no LinkedIn. Sem filtro, use any.
python buscador_vagas/buscador.py --portal linkedin --recent-period month
python buscador_vagas/buscador.py --portal linkedin --recent-period week
python buscador_vagas/buscador.py --portal linkedin --recent-period 24hO Raxy envia f_TPR=r2592000 para ultimo mes, f_TPR=r604800 para ultima semana e f_TPR=r86400 para ultimas 24h. No SDK, use JobFinder(recent_period="month"), "week" ou "day".
Durante o detalhamento, o campo criteria.requisitos é preenchido automaticamente a partir da descrição da vaga com keywords técnicas e comportamentais conhecidas, por exemplo python, sql, power_bi, cloud, docker, scrum, comunicacao, lideranca e testes.
Esse campo pode ser usado nos filtros:
{
"fields": ["criteria.requisitos"],
"contains": ["python", "sql"]
}python buscador_vagas/buscador.py --threads 8 --detail-threads 5Os filtros são arquivos JSON que você passa com --filters. Eles permitem incluir ou excluir vagas com base no título, descrição, localização, critérios e outros campos.
Cada filtro é um objeto JSON com operadores. O operador mais simples é o contains:
{
"fields": ["title", "description"],
"contains": "python"
}Isso mantém apenas vagas cujo título ou descrição contenham "python".
| Operador | O que faz | Exemplo |
|---|---|---|
contains |
O campo contém o texto (case insensitive) | "contains": "python" |
contains (lista) |
O campo contém qualquer item da lista | "contains": ["estagio", "estágio"] |
not_contains |
O campo não contém o texto | "not_contains": "trainee" |
equals |
O campo é exatamente igual ao valor | "equals": "Remoto" |
not_equals |
O campo é diferente do valor | "not_equals": "Presencial" |
regex |
O campo casa com uma expressão regular | `"regex": "python |
in |
O campo é igual a um dos valores da lista | "in": ["São Paulo", "SP"] |
exists |
O campo existe e não está vazio | "exists": true |
Exemplos de cada operador:
{ "fields": ["title"], "not_contains": "trainee" }
{ "fields": ["company"], "equals": "Google" }
{ "fields": ["location"], "regex": "(SP|RJ)" }
{ "fields": ["criteria.Tipo"], "in": ["Remoto", "Híbrido"] }
{ "fields": ["description"], "exists": true }Combine regras com all, any e not:
| Operador | Efeito |
|---|---|
all |
Todas as sub-regras precisam ser verdade (AND) |
any |
Pelo menos uma sub-regra precisa ser verdade (OR) |
not |
A sub-regra não pode ser verdade (NOT) |
all — todas as condições devem ser atendidas:
{
"all": [
{ "fields": ["title"], "contains": "senior" },
{ "fields": ["location"], "contains": "remoto" }
]
}Só passa se o título contiver "senior" E a localização contiver "remoto".
any — qualquer condição já aprova:
{
"any": [
{ "fields": ["title", "description"], "contains": "python" },
{ "fields": ["title", "description"], "contains": ["dados", "data"] }
]
}Passa se tiver "python" OU "dados"/"data".
not — nega a condição:
{
"not": {
"fields": ["title", "description"],
"contains": ["estagio", "estágio", "aprendiz"]
}
}Exclui vagas que contenham "estagio", "estágio" ou "aprendiz".
Você pode aninhar livremente:
{
"all": [
{ "fields": ["title"], "contains": "python" },
{
"not": {
"fields": ["title"],
"contains": "estágio"
}
},
{
"any": [
{ "fields": ["location"], "contains": "remoto" },
{ "fields": ["location"], "contains": "hibrido" }
]
}
]
}O filtro opera sobre o dicionário completo da vaga (básico + detalhado). Os campos mais comuns:
| Campo | Origem | Conteúdo |
|---|---|---|
title |
básico | Título da vaga |
company |
básico | Nome da empresa |
location |
básico | Localização |
modalidade |
básico/canônico | remoto, híbrido ou presencial |
description |
detalhado | Descrição completa |
criteria |
detalhado | Objeto com critérios (ex: {"Tipo": "Efetivo/CLT", "Nivel de experiencia": "N/A"}) |
criteria.Tipo |
detalhado | Acesso via dot notation, ex: tipo de contratação |
criteria.Nivel de experiencia |
detalhado | Nível hierárquico quando informado pelo portal |
criteria.requisitos |
detalhado | Lista de keywords inferidas da descrição |
listed_text |
básico | Texto de quando foi publicada |
Para acessar campos aninhados, use dot notation: criteria.Tipo, criteria.Nivel de experiencia.
- Execute uma busca primeiro para gerar
output/linkedin/detalhadas.json - Veja a estrutura dos campos no JSON gerado
- Crie um arquivo
.jsoncom as regras desejadas - Passe com
--filters
Exemplo passo a passo:
# 1. Busca para gerar os JSONs de saída
python buscador_vagas/buscador.py --keywords "Python" --details-limit 5
# 2. Olhe o arquivo gerado para conhecer os campos
python -m json.tool output/linkedin/detalhadas.json
# 3. Crie seu filtro, por exemplo meu_filtro.json:
# {
# "all": [
# { "fields": ["title"], "contains": "senior" },
# { "fields": ["criteria.Tipo"], "in": ["Remoto", "Híbrido"] }
# ]
# }
# 4. Rode com o filtro
python buscador_vagas/buscador.py --keywords "Python" --filters meu_filtro.json| Arquivo | Efeito |
|---|---|
buscador_vagas/filters/python.json |
Apenas vagas com "python" no título ou descrição |
buscador_vagas/filters/python_or_data.json |
Python OU dados/data/analytics |
buscador_vagas/filters/senior_remote.json |
Senior E remoto |
buscador_vagas/filters/not_internship.json |
Exclui estágio/aprendiz |
Portal de vagas a consultar. Padrão: linkedin.
--portal linkedin LinkedIn (padrão, sem autenticação)
--portal gupy Gupy (API pública REST)
--portal glassdoor Glassdoor (requer --gd-cookie)
Termo de busca. Use aspas para múltiplas palavras. Depois do detalhamento, o Raxy mantém apenas vagas em que esse termo aparece no título ou na descrição; Vagas, o padrão, não aplica esse filtro final.
--keywords "Python"
--keywords "Desenvolvedor Java Sênior"
Controlam o local da busca. O Raxy consulta o typeahead do portal e mostra as opções disponíveis.
--location "São Paulo" # texto da busca
--location "São Paulo" --location-choice 1 # escolhe automaticamente o 1º resultado
--location-id 106057199 # pula o typeahead, usa o ID direto
--geo-id 106057199 # alias do --location-id (LinkedIn)
Fonte de proxies do repositório F0rc3Run/splitted-by-country. Padrão: united-states.
--provider brazil
--provider united-states
Modelo de trabalho desejado, exclusivo do LinkedIn. Padrão: normal.
--work-type normal # não aplica filtro de modalidade no LinkedIn
--work-type remote # remoto; aceita também remoto
--work-type hybrid # híbrido; aceita também hibrido/híbrido
Filtro booleano de candidaturas, exclusivo do LinkedIn. Por padrão fica desligado.
--under-10-applicants # adiciona f_EA=true
Periodo de publicacao, exclusivo do LinkedIn. Padrão: any.
--recent-period any # não aplica filtro de data
--recent-period month # ultimo mes; f_TPR=r2592000
--recent-period week # ultima semana; f_TPR=r604800
--recent-period day # ultimas 24h; aceita também 24h
URL ou caminho local para uma lista manual de proxies. Pode repetir a flag para múltiplas fontes.
--proxy-source "https://exemplo.com/proxies.txt"
--proxy-source "./meus_proxies.txt" --proxy-source "https://outra-fonte.com/proxies.txt"
Número alvo de bridges HTTP a manter no pool. O Raxy testa proxies até atingir esse número. Se encontrar menos, segue com os disponíveis.
--valid-count 25 # padrão
--valid-count 50 # pool maior = mais resiliência
Quantos detalhes de vaga cada proxy processa antes de rotacionar para o próximo. Útil para evitar bloqueio por excesso de requisições no mesmo IP.
--jobs-per-proxy 5 # troca de proxy a cada 5 detalhes (padrão)
--jobs-per-proxy 1 # troca a cada vaga (máximo de discrição)
--jobs-per-proxy 20 # troca só a cada 20 detalhes
Número máximo de proxies a testar. Corta o teste cedo se você já tiver proxies suficientes.
--max-count 10 # testa no máximo 10 proxies
Paginação. --max-jobs define o total de vagas a coletar; --start define o offset inicial. A cada página o offset incrementa em 60.
--max-jobs 180 --start 0 # coleta 180 vagas do início
--max-jobs 300 --start 60 # começa do offset 60, coleta até 300
Timeouts em segundos. --timeout para o teste inicial de conectividade do proxy; --detail-timeout para o request de detalhe da vaga.
--timeout 12 # 12s para testar cada proxy (padrão)
--detail-timeout 5 # 5s para cada detalhe de vaga (padrão)
Paralelismo. --threads para testar proxies simultaneamente; --detail-threads para buscar detalhes de vagas em paralelo.
--threads 8 # 8 workers testam proxies por vez (padrão)
--detail-threads 5 # 5 threads buscam detalhes em paralelo (padrão)
Limita quantas vagas serão detalhadas. Use 0 para detalhar todas.
--details-limit 10 # detalha só as 10 primeiras vagas
--details-limit 0 # detalha todas as vagas encontradas
Caminhos dos arquivos de saída.
--jobs-output output/linkedin/vagas.json # vagas básicas
--details-output output/linkedin/detalhadas.json # vagas com descrição
Quantas vagas exibir na tabela do terminal ao final.
--show-jobs 20 # mostra 20 vagas na tabela
--show-jobs 50 # mostra 50 vagas na tabela
Arquivo JSON com regras de filtragem. O motor suporta operadores all, any, not, contains, not_contains, equals, not_equals, regex, in, exists e dot notation para campos aninhados.
--filters filters/python.json
--filters filters/senior_remote.json
Ativa filtro restritivo por keyword no título e descrição. Sem essa flag, a keyword da busca é usada apenas para filtrar o resultado final (vagas sem a keyword no título ou descrição são descartadas). Com --filter-by-keywords, o filtro é aplicado durante a coleta — vagas que não contêm a keyword no título são ignoradas antes mesmo do detalhamento.
--filter-by-keywords # ativa filtro restritivo
Cookie de sessão do Glassdoor (obrigatório para o portal Glassdoor). Faça login no Glassdoor pelo navegador, abra o DevTools (F12) > Network, copie o header Cookie de qualquer requisição.
--gd-cookie "session_id=abc123; ..."
Os arquivos JSON são salvos em um schema canônico estável. Campos internos específicos de cada portal ficam em provider_data durante a execução, mas não são persistidos por padrão.
Vagas básicas: provider, job_id, title, company, location, listed_at, listed_text, url, company_url, logo_url, modalidade
listed_at é salvo como Unix timestamp em segundos quando o portal fornece uma data absoluta. Campos textuais, como listed_text e detail_posted_text, continuam preservando o texto original do portal.
Vagas detalhadas (adicional): detail_title, detail_company, detail_company_url, detail_location, detail_posted_text, detail_applicants_text, description, criteria, apply_text, detail_url, detail_logo_url, detail_status_code, detail_html_size, detail_bridge_index
criteria.requisitos contém a lista de requisitos/keywords inferidos da descrição quando o detalhamento encontra esses termos.
Raxy também oferece uma interface TUI interativa com prompt_toolkit:
# Abre a interface gráfica no terminal
python buscador_vagas/buscador.pyA TUI exige um config.yaml no diretório atual antes de abrir. Configurações técnicas, como provider de proxy, limites, timeouts, caminhos de saída e Redis, vêm desse arquivo; o termo de busca não deve ser configurado em defaults.keywords, porque ele é informado a cada busca.
A TUI guia a busca em etapas com seletores para valores fechados, como portal, modalidade do LinkedIn e periodo de vagas recentes, e campos textuais apenas onde há entrada livre. O status em tempo real é exibido via eventos Redis/pub-sub durante proxy, busca, detalhes e salvamento.
Para usar com Redis, as variáveis RAXY_REDIS_URL e RAXY_REDIS_CHANNEL são repassadas automaticamente ao container ao iniciar a busca pela TUI.
O Raxy publica eventos em tempo real via Redis pub/sub durante todo o ciclo de busca.
| Canal | Conteúdo | Quando |
|---|---|---|
raxy:events |
Evento JSON: {name, message, level, payload, occurred_at} |
Todo ciclo de vida |
vagas:disponiveis |
{portal, total_vagas, vagas: [...]} |
Ao finalizar a busca |
import redis, json
r = redis.Redis()
pubsub = r.pubsub()
pubsub.subscribe("vagas:disponiveis")
for msg in pubsub.listen():
if msg["type"] == "message":
data = json.loads(msg["data"])
print(f"{data['portal']}: {data['total_vagas']} vagas")redis:
url: "redis://localhost:6379/0"
channel: "raxy:events"| Env Var | Sobrepoe |
|---|---|
RAXY_REDIS_URL |
redis.url |
RAXY_REDIS_CHANNEL |
redis.channel |
RAXY_REDIS_DATA_CHANNEL |
redis.data_channel |
RAXY_LOG_LEVEL |
logging.level |
XRAY_PATH |
Caminho do binário Xray |
O Raxy baixa listas de proxies de URLs públicas (organizadas por país), testa conectividade em paralelo, e inicia bridges Xray locais (http://127.0.0.1:54xxx).
- Rotação: a cada N vagas detalhadas troca de bridge (round-robin)
- Fallback: se uma bridge falha, a próxima disponível assume
- Cache: resultados de teste salvos em
proxy_cache.json
Provedores disponíveis: united-states, brazil, canada, germany, netherlands, all (40+ países).
nix-shell --run 'python -m pytest'Estrutura: tests/domain/, tests/application/, tests/infrastructure/, tests/modules/, tests/tui/, tests/view/.
Arquitetura hexagonal (Ports & Adapters):
+---------+ TUI +---------+
| CLI | | SDK |
+----+----+ +----+----+
| |
+--------+--------+--------+
| |
JobSearchService |
| |
+--------+--------+ |
| | | |
[Ports] [Domínio] [Eventos] |
| | | |
LinkedIn Gupy Glassdoor |
| | |
BotasaurusHttpClient Redis |
| | |
ProxyFrameworkPool -----+ |
| |
[Xray bridges] [JSON files]
| Diretório | Camada |
|---|---|
job_search/domain/ |
Entidades de negócio |
job_search/application/ |
Orquestração, DTOs, Ports, Eventos |
job_search/infrastructure/ |
HTTP, Redis, JSON, Proxy, Config |
job_search/interfaces/ |
CLI, TUI, Console |
job_search/providers/ |
Adaptadores dos portais |
sdk/ |
API programática |
api/ |
API REST/SSE FastAPI |
ui/ |
UI web estática |
Entrypoints: buscador_vagas/buscador.py → launcher.py → CLI (se args) ou TUI; api.main:app → API REST/SSE; ui/index.html → UI web.