@danhachuel/pandanews 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Todas as versões publicadas são registradas aqui antes do publish (regra em AGENTS.md). A etiqueta visual usa PATCH de dois dígitos (ex.: 0.1.00); o registry npm normaliza para a versão canônica (ex.: 0.1.0).
4
4
 
5
+ ## 0.3.00 (2026-10-24)
6
+
7
+ - **Pipeline de notícias completo** (Fase 3): coleta RSS (feedparser) → filtragem por relevância (palavras-chave do tema + bônus de região) → redação via LLM com fallback NVIDIA → Gemini (resposta em JSON: título, resumo, corpo) → imagem fotojornalística via Gemini Image salva em `data/imagens/`
8
+ - **Scheduler ativo** (Fase 5): APScheduler com um job por blog ativo (intervalo = frequência do blog), toggle global em Automação liga/desliga de fato, recarregamento automático ao criar/editar/pausar/excluir blogs
9
+ - **Novos endpoints**: `POST /api/v1/noticias/gerar` (dispara o pipeline), `POST /api/v1/noticias/{id}/regenerar`, `GET/POST /api/v1/imagens` (galeria, arquivo e geração manual por prompt), `GET /api/v1/automacao/status` (próxima execução por blog), `POST /api/v1/automacao/executar`
10
+ - Dedup de itens: tabela `vistos` evita reutilizar o mesmo item de feed; migração leve adiciona `fonte_url`/`fonte_titulo` às notícias em bancos existentes
11
+ - Logs de execução por etapa (coleta/redação/imagem/pipeline) visíveis na página Logs
12
+ - UI: botão "Gerar notícia agora" com stepper de progresso (Gradiente de Meta: coletando → redigindo → gerando imagem → salvo), ação Regenerar nas notícias, galeria real de imagens com geração manual e próxima execução por blog na Automação
13
+
14
+ ## 0.2.02 (2026-10-24)
15
+
16
+ - **O sistema NUNCA abre o navegador**: o app exibe o endereço no terminal e o usuário decide quando abrir (dependência `open` removida)
17
+ - **Terminal limpo**: logs de acesso do uvicorn (stdout — `GET /health`, `GET /api/v1/*` etc.) vão apenas para `data/backend.log`; o console mantém somente stderr (banner de inicialização, WARNING/ERROR e tracebacks)
18
+ - **Fallback de porta também para o backend**: se 8000 estiver ocupada (ex.: outra instância), o backend sobe em 8001, 8002... e o proxy da UI segue automaticamente — junto com o fallback já existente da UI (4030, 4031...)
19
+ - Novo endpoint `GET /health` na raiz (200 OK) para clientes que esperam esse caminho — elimina os 404; o health canônico segue em `/api/v1/health`
20
+ - `data/backend.log` continua registrando TODAS as requisições (incluindo `/health`) para auditoria — o filtro no logger `uvicorn.access` (Python) foi deliberadamente evitado: removeria `/health` também do arquivo de auditoria; o silenciamento é feito apenas no console
21
+
5
22
  ## 0.2.01 (2026-10-23)
6
23
 
7
24
  - **Blindagem do backend**: saída do uvicorn espelhada no console e gravada em `<home>/data/backend.log` — queda anormal registra o motivo para diagnóstico
package/README.md CHANGED
@@ -34,11 +34,12 @@ npx --yes @danhachuel/pandanews install
34
34
 
35
35
  O runtime fica em `~/.pandanews` (sobrescreva com a variável de ambiente `PANDANEWS_HOME`) — **upgrades de versão não reinstalam** o que já existe: o instalador detecta Python, dependências e dados já presentes e só instala o que falta.
36
36
 
37
- ## Status (versão 0.2.00)
37
+ ## Status (versão 0.3.00)
38
38
 
39
- - UI React completa: Dashboard, Blogs, Notícias, Imagens, Automação, Configurações e Logs
40
- - Backend: CRUD de blogs, configurações NVIDIA/Gemini com teste de conexão, dashboard e logs — SQLite local
41
- - Próximo: pipeline de geração de notícias (Fase 3) e scheduler ativo (Fase 5)
39
+ - **Pipeline completo**: coleta RSS → redação via LLM (fallback NVIDIA → Gemini) → imagem fotojornalística via Gemini Image → rascunho para revisão
40
+ - **Scheduler ativo**: um job por blog ativo (frequência configurável), toggle global em Automação e execução imediata (⚡ Gerar notícia agora)
41
+ - UI completa: Dashboard, Blogs, Notícias (gerar/regenerar/aprovar), Imagens (galeria + geração manual), Automação, Configurações, Logs
42
+ - Próximo (Fase 6): detecção automática de Supabase, migração SQLite → Supabase e revisão das 7 leis de UX
42
43
 
43
44
  - `npx @danhachuel/pandanews` instala tudo junto (Python portátil + dependências + banco local) e sobe o backend em 127.0.0.1:8000 com página em 127.0.0.1:4030 — se a porta estiver ocupada, usa a seguinte automaticamente (4031, 4032, ...)
44
45
  - A UI React completa (Dashboard, Blogs, Notícias, Imagens, Automação, Configurações) e o pipeline de notícias chegam nas próximas versões (Fases 1 a 3 do plano — ver `CHANGELOG.md`)
@@ -1,25 +1,37 @@
1
1
  """PandaNews API — FastAPI.
2
2
 
3
- Fase 1: CRUD de blogs, configurações (keys mascaradas), teste de LLM,
4
- dashboard com métricas/onboarding, notícias e logs. SQLite local criado
5
- automaticamente no startup (init_db). CORS liberado para a UI local.
3
+ Fases 1, 3 e 5: CRUD de blogs, configurações (keys mascaradas), teste de LLM,
4
+ dashboard, notícias (com geração via pipeline), imagens, logs, automação com
5
+ scheduler ativo por blog. SQLite local criado automaticamente no startup.
6
+ CORS liberado para a UI local (localhost em qualquer porta).
6
7
  """
7
8
  from contextlib import asynccontextmanager
8
9
 
9
10
  from fastapi import FastAPI
10
11
  from fastapi.middleware.cors import CORSMiddleware
11
12
 
12
- from backend.app.routers import blogs, configuracoes, dashboard, llm, logs, noticias
13
- from backend.core.database import init_db
13
+ from backend.app.routers import automacao, blogs, configuracoes, dashboard, imagens, llm, logs, noticias
14
+ from backend.core.database import SessionLocal, init_db
15
+ from backend.core.llm_router import get_config
14
16
 
15
17
 
16
18
  @asynccontextmanager
17
19
  async def lifespan(_: FastAPI):
18
20
  init_db()
21
+ from backend.core import scheduler
22
+
23
+ db = SessionLocal()
24
+ try:
25
+ if get_config(db, "scheduler_ativo", "true") == "true":
26
+ scheduler.iniciar()
27
+ scheduler.recarregar()
28
+ finally:
29
+ db.close()
19
30
  yield
31
+ scheduler.parar()
20
32
 
21
33
 
22
- app = FastAPI(title="PandaNews API", version="0.2.1", lifespan=lifespan)
34
+ app = FastAPI(title="PandaNews API", version="0.3.0", lifespan=lifespan)
23
35
 
24
36
  app.add_middleware(
25
37
  CORSMiddleware,
@@ -33,9 +45,17 @@ app.include_router(configuracoes.router, prefix="/api/v1")
33
45
  app.include_router(dashboard.router, prefix="/api/v1")
34
46
  app.include_router(llm.router, prefix="/api/v1")
35
47
  app.include_router(noticias.router, prefix="/api/v1")
48
+ app.include_router(imagens.router, prefix="/api/v1")
49
+ app.include_router(automacao.router, prefix="/api/v1")
36
50
  app.include_router(logs.router, prefix="/api/v1")
37
51
 
38
52
 
39
53
  @app.get("/api/v1/health")
40
54
  def health() -> dict:
41
55
  return {"status": "ok", "service": "pandanews", "version": app.version}
56
+
57
+
58
+ @app.get("/health")
59
+ def health_root() -> dict:
60
+ """Alias na raiz para clientes que esperam /health (extensões, monitores)."""
61
+ return {"status": "ok", "service": "pandanews", "version": app.version}
@@ -0,0 +1,47 @@
1
+ """Automação (/api/v1/automacao): estado do scheduler e execução imediata."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends
5
+ from pydantic import BaseModel
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from backend.core import scheduler
10
+ from backend.core.database import Blog, get_db
11
+
12
+ router = APIRouter(prefix="/automacao", tags=["automacao"])
13
+
14
+
15
+ class BlogIdIn(BaseModel):
16
+ blog_id: int
17
+
18
+
19
+ @router.get("/status")
20
+ def status(db: Session = Depends(get_db)) -> dict:
21
+ jobs = {job["blog_id"]: job["proxima_execucao"] for job in scheduler.jobs()}
22
+ blogs = db.scalars(select(Blog).where(Blog.status == "ativo")).all()
23
+ return {
24
+ "ativo": scheduler.esta_ativo(),
25
+ "jobs": [
26
+ {
27
+ "blog_id": blog.id,
28
+ "nome": blog.nome,
29
+ "frequencia_horas": blog.frequencia_horas,
30
+ "proxima_execucao": jobs.get(blog.id),
31
+ }
32
+ for blog in blogs
33
+ ],
34
+ }
35
+
36
+
37
+ @router.post("/executar")
38
+ def executar_agora(payload: BlogIdIn) -> dict:
39
+ from backend.core import pipeline
40
+
41
+ noticia = pipeline.gerar_noticia(payload.blog_id)
42
+ if noticia is None:
43
+ return {
44
+ "ok": False,
45
+ "detail": "Nenhuma notícia nova: sem itens nas fontes, todos já utilizados ou erro na geração — verifique os Logs.",
46
+ }
47
+ return {"ok": True, "noticia_id": noticia.id, "titulo": noticia.titulo}
@@ -41,6 +41,13 @@ def obter_blog(blog_id: int, db: Session = Depends(get_db)) -> dict:
41
41
  return blog_to_dict(blog)
42
42
 
43
43
 
44
+ def _sync_scheduler() -> None:
45
+ from backend.core import scheduler
46
+
47
+ if scheduler.esta_ativo():
48
+ scheduler.recarregar()
49
+
50
+
44
51
  @router.post("", status_code=201)
45
52
  def criar_blog(payload: BlogIn, db: Session = Depends(get_db)) -> dict:
46
53
  blog = Blog(
@@ -56,6 +63,7 @@ def criar_blog(payload: BlogIn, db: Session = Depends(get_db)) -> dict:
56
63
  db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog criado: {payload.nome}"))
57
64
  db.commit()
58
65
  db.refresh(blog)
66
+ _sync_scheduler()
59
67
  return blog_to_dict(blog)
60
68
 
61
69
 
@@ -71,6 +79,7 @@ def atualizar_blog(blog_id: int, payload: BlogPatch, db: Session = Depends(get_d
71
79
  setattr(blog, key, value)
72
80
  db.commit()
73
81
  db.refresh(blog)
82
+ _sync_scheduler()
74
83
  return blog_to_dict(blog)
75
84
 
76
85
 
@@ -85,3 +94,4 @@ def excluir_blog(blog_id: int, db: Session = Depends(get_db)) -> None:
85
94
  db.delete(blog)
86
95
  db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog excluído: {blog.nome}"))
87
96
  db.commit()
97
+ _sync_scheduler()
@@ -55,4 +55,16 @@ def salvar_configuracoes(payload: ConfiguracoesPatch, db: Session = Depends(get_
55
55
  else:
56
56
  row.valor = str(valor)
57
57
  db.commit()
58
+ if "scheduler_ativo" in data:
59
+ from backend.core import scheduler
60
+
61
+ bruto = data["scheduler_ativo"]
62
+ ligado = bruto if isinstance(bruto, bool) else str(bruto).lower() == "true"
63
+ if ligado and not scheduler.esta_ativo():
64
+ scheduler.iniciar()
65
+ scheduler.recarregar()
66
+ elif ligado:
67
+ scheduler.recarregar()
68
+ else:
69
+ scheduler.parar()
58
70
  return obter_configuracoes(db)
@@ -0,0 +1,49 @@
1
+ """Imagens (/api/v1/imagens): galeria, arquivo e geração manual."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from fastapi.responses import FileResponse
6
+ from pydantic import BaseModel
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from backend.core.config import IMAGES_DIR
11
+ from backend.core.database import Noticia, get_db
12
+
13
+ router = APIRouter(prefix="/imagens", tags=["imagens"])
14
+
15
+
16
+ class PromptIn(BaseModel):
17
+ prompt: str
18
+
19
+
20
+ @router.get("")
21
+ def listar_imagens(db: Session = Depends(get_db)) -> list[dict]:
22
+ noticias = db.scalars(
23
+ select(Noticia).where(Noticia.imagem_url != "").order_by(Noticia.created_at.desc()).limit(100)
24
+ ).all()
25
+ return [
26
+ {"noticia_id": n.id, "url": n.imagem_url, "titulo": n.titulo, "created_at": n.created_at}
27
+ for n in noticias
28
+ ]
29
+
30
+
31
+ @router.get("/{nome}")
32
+ def servir_imagem(nome: str) -> FileResponse:
33
+ if "/" in nome or "\\" in nome or ".." in nome or not nome.endswith(".png"):
34
+ raise HTTPException(status_code=400, detail="Nome de arquivo inválido")
35
+ caminho = IMAGES_DIR / nome
36
+ if not caminho.exists():
37
+ raise HTTPException(status_code=404, detail="Imagem não encontrada")
38
+ return FileResponse(caminho, media_type="image/png")
39
+
40
+
41
+ @router.post("/gerar")
42
+ def gerar_imagem_manual(payload: PromptIn, db: Session = Depends(get_db)) -> dict:
43
+ from backend.core import image_gen
44
+
45
+ try:
46
+ url = image_gen.imagem_manual(db, payload.prompt.strip())
47
+ return {"ok": True, "url": url}
48
+ except Exception as exc: # noqa: BLE001
49
+ return {"ok": False, "detail": str(exc)}
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from fastapi import APIRouter, Depends, HTTPException
5
+ from pydantic import BaseModel
5
6
  from sqlalchemy import select
6
7
  from sqlalchemy.orm import Session
7
8
 
@@ -11,6 +12,51 @@ from backend.core.database import Noticia, get_db
11
12
  router = APIRouter(prefix="/noticias", tags=["noticias"])
12
13
 
13
14
 
15
+ class GerarIn(BaseModel):
16
+ blog_id: int
17
+
18
+
19
+ def _serializar(n: Noticia) -> dict:
20
+ return {
21
+ "id": n.id,
22
+ "blog_id": n.blog_id,
23
+ "titulo": n.titulo,
24
+ "corpo": n.corpo,
25
+ "resumo": n.resumo,
26
+ "imagem_url": n.imagem_url,
27
+ "status": n.status,
28
+ "llm_provider": n.llm_provider,
29
+ "fonte_url": n.fonte_url,
30
+ "fonte_titulo": n.fonte_titulo,
31
+ "created_at": n.created_at,
32
+ }
33
+
34
+
35
+ @router.post("/gerar", status_code=201)
36
+ def gerar_noticia(payload: GerarIn) -> dict:
37
+ from backend.core import pipeline
38
+
39
+ noticia = pipeline.gerar_noticia(payload.blog_id)
40
+ if noticia is None:
41
+ raise HTTPException(
42
+ status_code=409,
43
+ detail="Nenhuma notícia nova: sem itens nas fontes, todos já utilizados ou erro na geração — verifique os Logs.",
44
+ )
45
+ return _serializar(noticia)
46
+
47
+
48
+ @router.post("/{noticia_id}/regenerar")
49
+ def regenerar_noticia(noticia_id: int) -> dict:
50
+ from backend.core import pipeline
51
+
52
+ noticia = pipeline.regenerar_noticia(noticia_id)
53
+ if noticia is None:
54
+ raise HTTPException(status_code=404, detail="Notícia (ou blog/fonte) não encontrada")
55
+ if not noticia.fonte_titulo and not noticia.fonte_url:
56
+ raise HTTPException(status_code=422, detail="Notícia sem fonte associada — use Gerar notícia para criar uma nova")
57
+ return _serializar(noticia)
58
+
59
+
14
60
  @router.get("")
15
61
  def listar_noticias(
16
62
  blog_id: int | None = None,
@@ -55,9 +55,22 @@ class Noticia(Base):
55
55
  imagem_url: Mapped[str] = mapped_column(String(500), default="")
56
56
  status: Mapped[str] = mapped_column(String(20), default="rascunho", index=True)
57
57
  llm_provider: Mapped[str] = mapped_column(String(50), default="")
58
+ fonte_url: Mapped[str] = mapped_column(String(600), default="")
59
+ fonte_titulo: Mapped[str] = mapped_column(Text, default="")
58
60
  created_at: Mapped[datetime] = mapped_column(default=_now, index=True)
59
61
 
60
62
 
63
+ class Visto(Base):
64
+ """Itens de feed já utilizados por blog (dedup do pipeline)."""
65
+
66
+ __tablename__ = "vistos"
67
+
68
+ id: Mapped[int] = mapped_column(primary_key=True)
69
+ blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.id"), index=True)
70
+ url: Mapped[str] = mapped_column(String(600))
71
+ created_at: Mapped[datetime] = mapped_column(default=_now)
72
+
73
+
61
74
  class Configuracao(Base):
62
75
  __tablename__ = "configuracoes"
63
76
 
@@ -78,6 +91,24 @@ class Log(Base):
78
91
 
79
92
  def init_db() -> None:
80
93
  Base.metadata.create_all(bind=engine)
94
+ _migrate()
95
+
96
+
97
+ def _migrate() -> None:
98
+ """Migrações leves para bancos existentes (SQLite)."""
99
+ if not DB_URL.startswith("sqlite"):
100
+ return
101
+ from sqlalchemy import text
102
+
103
+ with engine.connect() as conn:
104
+ colunas = [row[1] for row in conn.execute(text("PRAGMA table_info(noticias)"))]
105
+ for coluna, ddl in (
106
+ ("fonte_url", "VARCHAR(600) DEFAULT ''"),
107
+ ("fonte_titulo", "TEXT DEFAULT ''"),
108
+ ):
109
+ if coluna not in colunas:
110
+ conn.execute(text(f"ALTER TABLE noticias ADD COLUMN {coluna} {ddl}"))
111
+ conn.commit()
81
112
 
82
113
 
83
114
  def get_db():
@@ -1,6 +1,84 @@
1
- """Geração de imagens do PandaNews (Fase 3).
1
+ """Geração de imagens do PandaNews (Gemini Image).
2
2
 
3
- Gemini Image API com prompt fotojornalístico a partir de palavras-chave da
4
- notícia; imagens salvas em data/imagens/{noticia_id}.png. Se falhar, a notícia
5
- segue sem imagem e é sinalizada na UI para ação manual.
3
+ Extrai palavras-chave do título/resumo da notícia (TF simples), monta um
4
+ prompt fotojornalístico e chama a Gemini Image API. A imagem é salva em
5
+ <home>/data/imagens/ e servida por /api/v1/imagens/{nome}. Se a geração
6
+ falhar, a notícia segue sem imagem (sinalizada na UI) — o pipeline não para.
6
7
  """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import re
12
+ from datetime import datetime, timezone
13
+
14
+ import httpx
15
+
16
+ from backend.core.config import IMAGES_DIR
17
+ from backend.core.llm_router import get_config
18
+
19
+ IMAGE_MODEL_DEFAULT = "gemini-3.1-flash-image"
20
+
21
+ STOPWORDS = set(
22
+ "a o e de da do das dos em no na nos nas um uma com para por que se ao aos "
23
+ "as os mais como onde quando após sobre entre sua seu suas seus pelo pela "
24
+ "isso esse essa isto este esta foi ser ter pela pelos".split()
25
+ )
26
+
27
+
28
+ def extrair_palavras_chave(texto: str, max_palavras: int = 10) -> list[str]:
29
+ palavras = re.findall(r"[a-záàâãéêíóôõúüç]{4,}", (texto or "").lower())
30
+ contagem: dict[str, int] = {}
31
+ for palavra in palavras:
32
+ if palavra in STOPWORDS:
33
+ continue
34
+ contagem[palavra] = contagem.get(palavra, 0) + 1
35
+ ordenadas = sorted(contagem, key=contagem.get, reverse=True)
36
+ return ordenadas[:max_palavras]
37
+
38
+
39
+ def gerar_imagem(db, prompt_visual: str) -> bytes:
40
+ """Chama a Gemini Image API e devolve os bytes da imagem."""
41
+ api_key = get_config(db, "google_api_key")
42
+ if not api_key:
43
+ raise RuntimeError("API key do Google Gemini não configurada — adicione em Configurações para gerar imagens.")
44
+ modelo = get_config(db, "google_image_model", IMAGE_MODEL_DEFAULT)
45
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{modelo}:generateContent"
46
+ payload = {
47
+ "contents": [{"parts": [{"text": prompt_visual}]}],
48
+ "generationConfig": {"responseModalities": ["IMAGE"]},
49
+ }
50
+ resposta = httpx.post(url, json=payload, headers={"x-goog-api-key": api_key}, timeout=180.0)
51
+ if resposta.status_code != 200:
52
+ raise RuntimeError(f"Gemini Image respondeu {resposta.status_code}: {resposta.text[:300]}")
53
+ dados = resposta.json()
54
+ for candidato in dados.get("candidates", []):
55
+ for parte in candidato.get("content", {}).get("parts", []):
56
+ inline = parte.get("inlineData") or parte.get("inline_data")
57
+ if inline and inline.get("data"):
58
+ return base64.b64decode(inline["data"])
59
+ raise RuntimeError("Gemini Image não retornou imagem — verifique o modelo configurado em Configurações.")
60
+
61
+
62
+ def _salvar(dados: bytes, nome: str) -> str:
63
+ IMAGES_DIR.mkdir(parents=True, exist_ok=True)
64
+ (IMAGES_DIR / nome).write_bytes(dados)
65
+ return f"/api/v1/imagens/{nome}"
66
+
67
+
68
+ def imagem_para_noticia(db, noticia) -> str:
69
+ """Gera a imagem de capa de uma notícia (estilo fotojornalístico)."""
70
+ chaves = extrair_palavras_chave(f"{noticia.titulo} {noticia.resumo}")
71
+ prompt = (
72
+ "Fotografia jornalística profissional, iluminação natural, realista, "
73
+ "sem texto ou marca d'água, retratando: "
74
+ f"{', '.join(chaves) if chaves else (noticia.titulo or 'notícias gerais')}"
75
+ )
76
+ dados = gerar_imagem(db, prompt)
77
+ return _salvar(dados, f"{noticia.id}.png")
78
+
79
+
80
+ def imagem_manual(db, prompt: str) -> str:
81
+ """Gera uma imagem a partir de prompt livre (página Imagens)."""
82
+ dados = gerar_imagem(db, prompt)
83
+ nome = f"manual-{int(datetime.now(timezone.utc).timestamp() * 1000)}.png"
84
+ return _salvar(dados, nome)