Stf / app.py
caarleexx's picture
Update app.py
b2d55e2 verified
import os
import sys
import json
import time
import logging
import requests
import urllib3
from flask import Flask, request, jsonify, render_template_string, url_for
from playwright.sync_api import sync_playwright
# Suprimir warnings de SSL
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuração de logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Constantes da API STF
STF_API_URL = "https://jurisprudencia.stf.jus.br/api/search/search"
# Cache de token
token_cache = {"token": None, "expires_at": 0}
HEADERS = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://jurisprudencia.stf.jus.br/pages/search",
"Origin": "https://jurisprudencia.stf.jus.br"
}
# ============================================
# Funções de token e busca
# ============================================
def get_fresh_token(force_refresh=False):
global token_cache
if not force_refresh and token_cache["token"] and time.time() < token_cache["expires_at"]:
logger.info("Usando token em cache")
return token_cache["token"]
logger.info("Obtendo novo token via Playwright" + (" (forçado)" if force_refresh else ""))
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=['--no-sandbox'])
context = browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
page = context.new_page()
page.goto("https://jurisprudencia.stf.jus.br/pages/search", wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(3000)
cookies = context.cookies()
token = None
for cookie in cookies:
if cookie.get('name') == 'aws-waf-token':
token = cookie.get('value')
break
browser.close()
if token:
token_cache["token"] = token
token_cache["expires_at"] = time.time() + 600
logger.info(f"Token obtido: {token[:30]}...")
return token
else:
logger.warning("Token não encontrado nos cookies")
return None
except Exception as e:
logger.error(f"Erro ao obter token: {str(e)}")
return None
def search_stf(query: str, bases: list, page: int = 1, page_size: int = 20):
token = get_fresh_token()
if not token:
return {"error": "Não foi possível obter token de acesso"}, 503
from_idx = (page - 1) * page_size
should_filters = []
for base in bases:
should_filters.append({"term": {"base": base}})
payload = {
"query": {
"bool": {
"filter": [{"bool": {"should": should_filters, "minimum_should_match": 1}}],
"must": [{"bool": {"should": [
{"query_string": {"fields": ["ementa_texto"], "query": query, "default_operator": "AND", "fuzziness": "AUTO:4,7"}},
{"query_string": {"fields": ["decisao_texto"], "query": query, "default_operator": "AND", "fuzziness": "AUTO:4,7"}},
{"query_string": {"fields": ["acordao_ata"], "query": query, "default_operator": "AND", "fuzziness": "AUTO:4,7"}}
]}}]
}
},
"_source": ["id", "titulo", "ementa_texto", "decisao_texto", "acordao_ata",
"processo_codigo_completo", "relator_processo_nome",
"orgao_julgador", "julgamento_data", "publicacao_data", "inteiro_teor_url", "base"],
"size": page_size,
"from": from_idx,
"sort": [{"julgamento_data": {"order": "desc"}}],
"track_total_hits": True
}
headers = HEADERS.copy()
headers['Cookie'] = f'aws-waf-token={token}'
try:
response = requests.post(STF_API_URL, headers=headers, json=payload, verify=False, timeout=30)
if response.status_code == 200:
return response.json()
elif response.status_code == 202:
token = get_fresh_token(force_refresh=True)
if token:
headers['Cookie'] = f'aws-waf-token={token}'
response = requests.post(STF_API_URL, headers=headers, json=payload, verify=False, timeout=30)
if response.status_code == 200:
return response.json()
return {"error": "Token expirado e renovação falhou"}, 503
else:
return {"error": f"API retornou status {response.status_code}"}, response.status_code
except Exception as e:
return {"error": str(e)}, 502
# ============================================
# CSS compartilhado
# ============================================
SHARED_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300&family=DM+Sans:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #0e1018;
--bg-1: #13161f;
--bg-2: #191d28;
--bg-3: #1f2435;
--gold: #c9a84c;
--gold-lt: #e8cf82;
--gold-dim: #7a6030;
--gold-glow: rgba(201,168,76,.12);
--gold-glow2: rgba(201,168,76,.05);
--gold-bd: rgba(201,168,76,.22);
--blue: #3d7de8;
--blue-dim: rgba(61,125,232,.13);
--blue-bd: rgba(61,125,232,.30);
--purple: #7c5cbf;
--purple-dim: rgba(124,92,191,.13);
--purple-bd: rgba(124,92,191,.30);
--tx: #e6e8ee;
--tx-2: #8e91a8;
--tx-3: #484c62;
--bd: rgba(255,255,255,.07);
--bd-2: rgba(255,255,255,.11);
--red-dim: rgba(239,68,68,.12);
--red-bd: rgba(239,68,68,.28);
--serif: 'Cormorant Garamond', Georgia, serif;
--sans: 'DM Sans', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
font-family: var(--sans);
background: var(--bg);
color: var(--tx);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
line-height: 1.6;
overflow-x: hidden;
width: 100%;
}
/* Grain */
body::after {
content: '';
position: fixed; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
pointer-events: none; z-index: 9999; opacity: .5;
}
::selection { background: var(--gold-glow); color: var(--tx); }
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-thumb { background: var(--gold-dim); border-radius: 3px; }
/* ── TOPBAR ── */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--bd);
position: sticky; top: 0; z-index: 100;
background: rgba(14,16,24,.92);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
width: 100%; box-sizing: border-box;
}
.brand { display: flex; align-items: center; gap: 14px; text-decoration: none; }
.brand-logo { height: 52px; width: auto; object-fit: contain; }
.brand-sep { width: 1px; height: 20px; background: var(--bd-2); }
.brand-label {
font-family: var(--mono);
font-size: .65rem;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
}
.topbar-right { display: flex; align-items: center; gap: 14px; }
.status-dot {
display: flex; align-items: center; gap: 6px;
font-family: var(--mono); font-size: .68rem;
color: var(--tx-3); letter-spacing: .06em;
}
.status-dot::before {
content: ''; width: 6px; height: 6px; border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 6px rgba(74,222,128,.6);
}
/* ── SHELL ── */
.shell { max-width: 1060px; margin: 0 auto; padding: 0 16px 80px; width: 100%; box-sizing: border-box; }
/* ── HERO ── */
.hero { padding: 56px 0 44px; text-align: center; position: relative; align-items: center;}
.hero-glow {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 600px; height: 300px;
background: radial-gradient(ellipse at center top, rgba(201,168,76,.08) 0%, transparent 70%);
pointer-events: none;
}
.hero-logo-wrap { margin-bottom: 28px; margin-top: 82px; }
.hero-logo { height: 186px; width: auto; object-fit: contain; filter: drop-shadow(0 0 24px rgba(201,168,76,.15)); }
.hero-sub {
font-size: .8rem;
font-family: var(--mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--tx-3);
margin-bottom: 10px;
}
.hero-title {
font-family: var(--serif);
font-size: clamp(1.8rem, 4vw, 3rem);
font-weight: 300;
color: var(--tx);
line-height: 1.15;
margin-bottom: 10px;
}
.hero-title em { font-style: italic; color: var(--gold-lt); }
/* ── SEARCH AREA ── */
.search-wrap {
margin-top: 36px;
display: flex;
flex-direction: column;
align-items: center; /* centraliza os filhos horizontalmente */
width: 100%;
}
/* Filtros de base — linha simples e centralizada */
.bases-line {
display: flex;
align-items: center;
justify-content: center; /* centraliza o conteúdo interno */
gap: 20px;
margin-bottom: 14px;
font-size: .8rem;
color: var(--tx-3);
width: 100%; /* ocupa toda a largura para que o centro seja relativo à tela */
}
.base-check {
display: flex; align-items: center; gap: 7px;
cursor: pointer; user-select: none;
color: var(--tx-2);
transition: color .2s;
}
.base-check:hover { color: var(--tx); }
.base-check input { display: none; }
.check-box {
width: 15px; height: 15px;
border: 1px solid var(--bd-2); border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: all .2s; flex-shrink: 0;
}
.base-check input:checked + .check-box { border-color: var(--gold-dim); background: var(--gold-glow); }
.base-check input:checked + .check-box::after {
content: ''; width: 6px; height: 6px; border-radius: 2px; background: var(--gold);
}
.base-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.dot-ac { background: var(--blue); }
.dot-de { background: var(--purple); }
/* Input row */
.search-row {
display: flex; gap: 10px; align-items: center;
background: var(--bg-1);
border: 1px solid var(--bd-2);
border-radius: 14px;
padding: 6px 12px 6px 16px;
transition: border-color .25s, box-shadow .25s;
width: 100%; box-sizing: border-box;
}
.search-row:focus-within {
border-color: var(--gold-dim);
box-shadow: 0 0 0 3px var(--gold-glow), 0 8px 32px rgba(0,0,0,.3);
}
.search-input {
flex: 1; background: transparent; border: none; outline: none;
font-family: var(--sans); font-size: .95rem; color: var(--tx);
padding: 10px 0; min-width: 0;
}
.search-input::placeholder { color: var(--tx-3); }
.search-btn {
background: linear-gradient(135deg, #6b4f1a, var(--gold));
border: none; border-radius: 10px;
padding: 11px 26px;
font-family: var(--sans); font-size: .85rem; font-weight: 500;
color: #0e1018; letter-spacing: .04em;
cursor: pointer; white-space: nowrap;
display: flex; align-items: center; gap: 8px;
transition: opacity .2s, transform .15s;
flex-shrink: 0;
}
.search-btn:hover { opacity: .88; transform: translateY(-1px); }
.search-btn:disabled { opacity: .35; cursor: not-allowed; transform: none; }
/* ── STATS CARDS ── */
.stats-row {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
margin: 32px 0 0; display: none;
}
.stat-card {
background: var(--bg-1); border: 1px solid var(--bd);
border-radius: 14px; padding: 20px 22px; text-align: center;
position: relative; overflow: hidden;
transition: border-color .2s;
}
.stat-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
opacity: .5;
}
.stat-card:hover { border-color: var(--gold-bd); }
.stat-val {
font-family: var(--serif); font-size: 2rem; font-weight: 300;
color: var(--gold-lt); line-height: 1; margin-bottom: 4px;
}
.stat-label {
font-family: var(--mono); font-size: .63rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--tx-3);
}
/* ── DIVIDER ── */
.section-divider {
display: none; align-items: center; gap: 14px; margin: 28px 0 20px;
}
.div-line { flex: 1; height: 1px; background: var(--bd); }
.div-label {
font-family: var(--mono); font-size: .65rem; letter-spacing: .14em;
text-transform: uppercase; color: var(--tx-3); white-space: nowrap;
}
/* ── RESULT CARDS ── */
.result-card {
background: var(--bg-1); border: 1px solid var(--bd);
border-radius: 16px; padding: 22px 20px;
margin-bottom: 12px; position: relative; overflow: hidden;
transition: border-color .22s, box-shadow .22s, transform .15s;
animation: riseUp .3s ease both;
width: 100%; box-sizing: border-box;
}
@keyframes riseUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Accent linha esquerda — neutro */
.result-card::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
background: rgba(255,255,255,.08);
transition: opacity .2s;
}
.result-card:hover::before { background: var(--gold-dim); }
/* Corner circuit decoration */
.result-card::after {
content: '';
position: absolute; right: 0; top: 0;
width: 80px; height: 80px;
background: radial-gradient(circle at top right, rgba(255,255,255,.025) 0%, transparent 70%);
pointer-events: none;
}
.result-card:hover {
border-color: var(--gold-bd);
box-shadow: 0 0 0 1px var(--gold-bd), 0 12px 40px rgba(0,0,0,.35);
transform: translateY(-1px);
}
/* Card header */
.card-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 14px; margin-bottom: 14px;
}
.card-title {
font-family: var(--serif); font-size: 1.08rem; font-weight: 400;
color: var(--tx); line-height: 1.35; flex: 1;
}
.card-num {
font-family: var(--mono); font-size: .62rem; color: var(--tx-3);
flex-shrink: 0; margin-top: 3px;
}
/* Tipo tag */
.tipo-tag {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--mono); font-size: .58rem; letter-spacing: .05em;
padding: 3px 8px; border-radius: 20px; font-weight: 500;
flex-shrink: 0; margin-top: 2px; white-space: nowrap;
}
.tipo-tag.acordaos { background: var(--blue-dim); color: #7ab4ff; border: 1px solid var(--blue-bd); }
.tipo-tag.decisoes { background: var(--purple-dim); color: #b39dff; border: 1px solid var(--purple-bd); }
.tipo-tag-dot { width: 5px; height: 5px; border-radius: 50%; }
.tipo-tag.acordaos .tipo-tag-dot { background: var(--blue); }
.tipo-tag.decisoes .tipo-tag-dot { background: var(--purple); }
/* Meta pills */
.meta-row {
display: flex; flex-wrap: wrap; gap: 6px;
margin-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px solid var(--bd);
}
.meta-pill {
font-size: .70rem; color: var(--tx-2);
background: var(--bg-2); border: 1px solid var(--bd);
border-radius: 20px; padding: 3px 10px;
display: flex; align-items: center; gap: 4px;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.meta-pill-icon { opacity: .55; font-size: .75rem; }
/* Field blocks */
.field-block {
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.07);
border-left: 2px solid rgba(255,255,255,.12);
}
.field-block.acordao { border-left-color: var(--gold-dim); }
.field-label {
font-family: var(--mono); font-size: .60rem; letter-spacing: .14em;
text-transform: uppercase; font-weight: 500;
margin-bottom: 7px; display: block;
color: var(--tx-3);
}
.field-block.acordao .field-label { color: var(--gold-dim); }
.field-text {
font-size: .84rem; color: var(--tx-2); line-height: 1.7;
white-space: pre-wrap; max-height: 200px; overflow-y: auto;
}
.field-text::-webkit-scrollbar { width: 2px; }
.field-text::-webkit-scrollbar-thumb { background: var(--bd-2); }
/* Card footer */
.card-foot { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.btn-ghost {
font-family: var(--sans); font-size: .77rem; color: var(--tx-2);
background: var(--bg-2); border: 1px solid var(--bd);
border-radius: 20px; padding: 7px 16px;
text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
transition: color .2s, border-color .2s; cursor: pointer;
}
.btn-ghost:hover { color: var(--tx); border-color: var(--bd-2); }
.btn-gold {
font-family: var(--sans); font-size: .77rem; color: var(--gold);
background: var(--gold-glow); border: 1px solid var(--gold-bd);
border-radius: 20px; padding: 7px 16px;
text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
transition: background .2s;
}
.btn-gold:hover { background: rgba(201,168,76,.18); }
/* ── LOADER ── */
#loader { display: none; text-align: center; padding: 60px 0; }
.spinner {
width: 32px; height: 32px; margin: 0 auto 14px;
border: 2px solid var(--bd); border-top-color: var(--gold);
border-radius: 50%; animation: spin .75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loader-txt { font-family: var(--mono); font-size: .72rem; color: var(--tx-3); letter-spacing: .1em; }
/* ── ERROR ── */
.err-box {
display: none; background: var(--red-dim); border: 1px solid var(--red-bd);
border-radius: 12px; padding: 16px 20px; font-size: .88rem;
color: #fca5a5; margin-top: 16px;
}
/* ── EMPTY ── */
.empty-box { display: none; text-align: center; padding: 70px 0; color: var(--tx-3); }
.empty-icon { font-size: 2rem; margin-bottom: 10px; opacity: .4; }
/* ── PAGINATION ── */
.pag-wrap {
display: none; flex-wrap: wrap; align-items: center;
justify-content: center; gap: 6px; margin-top: 36px;
}
.pag-btn {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--bd); background: transparent;
color: var(--tx-2); cursor: pointer; font-size: .83rem;
display: flex; align-items: center; justify-content: center;
transition: all .18s; font-family: var(--sans);
}
.pag-btn:hover:not(:disabled) { border-color: var(--gold-bd); color: var(--gold); }
.pag-btn.active { background: var(--gold-glow); border-color: var(--gold-bd); color: var(--gold); }
.pag-btn:disabled { opacity: .25; cursor: default; }
.pag-info { font-family: var(--mono); font-size: .68rem; color: var(--tx-3); padding: 0 10px; letter-spacing: .06em; }
/* ── FOOTER ── */
.footer {
border-top: 1px solid var(--bd); margin-top: 70px; padding-top: 24px;
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;
}
.footer-brand { font-family: var(--serif); font-size: .9rem; color: var(--tx-3); font-style: italic; }
.footer-brand span { color: var(--gold); }
.footer-note { font-family: var(--mono); font-size: .63rem; color: var(--tx-3); letter-spacing: .04em; }
/* ── DOC PAGE SPECIFICS ── */
.back-link {
display: inline-flex; align-items: center; gap: 7px;
font-size: .8rem; color: var(--tx-2); text-decoration: none;
border: 1px solid var(--bd); border-radius: 20px; padding: 7px 16px;
transition: color .2s, border-color .2s;
}
.back-link:hover { color: var(--tx); border-color: var(--bd-2); }
.doc-header-card {
background: var(--bg-1); border: 1px solid var(--bd);
border-radius: 16px; padding: 28px 30px; margin: 28px 0 20px;
position: relative; overflow: hidden;
}
.doc-header-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
opacity: .6;
}
.doc-title {
font-family: var(--serif); font-size: 1.5rem; font-weight: 400;
color: var(--tx); margin-bottom: 18px; line-height: 1.3;
}
.doc-field {
border-radius: 12px; padding: 20px 22px; margin-bottom: 14px;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.07);
border-left: 2px solid rgba(255,255,255,.12);
}
.doc-field.acordao { border-left-color: var(--gold-dim); }
.doc-field-label {
font-family: var(--mono); font-size: .62rem; letter-spacing: .14em;
text-transform: uppercase; font-weight: 500;
margin-bottom: 12px; display: block;
color: var(--tx-3);
}
.doc-field.acordao .doc-field-label { color: var(--gold-dim); }
.doc-field-text {
font-size: .9rem; color: var(--tx-2); line-height: 1.78;
white-space: pre-wrap;
}
.inteiro-teor-link {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--sans); font-size: .85rem;
color: var(--gold); text-decoration: none;
border: 1px solid var(--gold-bd); border-radius: 10px;
padding: 12px 22px; margin-top: 16px;
background: var(--gold-glow); transition: background .2s;
}
.inteiro-teor-link:hover { background: rgba(201,168,76,.18); }
/* SVG icon inline helper */
.ico { display: inline-block; vertical-align: middle; }
"""
# ============================================
# Rota principal (HTML)
# ============================================
@app.route('/')
def index():
return render_template_string("""<!DOCTYPE html>
<html lang="pt-BR">
<head>
<title>PARA AI — Jurisprudência STF</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>""" + SHARED_CSS + """</style>
</head>
<body>
<!-- TOPBAR -->
<nav class="topbar">
<a href="/" class="brand">
<span class="brand-label">STF | TJPR</span>
</a>
<div class="topbar-right">
<span class="status-dot">online</span>
</div>
</nav>
<div class="shell">
<!-- HERO -->
<section class="hero">
<div class="hero-glow"></div>
<div class="hero-logo-wrap">
<img src="https://huggingface.co/spaces/caarleexx/paraAI_CHATBOT/resolve/main/public/logo.png"
alt="PARA AI" class="hero-logo"
onerror="this.style.display='none'">
</div>
<h1 class="hero-title">Busque acórdãos e decisões<br></h1>
<!-- SEARCH -->
<div class="search-wrap">
<!-- Bases: linha discreta acima do input -->
<div class="bases-line">
<span>Bases:</span>
<label class="base-check">
<input type="checkbox" id="cbAcordaos" checked>
<span class="check-box"></span>
<span class="base-dot dot-ac"></span>
Acórdãos
</label>
<label class="base-check">
<input type="checkbox" id="cbDecisoes" checked>
<span class="check-box"></span>
<span class="base-dot dot-de"></span>
Decisões Monocráticas
</label>
</div>
<!-- Input + botão -->
<div class="search-row">
<input type="text" id="query" class="search-input"
placeholder="Ex.: habeas corpus tráfico, dano moral, ADI…"
autocomplete="off" autofocus>
<button id="searchBtn" class="search-btn" onclick="doSearch(1)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
Pesquisar
</button>
</div>
</div>
</section>
<!-- LOADER -->
<div id="loader">
<div class="spinner"></div>
<div class="loader-txt">consultando o STF…</div>
</div>
<!-- ERROR -->
<div class="err-box" id="errBox"></div>
<!-- STATS -->
<div class="stats-row" id="statsRow">
<div class="stat-card">
<div class="stat-val" id="statTotal">—</div>
<div class="stat-label">Total encontrado</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statAcordaos">—</div>
<div class="stat-label">Acórdãos</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statDecisoes">—</div>
<div class="stat-label">Decisões</div>
</div>
</div>
<!-- DIVIDER -->
<div class="section-divider" id="secDivider">
<div class="div-line"></div>
<span class="div-label" id="divLabel">Resultados</span>
<div class="div-line"></div>
</div>
<!-- EMPTY -->
<div class="empty-box" id="emptyBox">
<div class="empty-icon">⚖</div>
<p>Nenhum resultado para esta busca.</p>
</div>
<!-- RESULTS -->
<div id="results"></div>
<!-- PAGINATION -->
<div class="pag-wrap" id="pagWrap"></div>
<!-- FOOTER -->
<footer class="footer">
<span class="footer-brand">PARA<span>AI</span></span>
<span class="footer-note">CONVERGENT LAW TECHNOLOGIES</span>
</footer>
</div><!-- /shell -->
<script>
let curPage = 1, curQuery = '', totalRes = 0;
const PS = 10;
document.getElementById('query').addEventListener('keydown', e => {
if (e.key === 'Enter') doSearch(1);
});
function fmtNum(n) { return n >= 1000 ? (n/1000).toFixed(1)+'k' : String(n); }
function fmtDate(d) {
if (!d) return null;
const m = String(d).match(/(\\d{4})-(\\d{2})-(\\d{2})/);
return m ? m[3]+'/'+m[2]+'/'+m[1] : String(d).substring(0,10);
}
function trunc(t, max=420) {
if (!t) return '';
return t.length > max ? t.substring(0, max) + '…' : t;
}
function setVisible(id, val) {
const el = document.getElementById(id);
if (el) el.style.display = val;
}
async function doSearch(page) {
const q = document.getElementById('query').value.trim();
if (!q) { document.getElementById('query').focus(); return; }
const ac = document.getElementById('cbAcordaos').checked;
const de = document.getElementById('cbDecisoes').checked;
if (!ac && !de) { showErr('Selecione ao menos uma base.'); return; }
curQuery = q; curPage = page;
document.getElementById('results').innerHTML = '';
setVisible('loader', 'block');
setVisible('errBox', 'none');
setVisible('emptyBox', 'none');
document.getElementById('statsRow').style.display = 'none';
document.getElementById('secDivider').style.display = 'none';
document.getElementById('pagWrap').style.display = 'none';
document.getElementById('searchBtn').disabled = true;
try {
const params = new URLSearchParams({q, acordaos: ac, decisoes: de, page, page_size: PS});
const res = await fetch('/api/busca-multipla?' + params);
const data = await res.json();
setVisible('loader', 'none');
document.getElementById('searchBtn').disabled = false;
if (!res.ok || data.error) throw new Error(data.error || 'Erro ' + res.status);
if (!data.resultados || !data.resultados.length) {
setVisible('emptyBox', 'block'); return;
}
totalRes = data.total || data.resultados.length;
const totalPages = Math.ceil(totalRes / PS);
// Stats
document.getElementById('statTotal').textContent = fmtNum(totalRes);
document.getElementById('statAcordaos').textContent = fmtNum(data.total_acordaos || 0);
document.getElementById('statDecisoes').textContent = fmtNum(data.total_decisoes || 0);
document.getElementById('statsRow').style.display = 'grid';
// Divider
document.getElementById('divLabel').textContent = 'Página ' + page + ' de ' + totalPages;
document.getElementById('secDivider').style.display = 'flex';
// Cards
const container = document.getElementById('results');
data.resultados.forEach((item, i) => {
const offset = (page-1)*PS + i + 1;
container.appendChild(buildCard(item, offset));
});
// Pagination
buildPag(totalPages);
if (page > 1) document.getElementById('results').scrollIntoView({behavior:'smooth'});
} catch(e) {
setVisible('loader', 'none');
document.getElementById('searchBtn').disabled = false;
showErr(e.message);
}
}
function showErr(msg) {
const el = document.getElementById('errBox');
el.style.display = 'block';
el.textContent = '✕ ' + msg;
}
function buildCard(item, num) {
const tipo = item.base === 'acordaos' ? 'acordaos' : 'decisoes';
const label = item.base === 'acordaos' ? 'ACÓRDÃO' : 'DECISÃO';
const date = fmtDate(item.data);
const pub = fmtDate(item.publicacao);
const div = document.createElement('div');
div.className = 'result-card ' + tipo;
div.style.animationDelay = (num % 10 * 35) + 'ms';
let meta = '';
if (item.processo) meta += `<span class="meta-pill"><span class="meta-pill-icon">⚙</span>${item.processo}</span>`;
if (item.relator) meta += `<span class="meta-pill"><span class="meta-pill-icon">⚖</span>${item.relator}</span>`;
if (item.orgao) meta += `<span class="meta-pill"><span class="meta-pill-icon">🏛</span>${item.orgao}</span>`;
if (date) meta += `<span class="meta-pill"><span class="meta-pill-icon">📅</span>${date}</span>`;
if (pub) meta += `<span class="meta-pill"><span class="meta-pill-icon">📢</span>DJe ${pub}</span>`;
let fields = '';
if (item.ementa)
fields += `<div class="field-block ementa"><span class="field-label">Ementa</span><div class="field-text">${trunc(item.ementa)}</div></div>`;
if (item.decisao_texto)
fields += `<div class="field-block decisao"><span class="field-label">Decisão</span><div class="field-text">${trunc(item.decisao_texto,360)}</div></div>`;
if (item.acordao_ata)
fields += `<div class="field-block acordao"><span class="field-label">Acórdão / Ata</span><div class="field-text">${trunc(item.acordao_ata,360)}</div></div>`;
let foot = `<a class="btn-ghost" href="/documento/${encodeURIComponent(item.id)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
Ver documento
</a>`;
if (item.url_documento)
foot += `<a class="btn-gold" href="${item.url_documento}" target="_blank" rel="noopener">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Inteiro teor
</a>`;
div.innerHTML = `
<div class="card-head">
<div class="card-title">${item.titulo || item.processo || 'Documento STF'}</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
<span class="tipo-tag ${tipo}"><span class="tipo-tag-dot"></span>${label}</span>
<span class="card-num">#${num}</span>
</div>
</div>
<div class="meta-row">${meta}</div>
${fields}
<div class="card-foot">${foot}</div>`;
return div;
}
function buildPag(total) {
const pg = document.getElementById('pagWrap');
pg.innerHTML = '';
if (total <= 1) return;
pg.style.display = 'flex';
const btn = (lbl, page, active, disabled) => {
const b = document.createElement('button');
b.className = 'pag-btn' + (active ? ' active' : '');
b.textContent = lbl; b.disabled = disabled;
if (!disabled && !active) b.onclick = () => doSearch(page);
pg.appendChild(b);
};
btn('‹', curPage-1, false, curPage===1);
const s = Math.max(1, curPage-2), e = Math.min(total, curPage+2);
for (let p=s; p<=e; p++) btn(p, p, p===curPage, false);
btn('›', curPage+1, false, curPage===total);
const info = document.createElement('span');
info.className = 'pag-info';
info.textContent = curPage + ' / ' + total;
pg.insertBefore(info, pg.children[Math.floor(pg.children.length/2)]);
}
</script>
</body>
</html>
""")
# ============================================
# Página de detalhes do documento
# ============================================
@app.route('/documento/<doc_id>')
def documento_detalhe(doc_id):
return render_template_string("""<!DOCTYPE html>
<html lang="pt-BR">
<head>
<title>PARA AI — Documento {{ doc_id }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>""" + SHARED_CSS + """</style>
</head>
<body>
<!-- TOPBAR -->
<nav class="topbar">
<a href="/" class="brand">
<span class="brand-label">STF - TJPR</span>
</a>
<div class="topbar-right">
<a href="/" class="back-link">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Voltar à busca
</a>
</div>
</nav>
<div class="shell" style="padding-top:0">
<div id="loader" style="display:block">
<div style="padding:80px 0; text-align:center">
<div class="spinner"></div>
<div class="loader-txt">carregando documento…</div>
</div>
</div>
<div class="err-box" id="errBox"></div>
<div id="docWrap" style="display:none"></div>
<footer class="footer">
<span class="footer-brand">PARA<span>AI</span></span>
<span class="footer-note">CONVERGENT LAW TECHNOLOGIES</span>
</footer>
</div>
<script>
const DOC_ID = "{{ doc_id }}";
function fmtDate(d) {
if (!d) return null;
const m = String(d).match(/(\\d{4})-(\\d{2})-(\\d{2})/);
return m ? m[3]+'/'+m[2]+'/'+m[1] : String(d).substring(0,10);
}
async function loadDoc() {
try {
const res = await fetch('/api/documento/' + DOC_ID);
const data = await res.json();
document.getElementById('loader').style.display = 'none';
if (data.error) throw new Error(data.error);
const tipo = data.base === 'acordaos' ? 'acordaos' : 'decisoes';
const label = data.base === 'acordaos' ? 'Acórdão' : 'Decisão Monocrática';
const date = fmtDate(data.data);
const pub = fmtDate(data.publicacao);
let meta = `<span class="meta-pill">${label}</span>`;
if (data.relator) meta += `<span class="meta-pill"><span class="meta-pill-icon">⚖</span>${data.relator}</span>`;
if (data.orgao) meta += `<span class="meta-pill"><span class="meta-pill-icon">🏛</span>${data.orgao}</span>`;
if (date) meta += `<span class="meta-pill"><span class="meta-pill-icon">📅</span>${date}</span>`;
if (pub) meta += `<span class="meta-pill"><span class="meta-pill-icon">📢</span>DJe ${pub}</span>`;
if (data.processo) meta += `<span class="meta-pill"><span class="meta-pill-icon">⚙</span>${data.processo}</span>`;
let fields = '';
if (data.ementa)
fields += `<div class="doc-field ementa"><span class="doc-field-label">Ementa</span><div class="doc-field-text">${data.ementa.replace(/\\n/g,'<br>')}</div></div>`;
if (data.decisao_texto)
fields += `<div class="doc-field decisao"><span class="doc-field-label">Decisão</span><div class="doc-field-text">${data.decisao_texto.replace(/\\n/g,'<br>')}</div></div>`;
if (data.acordao_ata)
fields += `<div class="doc-field acordao"><span class="doc-field-label">Acórdão / Ata</span><div class="doc-field-text">${data.acordao_ata.replace(/\\n/g,'<br>')}</div></div>`;
let teor = '';
if (data.url_documento)
teor = `<a class="inteiro-teor-link" href="${data.url_documento}" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Ver inteiro teor (PDF/HTML)
</a>`;
document.getElementById('docWrap').style.display = 'block';
document.getElementById('docWrap').innerHTML = `
<div class="doc-header-card">
<div class="doc-title">${data.titulo || data.processo || 'Documento STF'}</div>
<div class="meta-row">${meta}</div>
</div>
${fields}
${teor}`;
} catch(e) {
document.getElementById('loader').style.display = 'none';
const el = document.getElementById('errBox');
el.style.display = 'block';
el.textContent = '✕ ' + e.message;
}
}
loadDoc();
</script>
</body>
</html>
""", doc_id=doc_id)
# ============================================
# API para buscar documento por ID
# ============================================
@app.route('/api/documento/<doc_id>')
def api_documento(doc_id):
token = get_fresh_token()
if not token:
return jsonify({"error": "Não foi possível obter token"}), 503
payload = {
"query": {"ids": {"values": [doc_id]}},
"_source": ["id", "titulo", "ementa_texto", "decisao_texto", "acordao_ata",
"processo_codigo_completo", "relator_processo_nome",
"orgao_julgador", "julgamento_data", "publicacao_data", "inteiro_teor_url", "base"],
"size": 1
}
headers = HEADERS.copy()
headers['Cookie'] = f'aws-waf-token={token}'
try:
response = requests.post(STF_API_URL, headers=headers, json=payload, verify=False, timeout=30)
if response.status_code == 200:
data = response.json()
hits = data.get('result', {}).get('hits', {}).get('hits', [])
if hits:
src = hits[0].get('_source', {})
item = {
"id": src.get('id') or hits[0].get('_id'),
"titulo": src.get('titulo'),
"processo": src.get('processo_codigo_completo'),
"relator": src.get('relator_processo_nome'),
"orgao": src.get('orgao_julgador'),
"data": src.get('julgamento_data'),
"publicacao": src.get('publicacao_data'),
"ementa": src.get('ementa_texto'),
"decisao_texto": src.get('decisao_texto'),
"acordao_ata": src.get('acordao_ata'),
"url_documento": src.get('inteiro_teor_url'),
"base": src.get('base')
}
return jsonify({k: v for k, v in item.items() if v is not None})
return jsonify({"error": "Documento não encontrado"}), 404
elif response.status_code == 202:
token = get_fresh_token(force_refresh=True)
if token:
headers['Cookie'] = f'aws-waf-token={token}'
response = requests.post(STF_API_URL, headers=headers, json=payload, verify=False, timeout=30)
if response.status_code == 200:
data = response.json()
hits = data.get('result', {}).get('hits', {}).get('hits', [])
if hits:
src = hits[0].get('_source', {})
item = {
"id": src.get('id') or hits[0].get('_id'),
"titulo": src.get('titulo'),
"processo": src.get('processo_codigo_completo'),
"relator": src.get('relator_processo_nome'),
"orgao": src.get('orgao_julgador'),
"data": src.get('julgamento_data'),
"publicacao": src.get('publicacao_data'),
"ementa": src.get('ementa_texto'),
"decisao_texto": src.get('decisao_texto'),
"acordao_ata": src.get('acordao_ata'),
"url_documento": src.get('inteiro_teor_url'),
"base": src.get('base')
}
return jsonify({k: v for k, v in item.items() if v is not None})
return jsonify({"error": "Token expirado"}), 503
else:
return jsonify({"error": f"Erro {response.status_code}"}), response.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
# ============================================
# Endpoint /api/busca-multipla (retorna JSON)
# ============================================
@app.route('/api/busca-multipla')
def busca_multipla():
query = request.args.get('q', '')
busca_acordaos = request.args.get('acordaos', 'true').lower() == 'true'
busca_decisoes = request.args.get('decisoes', 'true').lower() == 'true'
page = int(request.args.get('page', '1'))
page_size = int(request.args.get('page_size', '20'))
if not query:
return jsonify({"error": "Parâmetro 'q' obrigatório"}), 400
if not busca_acordaos and not busca_decisoes:
return jsonify({"error": "Selecione pelo menos uma base para pesquisa"}), 400
bases = []
if busca_acordaos: bases.append("acordaos")
if busca_decisoes: bases.append("decisoes")
result = search_stf(query, bases, page, page_size)
if isinstance(result, tuple):
return jsonify(result[0]), result[1]
hits = result.get('result', {}).get('hits', {}).get('hits', [])
resultados = []
total_acordaos = 0
total_decisoes = 0
for hit in hits:
src = hit.get('_source', {})
base = src.get('base', '')
if base == 'acordaos': total_acordaos += 1
elif base == 'decisoes': total_decisoes += 1
item = {
"id": src.get('id') or hit.get('_id'),
"titulo": src.get('titulo'),
"processo": src.get('processo_codigo_completo'),
"relator": src.get('relator_processo_nome'),
"orgao": src.get('orgao_julgador'),
"data": src.get('julgamento_data'),
"publicacao": src.get('publicacao_data'),
"ementa": src.get('ementa_texto'),
"decisao_texto": src.get('decisao_texto'),
"acordao_ata": src.get('acordao_ata'),
"url_documento": src.get('inteiro_teor_url'),
"base": base,
"score": hit.get('_score')
}
item = {k: v for k, v in item.items() if v is not None}
resultados.append(item)
return jsonify({
"q": query, "bases": bases, "page": page, "page_size": page_size,
"total": result.get('result', {}).get('hits', {}).get('total', {}).get('value', len(resultados)),
"total_acordaos": total_acordaos,
"total_decisoes": total_decisoes,
"resultados": resultados
})
# ============================================
# Endpoint de saúde
# ============================================
@app.route('/api/health', methods=['GET'])
def health():
playwright_status = False
try:
with sync_playwright() as p:
p.chromium.launch(headless=True).close()
playwright_status = True
except:
pass
return jsonify({
"status": "healthy",
"playwright_ready": playwright_status,
"token_cached": bool(token_cache["token"])
})
if __name__ == '__main__':
try:
import certifi
os.environ['SSL_CERT_FILE'] = certifi.where()
os.environ['REQUESTS_CA_BUNDLE'] = certifi.where()
except:
pass
logger.info("="*50)
logger.info("🚀 Iniciando PARA AI - Busca STF")
logger.info("📋 Bases: acordaos e decisoes")
logger.info("="*50)
port = int(os.environ.get('PORT', 7860))
app.run(host='0.0.0.0', port=port, debug=False)