@danhachuel/pandanews 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +5 -4
- package/backend/app/main.py +21 -6
- package/backend/app/routers/automacao.py +47 -0
- package/backend/app/routers/blogs.py +10 -0
- package/backend/app/routers/configuracoes.py +12 -0
- package/backend/app/routers/imagens.py +49 -0
- package/backend/app/routers/legal.py +35 -0
- package/backend/app/routers/noticias.py +46 -0
- package/backend/core/config.py +4 -0
- package/backend/core/database.py +31 -0
- package/backend/core/image_gen.py +82 -4
- package/backend/core/pipeline.py +212 -4
- package/backend/core/scheduler.py +80 -0
- package/frontend/dist/assets/index-BcgBzdzo.js +70 -0
- package/frontend/dist/assets/index-gsZzhCX8.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/legal/index.html +218 -0
- package/legal/privacy.html +207 -0
- package/legal/terms.html +185 -0
- package/package.json +2 -1
- package/scripts/start.js +1 -1
- package/frontend/dist/assets/index-BaPdaYIz.css +0 -1
- package/frontend/dist/assets/index-D0e-vr7T.js +0 -70
package/backend/core/pipeline.py
CHANGED
|
@@ -1,6 +1,214 @@
|
|
|
1
|
-
"""Pipeline de notícias do PandaNews
|
|
1
|
+
"""Pipeline de notícias do PandaNews.
|
|
2
2
|
|
|
3
|
-
Coleta RSS
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Coleta RSS → filtragem por relevância (palavras-chave do tema/região) →
|
|
4
|
+
rascunho via LLM (fallback ordenado NVIDIA → Gemini) → imagem
|
|
5
|
+
fotojornalística via Gemini → status rascunho. Cada etapa registra logs
|
|
6
|
+
(sucesso/erro) por blog; itens já utilizados são deduplicados (tabela vistos).
|
|
6
7
|
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import feedparser
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
|
|
16
|
+
from backend.core import image_gen, llm_router
|
|
17
|
+
from backend.core.database import Blog, Log, Noticia, SessionLocal, Visto
|
|
18
|
+
|
|
19
|
+
TEMAS_PALAVRAS: dict[str, list[str]] = {
|
|
20
|
+
"esporte": [
|
|
21
|
+
"futebol", "jogo", "time", "campeonato", "gol", "partida", "atleta",
|
|
22
|
+
"seleção", "torcida", "liga", "olimpíadas", "campeão", "vitória",
|
|
23
|
+
],
|
|
24
|
+
"politica": [
|
|
25
|
+
"governo", "presidente", "congresso", "eleição", "eleições", "deputado",
|
|
26
|
+
"senado", "senador", "ministro", "partido", "prefeito", "reforma", "voto",
|
|
27
|
+
],
|
|
28
|
+
"economia": [
|
|
29
|
+
"mercado", "dólar", "inflação", "juros", "bolsa", "pib", "economia",
|
|
30
|
+
"impostos", "emprego", "salário", "banco central", "câmbio",
|
|
31
|
+
],
|
|
32
|
+
"tecnologia": [
|
|
33
|
+
"tecnologia", "inteligência artificial", "app", "startup", "software",
|
|
34
|
+
"chip", "smartphone", "internet", "dados", "robô", "algoritmo",
|
|
35
|
+
],
|
|
36
|
+
"geral": [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _log(db, blog_id: int | None, etapa: str, status: str, mensagem: str) -> None:
|
|
41
|
+
db.add(Log(blog_id=blog_id, etapa=etapa, status=status, mensagem=mensagem[:2000]))
|
|
42
|
+
db.commit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _limpar(texto: str, limite: int) -> str:
|
|
46
|
+
texto = re.sub(r"<[^>]+>", " ", texto or "")
|
|
47
|
+
return " ".join(texto.split())[:limite]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def coletar_itens(db, blog: Blog, fontes: list[str]) -> list[dict]:
|
|
51
|
+
itens: list[dict] = []
|
|
52
|
+
for url in fontes:
|
|
53
|
+
try:
|
|
54
|
+
feed = feedparser.parse(url)
|
|
55
|
+
if getattr(feed, "bozo", 0) and not feed.entries:
|
|
56
|
+
erro = getattr(feed, "bozo_exception", "formato inválido")
|
|
57
|
+
_log(db, blog.id, "coleta", "erro", f"Falha ao ler feed {url}: {erro}")
|
|
58
|
+
continue
|
|
59
|
+
for entrada in feed.entries[:15]:
|
|
60
|
+
itens.append(
|
|
61
|
+
{
|
|
62
|
+
"titulo": _limpar(getattr(entrada, "title", ""), 300),
|
|
63
|
+
"link": getattr(entrada, "link", "") or "",
|
|
64
|
+
"resumo": _limpar(getattr(entrada, "summary", ""), 600),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
_log(db, blog.id, "coleta", "sucesso", f"{len(feed.entries)} itens coletados de {url}")
|
|
68
|
+
except Exception as exc: # noqa: BLE001
|
|
69
|
+
_log(db, blog.id, "coleta", "erro", f"Exceção ao ler {url}: {exc}")
|
|
70
|
+
return itens
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def score_relevancia(item: dict, blog: Blog) -> float:
|
|
74
|
+
texto = f"{item['titulo']} {item['resumo']}".lower()
|
|
75
|
+
pontos = 0.0
|
|
76
|
+
for palavra in TEMAS_PALAVRAS.get(blog.tema, []):
|
|
77
|
+
if palavra in texto:
|
|
78
|
+
pontos += 1.0
|
|
79
|
+
regiao = (blog.regiao or "").strip().lower()
|
|
80
|
+
if regiao and regiao in texto:
|
|
81
|
+
pontos += 2.0
|
|
82
|
+
return pontos
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prompt_redacao(blog: Blog, item: dict) -> str:
|
|
86
|
+
estilo = (blog.estilo_editorial or "").strip() or "jornalismo claro e direto em português do Brasil"
|
|
87
|
+
return (
|
|
88
|
+
"Você é editor-chefe de um blog de notícias. Escreva uma notícia ORIGINAL em "
|
|
89
|
+
"português do Brasil a partir da fonte abaixo.\n"
|
|
90
|
+
f"Blog: {blog.nome} — tema: {blog.tema} — região: {blog.regiao}\n"
|
|
91
|
+
f"Estilo editorial: {estilo}\n"
|
|
92
|
+
f"Fonte: \"{item['titulo']}\"\n"
|
|
93
|
+
f"Resumo da fonte: {item['resumo']}\n"
|
|
94
|
+
"Regras: reescreva com palavras próprias; não invente fatos que não estejam "
|
|
95
|
+
"na fonte; 3 a 5 parágrafos.\n"
|
|
96
|
+
'Responda SOMENTE com JSON válido no formato: {"titulo": "...", "resumo": "...", "corpo": "..."}'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_resposta_llm(texto: str) -> dict:
|
|
101
|
+
bruto = (texto or "").strip()
|
|
102
|
+
bruto = re.sub(r"^```(json)?\s*|\s*```$", "", bruto)
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(bruto)
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
casado = re.search(r"\{.*\}", bruto, re.DOTALL)
|
|
107
|
+
if casado:
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(casado.group(0))
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
pass
|
|
112
|
+
return {"titulo": "", "resumo": "", "corpo": bruto}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def gerar_noticia(blog_id: int) -> Noticia | None:
|
|
116
|
+
"""Pipeline completo para um blog. Retorna a notícia criada ou None."""
|
|
117
|
+
db = SessionLocal()
|
|
118
|
+
try:
|
|
119
|
+
blog = db.get(Blog, blog_id)
|
|
120
|
+
if blog is None:
|
|
121
|
+
_log(db, None, "pipeline", "erro", f"Blog {blog_id} não encontrado")
|
|
122
|
+
return None
|
|
123
|
+
fontes = json.loads(blog.fontes_rss or "[]")
|
|
124
|
+
if not fontes:
|
|
125
|
+
_log(db, blog.id, "coleta", "erro", "Blog sem fontes RSS configuradas")
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# 1. Coleta
|
|
129
|
+
itens = coletar_itens(db, blog, fontes)
|
|
130
|
+
if not itens:
|
|
131
|
+
_log(db, blog.id, "coleta", "erro", "Nenhum item coletado nas fontes")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# 2. Dedup + relevância
|
|
135
|
+
vistos = {v.url for v in db.scalars(select(Visto).where(Visto.blog_id == blog.id)).all()}
|
|
136
|
+
novos = [i for i in itens if i["link"] and i["link"] not in vistos]
|
|
137
|
+
if not novos:
|
|
138
|
+
_log(db, blog.id, "coleta", "sucesso", "Todos os itens das fontes já haviam sido utilizados")
|
|
139
|
+
return None
|
|
140
|
+
novos.sort(key=lambda i: score_relevancia(i, blog), reverse=True)
|
|
141
|
+
item = novos[0]
|
|
142
|
+
|
|
143
|
+
# 3. Redação via LLM (fallback NVIDIA -> Gemini)
|
|
144
|
+
try:
|
|
145
|
+
resposta, provedor = llm_router.gerar_texto(db, _prompt_redacao(blog, item), max_tokens=1600)
|
|
146
|
+
except Exception as exc: # noqa: BLE001
|
|
147
|
+
_log(db, blog.id, "redacao", "erro", f"Falha na redação: {exc}")
|
|
148
|
+
return None
|
|
149
|
+
dados = _parse_resposta_llm(resposta)
|
|
150
|
+
titulo = str(dados.get("titulo") or item["titulo"]).strip()
|
|
151
|
+
resumo = str(dados.get("resumo") or _limpar(item["resumo"], 240)).strip()
|
|
152
|
+
corpo = str(dados.get("corpo") or resposta).strip()
|
|
153
|
+
_log(db, blog.id, "redacao", "sucesso", f"Notícia redigida via {provedor}: {titulo}")
|
|
154
|
+
|
|
155
|
+
noticia = Noticia(
|
|
156
|
+
blog_id=blog.id,
|
|
157
|
+
titulo=titulo,
|
|
158
|
+
resumo=resumo,
|
|
159
|
+
corpo=corpo,
|
|
160
|
+
status="rascunho",
|
|
161
|
+
llm_provider=provedor,
|
|
162
|
+
fonte_url=item["link"],
|
|
163
|
+
fonte_titulo=item["titulo"],
|
|
164
|
+
)
|
|
165
|
+
db.add(noticia)
|
|
166
|
+
db.add(Visto(blog_id=blog.id, url=item["link"]))
|
|
167
|
+
db.commit()
|
|
168
|
+
db.refresh(noticia)
|
|
169
|
+
|
|
170
|
+
# 4. Imagem (falha não bloqueia: notícia fica sinalizada "sem imagem")
|
|
171
|
+
try:
|
|
172
|
+
caminho = image_gen.imagem_para_noticia(db, noticia)
|
|
173
|
+
noticia.imagem_url = caminho
|
|
174
|
+
db.commit()
|
|
175
|
+
_log(db, blog.id, "imagem", "sucesso", f"Imagem gerada: {caminho}")
|
|
176
|
+
except Exception as exc: # noqa: BLE001
|
|
177
|
+
_log(db, blog.id, "imagem", "erro", f"Imagem não gerada: {exc}")
|
|
178
|
+
|
|
179
|
+
return noticia
|
|
180
|
+
finally:
|
|
181
|
+
db.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def regenerar_noticia(noticia_id: int) -> Noticia | None:
|
|
185
|
+
"""Re-roda a redação de uma notícia existente a partir da fonte original."""
|
|
186
|
+
db = SessionLocal()
|
|
187
|
+
try:
|
|
188
|
+
noticia = db.get(Noticia, noticia_id)
|
|
189
|
+
if noticia is None:
|
|
190
|
+
return None
|
|
191
|
+
blog = db.get(Blog, noticia.blog_id)
|
|
192
|
+
if blog is None:
|
|
193
|
+
return None
|
|
194
|
+
if not noticia.fonte_titulo and not noticia.fonte_url:
|
|
195
|
+
_log(db, blog.id, "redacao", "erro", f"Notícia {noticia_id} sem fonte associada")
|
|
196
|
+
return noticia
|
|
197
|
+
prompt = _prompt_redacao(
|
|
198
|
+
blog,
|
|
199
|
+
{"titulo": noticia.fonte_titulo, "resumo": noticia.resumo or noticia.corpo[:300], "link": noticia.fonte_url},
|
|
200
|
+
)
|
|
201
|
+
resposta, provedor = llm_router.gerar_texto(db, prompt, max_tokens=1600)
|
|
202
|
+
dados = _parse_resposta_llm(resposta)
|
|
203
|
+
if dados.get("titulo"):
|
|
204
|
+
noticia.titulo = str(dados["titulo"]).strip()
|
|
205
|
+
if dados.get("resumo"):
|
|
206
|
+
noticia.resumo = str(dados["resumo"]).strip()
|
|
207
|
+
noticia.corpo = str(dados.get("corpo") or resposta).strip()
|
|
208
|
+
noticia.llm_provider = provedor
|
|
209
|
+
db.commit()
|
|
210
|
+
db.refresh(noticia)
|
|
211
|
+
_log(db, blog.id, "redacao", "sucesso", f"Notícia {noticia_id} regenerada via {provedor}")
|
|
212
|
+
return noticia
|
|
213
|
+
finally:
|
|
214
|
+
db.close()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Scheduler do PandaNews (APScheduler).
|
|
2
|
+
|
|
3
|
+
Um job por blog ativo, com intervalo = frequencia_horas do blog. O estado
|
|
4
|
+
global (configuracoes.scheduler_ativo) liga/desliga tudo. Os jobs chamam o
|
|
5
|
+
pipeline e registram logs; erros não derrubam o scheduler.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
10
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
from backend.core.database import Blog, Log, SessionLocal
|
|
14
|
+
|
|
15
|
+
_scheduler: BackgroundScheduler | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def esta_ativo() -> bool:
|
|
19
|
+
return _scheduler is not None and _scheduler.running
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def iniciar() -> None:
|
|
23
|
+
global _scheduler
|
|
24
|
+
if esta_ativo():
|
|
25
|
+
return
|
|
26
|
+
_scheduler = BackgroundScheduler(timezone="UTC", daemon=True)
|
|
27
|
+
_scheduler.start()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parar() -> None:
|
|
31
|
+
global _scheduler
|
|
32
|
+
if _scheduler is not None:
|
|
33
|
+
_scheduler.shutdown(wait=False)
|
|
34
|
+
_scheduler = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def recarregar() -> None:
|
|
38
|
+
"""Sincroniza os jobs com os blogs ativos (após CRUD ou toggle)."""
|
|
39
|
+
if _scheduler is None:
|
|
40
|
+
return
|
|
41
|
+
db = SessionLocal()
|
|
42
|
+
try:
|
|
43
|
+
ativos = db.scalars(select(Blog).where(Blog.status == "ativo")).all()
|
|
44
|
+
_scheduler.remove_all_jobs()
|
|
45
|
+
for blog in ativos:
|
|
46
|
+
_scheduler.add_job(
|
|
47
|
+
_executar,
|
|
48
|
+
IntervalTrigger(hours=max(1, int(blog.frequencia_horas or 6))),
|
|
49
|
+
id=f"blog_{blog.id}",
|
|
50
|
+
args=[blog.id],
|
|
51
|
+
replace_existing=True,
|
|
52
|
+
)
|
|
53
|
+
finally:
|
|
54
|
+
db.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def jobs() -> list[dict]:
|
|
58
|
+
if _scheduler is None:
|
|
59
|
+
return []
|
|
60
|
+
resultado = []
|
|
61
|
+
for job in _scheduler.get_jobs():
|
|
62
|
+
resultado.append(
|
|
63
|
+
{
|
|
64
|
+
"blog_id": job.args[0] if job.args else None,
|
|
65
|
+
"proxima_execucao": job.next_run_time.isoformat() if job.next_run_time else None,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return resultado
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _executar(blog_id: int) -> None:
|
|
72
|
+
from backend.core import pipeline
|
|
73
|
+
|
|
74
|
+
db = SessionLocal()
|
|
75
|
+
try:
|
|
76
|
+
db.add(Log(blog_id=blog_id, etapa="pipeline", status="sucesso", mensagem="Execução agendada iniciada"))
|
|
77
|
+
db.commit()
|
|
78
|
+
finally:
|
|
79
|
+
db.close()
|
|
80
|
+
pipeline.gerar_noticia(blog_id)
|