@danhachuel/pandanews 0.1.2 → 0.2.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 +21 -0
- package/README.md +6 -2
- package/backend/app/main.py +23 -4
- package/backend/app/routers/blogs.py +87 -0
- package/backend/app/routers/configuracoes.py +58 -0
- package/backend/app/routers/dashboard.py +59 -0
- package/backend/app/routers/llm.py +16 -0
- package/backend/app/routers/logs.py +38 -0
- package/backend/app/routers/noticias.py +65 -0
- package/backend/app/schemas.py +71 -1
- package/backend/core/config.py +42 -1
- package/backend/core/database.py +86 -4
- package/backend/core/llm_router.py +89 -4
- package/frontend/dist/assets/index-BaPdaYIz.css +1 -0
- package/frontend/dist/assets/index-D0e-vr7T.js +70 -0
- package/frontend/dist/favicon.svg +11 -0
- package/frontend/dist/index.html +17 -0
- package/package.json +1 -1
- package/scripts/build.js +12 -3
- package/scripts/install.js +106 -14
- package/scripts/start.js +57 -7
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
2
|
+
<rect width="100" height="100" rx="20" fill="#0b1220"/>
|
|
3
|
+
<circle cx="25" cy="28" r="13" fill="#e6edf3"/>
|
|
4
|
+
<circle cx="75" cy="28" r="13" fill="#e6edf3"/>
|
|
5
|
+
<circle cx="50" cy="52" r="30" fill="#e6edf3"/>
|
|
6
|
+
<ellipse cx="37" cy="48" rx="9" ry="11" fill="#0b1220" transform="rotate(-15 37 48)"/>
|
|
7
|
+
<ellipse cx="63" cy="48" rx="9" ry="11" fill="#0b1220" transform="rotate(15 63 48)"/>
|
|
8
|
+
<circle cx="38" cy="49" r="3" fill="#22c55e"/>
|
|
9
|
+
<circle cx="62" cy="49" r="3" fill="#22c55e"/>
|
|
10
|
+
<ellipse cx="50" cy="62" rx="5" ry="4" fill="#0b1220"/>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-BR" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>PandaNews — Automação de blogs de notícias com IA</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-D0e-vr7T.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BaPdaYIz.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/package.json
CHANGED
package/scripts/build.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Compila o frontend de desenvolvimento (frontend/ -> frontend/dist).
|
|
2
2
|
// A UI React + Vite + Tailwind é inicializada na Fase 2 do plano.
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -16,10 +16,19 @@ export async function runBuild() {
|
|
|
16
16
|
}
|
|
17
17
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
18
18
|
console.log('Instalando dependências do frontend...');
|
|
19
|
-
|
|
19
|
+
// shell: true — obrigatório para .cmd no Windows (Node >= 18)
|
|
20
|
+
const install = spawnSync(npmCmd, ['install'], { cwd: frontendDir, stdio: 'inherit', shell: true });
|
|
20
21
|
if (install.status !== 0) throw new Error('Falha ao instalar dependências do frontend.');
|
|
21
22
|
console.log('Compilando o frontend...');
|
|
22
|
-
const build = spawnSync(npmCmd, ['run', 'build'], { cwd: frontendDir, stdio: 'inherit' });
|
|
23
|
+
const build = spawnSync(npmCmd, ['run', 'build'], { cwd: frontendDir, stdio: 'inherit', shell: true });
|
|
23
24
|
if (build.status !== 0) throw new Error('Falha ao compilar o frontend.');
|
|
24
25
|
console.log('Frontend compilado em frontend/dist.');
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
// Entry point para execução direta: node scripts/build.js
|
|
29
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
30
|
+
runBuild().catch((err) => {
|
|
31
|
+
console.error(`pandanews build: ${err && err.message}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
});
|
|
34
|
+
}
|
package/scripts/install.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
// Instalador do runtime PandaNews.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
3
|
+
// O runtime vive em um diretório ESTÁVEL por usuário (~/.pandanews, ou
|
|
4
|
+
// PANDANEWS_HOME), separado da pasta do pacote npm: upgrades de versão não
|
|
5
|
+
// reinstalam Python/dependências. O instalador detecta o que já existe e só
|
|
6
|
+
// executa o que falta:
|
|
7
|
+
// 1. estrutura de pastas (data/, .runtime/)
|
|
8
|
+
// 2. Python 3.12.7 portátil (python-build-standalone)
|
|
9
|
+
// 3. virtualenv (.runtime/venv)
|
|
10
|
+
// 4. dependências do backend — com fingerprint de requirements.txt + versão
|
|
11
|
+
// do Python: pip só roda quando mudam
|
|
12
|
+
// 5. migração de runtimes legados (pasta do pacote / cache do npx)
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
10
14
|
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import {
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
17
|
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import fse from 'fs-extra';
|
|
13
20
|
|
|
14
21
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
15
22
|
export const PKG_ROOT = join(__dirname, '..');
|
|
@@ -17,9 +24,10 @@ export const PKG_ROOT = join(__dirname, '..');
|
|
|
17
24
|
const PYTHON_VERSION = '3.12.7';
|
|
18
25
|
const PYTHON_RELEASE = '20241016';
|
|
19
26
|
|
|
20
|
-
// Raiz de instalação: PANDANEWS_HOME sobrepõe
|
|
27
|
+
// Raiz de instalação estável: PANDANEWS_HOME sobrepõe; padrão ~/.pandanews.
|
|
21
28
|
export function resolveHome() {
|
|
22
|
-
|
|
29
|
+
if (process.env.PANDANEWS_HOME) return resolve(process.env.PANDANEWS_HOME);
|
|
30
|
+
return join(os.homedir(), '.pandanews');
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
// Detecta plataforma e asset de python-build-standalone.
|
|
@@ -51,6 +59,68 @@ function venvBin(home) {
|
|
|
51
59
|
: join(home, '.runtime', 'venv', 'bin', 'python');
|
|
52
60
|
}
|
|
53
61
|
|
|
62
|
+
function pythonSpec() {
|
|
63
|
+
return `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fingerprint das dependências: muda quando o requirements.txt ou a versão do
|
|
67
|
+
// Python mudam — só então o pip roda de novo.
|
|
68
|
+
function depsFingerprint() {
|
|
69
|
+
const reqPath = join(PKG_ROOT, 'backend', 'requirements.txt');
|
|
70
|
+
const req = existsSync(reqPath) ? readFileSync(reqPath, 'utf8') : '';
|
|
71
|
+
const hash = createHash('sha256').update(req).digest('hex').slice(0, 16);
|
|
72
|
+
return `${pythonSpec()};req=${hash}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Procura runtimes legados: na própria pasta do pacote (layout antigo) e nos
|
|
76
|
+
// buckets do cache do npx (versões anteriores do pacote). Best effort.
|
|
77
|
+
function findLegacyRuntime() {
|
|
78
|
+
const candidates = [];
|
|
79
|
+
const marker = '.python-ready';
|
|
80
|
+
const sameDir = join(PKG_ROOT, '.runtime');
|
|
81
|
+
if (existsSync(join(sameDir, marker))) candidates.push(sameDir);
|
|
82
|
+
try {
|
|
83
|
+
const cacheBase = process.env.npm_config_cache
|
|
84
|
+
? process.env.npm_config_cache
|
|
85
|
+
: join(os.homedir(), process.platform === 'win32' ? join('AppData', 'Local', 'npm-cache') : '.npm');
|
|
86
|
+
const npxRoot = join(cacheBase, '_npx');
|
|
87
|
+
if (existsSync(npxRoot)) {
|
|
88
|
+
for (const entry of readdirSync(npxRoot)) {
|
|
89
|
+
const rt = join(npxRoot, entry, 'node_modules', '@danhachuel', 'pandanews', '.runtime');
|
|
90
|
+
if (existsSync(join(rt, marker))) candidates.push(rt);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch { /* best effort */ }
|
|
94
|
+
if (candidates.length === 0) return null;
|
|
95
|
+
candidates.sort((a, b) => statSync(join(b, marker)).mtimeMs - statSync(join(a, marker)).mtimeMs);
|
|
96
|
+
return candidates[0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Reaproveita um runtime legado movendo-o para o home estável. Pula se o home
|
|
100
|
+
// já tiver um runtime válido, ou se PANDANEWS_SKIP_MIGRATE estiver definido.
|
|
101
|
+
function migrateLegacyRuntime(home) {
|
|
102
|
+
if (process.env.PANDANEWS_SKIP_MIGRATE) return;
|
|
103
|
+
const targetRuntime = join(home, '.runtime');
|
|
104
|
+
if (existsSync(join(targetRuntime, '.python-ready'))) return;
|
|
105
|
+
const legacy = findLegacyRuntime();
|
|
106
|
+
if (!legacy) return;
|
|
107
|
+
console.log(`Runtime já instalado encontrado em ${legacy} — reaproveitando (movendo para ${home})...`);
|
|
108
|
+
try {
|
|
109
|
+
// Remove stubs vazios criados antes da migração: rename de diretório no
|
|
110
|
+
// Windows falha (EPERM) quando o destino já existe.
|
|
111
|
+
rmSync(targetRuntime, { recursive: true, force: true });
|
|
112
|
+
fse.moveSync(legacy, targetRuntime);
|
|
113
|
+
const legacyData = join(dirname(legacy), 'data');
|
|
114
|
+
if (existsSync(legacyData)) {
|
|
115
|
+
fse.copySync(legacyData, join(home, 'data'));
|
|
116
|
+
rmSync(legacyData, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
console.log('Migração concluída.');
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.log(`Migração falhou (${err && err.message}) — seguindo com instalação nova.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
54
124
|
async function downloadPython(home, platform) {
|
|
55
125
|
const url = pythonArchiveUrl(platform);
|
|
56
126
|
const runtimeDir = join(home, '.runtime');
|
|
@@ -108,9 +178,19 @@ export async function runInstall({ skipPythonDeps = false } = {}) {
|
|
|
108
178
|
|
|
109
179
|
if (skipPythonDeps) {
|
|
110
180
|
console.log('--skip-python-deps: instalação estrutural (uso em CI).');
|
|
111
|
-
} else if (existsSync(join(home, '.runtime', '.python-ready'))) {
|
|
112
|
-
console.log('Runtime Python já provisionado — nada a fazer.');
|
|
113
181
|
} else {
|
|
182
|
+
migrateLegacyRuntime(home);
|
|
183
|
+
|
|
184
|
+
// Versão do Python mudou? Limpa o runtime antigo antes de reinstalar.
|
|
185
|
+
const specFile = join(home, '.runtime', '.python-spec');
|
|
186
|
+
const wantSpec = pythonSpec();
|
|
187
|
+
const haveSpec = existsSync(specFile) ? readFileSync(specFile, 'utf8').trim() : '';
|
|
188
|
+
if (haveSpec && haveSpec !== wantSpec) {
|
|
189
|
+
console.log('Versão do Python do runtime mudou — limpando instalação antiga...');
|
|
190
|
+
rmSync(join(home, '.runtime', 'python'), { recursive: true, force: true });
|
|
191
|
+
rmSync(join(home, '.runtime', 'venv'), { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
114
194
|
if (!existsSync(pythonBin(home))) {
|
|
115
195
|
await downloadPython(home, platform);
|
|
116
196
|
} else {
|
|
@@ -121,9 +201,21 @@ export async function runInstall({ skipPythonDeps = false } = {}) {
|
|
|
121
201
|
} else {
|
|
122
202
|
console.log('Virtualenv já presente.');
|
|
123
203
|
}
|
|
124
|
-
|
|
204
|
+
|
|
205
|
+
// Dependências: só roda o pip quando o fingerprint muda.
|
|
206
|
+
const fpFile = join(home, '.runtime', '.deps-fingerprint');
|
|
207
|
+
const haveFp = existsSync(fpFile) ? readFileSync(fpFile, 'utf8').trim() : '';
|
|
208
|
+
const wantFp = depsFingerprint();
|
|
209
|
+
if (haveFp !== wantFp) {
|
|
210
|
+
installDeps(home);
|
|
211
|
+
writeFileSync(fpFile, wantFp);
|
|
212
|
+
console.log('Dependências em dia.');
|
|
213
|
+
} else {
|
|
214
|
+
console.log('Dependências já instaladas — nada a fazer.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
writeFileSync(specFile, wantSpec);
|
|
125
218
|
writeFileSync(join(home, '.runtime', '.python-ready'), new Date().toISOString());
|
|
126
|
-
console.log('Dependências instaladas. Runtime Python pronto.');
|
|
127
219
|
}
|
|
128
220
|
|
|
129
221
|
writeFileSync(join(home, '.runtime', '.ready'), new Date().toISOString());
|
package/scripts/start.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// provisionado, executa o install completo (Python portátil + dependências)
|
|
5
5
|
// antes de subir os servidores. A UI começa em PANDANEWS_UI_PORT (padrão 4030)
|
|
6
6
|
// e, se a porta estiver ocupada, tenta automaticamente a seguinte (4031, ...).
|
|
7
|
+
// Requisições /api/* são proxies para o backend em 127.0.0.1:8000.
|
|
7
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
9
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { createServer } from 'node:http';
|
|
10
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
10
11
|
import { join, extname } from 'node:path';
|
|
11
12
|
import { resolveHome, runInstall, PKG_ROOT } from './install.js';
|
|
12
13
|
|
|
@@ -14,6 +15,8 @@ const BACKEND_PORT = Number(process.env.PANDANEWS_API_PORT || 8000);
|
|
|
14
15
|
const UI_PORT_BASE = Number(process.env.PANDANEWS_UI_PORT || 4030);
|
|
15
16
|
const UI_PORT_MAX_TRIES = 20;
|
|
16
17
|
|
|
18
|
+
const FAVICON_URI = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230b1220'/%3E%3Ccircle cx='25' cy='28' r='13' fill='%23e6edf3'/%3E%3Ccircle cx='75' cy='28' r='13' fill='%23e6edf3'/%3E%3Ccircle cx='50' cy='52' r='30' fill='%23e6edf3'/%3E%3Cellipse cx='37' cy='48' rx='9' ry='11' fill='%230b1220' transform='rotate(-15 37 48)'/%3E%3Cellipse cx='63' cy='48' rx='9' ry='11' fill='%230b1220' transform='rotate(15 63 48)'/%3E%3Ccircle cx='38' cy='49' r='3' fill='%2322c55e'/%3E%3Ccircle cx='62' cy='49' r='3' fill='%2322c55e'/%3E%3Cellipse cx='50' cy='62' rx='5' ry='4' fill='%230b1220'/%3E%3C/svg%3E";
|
|
19
|
+
|
|
17
20
|
function venvBin(home) {
|
|
18
21
|
return process.platform === 'win32'
|
|
19
22
|
? join(home, '.runtime', 'venv', 'Scripts', 'python.exe')
|
|
@@ -34,27 +37,70 @@ const MIME = {
|
|
|
34
37
|
function placeholderPage() {
|
|
35
38
|
return `<!DOCTYPE html>
|
|
36
39
|
<html lang="pt-BR">
|
|
37
|
-
<head
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<title>PandaNews</title>
|
|
43
|
+
<link rel="icon" href="${FAVICON_URI}">
|
|
44
|
+
</head>
|
|
38
45
|
<body style="font-family: Inter, system-ui, sans-serif; background: #0b1220; color: #e6edf3; display: flex; min-height: 100vh; align-items: center; justify-content: center;">
|
|
39
46
|
<div style="text-align: center;">
|
|
40
47
|
<h1 style="font-size: 3rem; margin: 0;">🐼 PandaNews</h1>
|
|
41
|
-
<p style="color: #9aa7b5;">Backend ativo em <a style="color: #22c55e;" href="
|
|
42
|
-
<p style="color: #9aa7b5;">A UI React completa chega na versão 0.2.00
|
|
48
|
+
<p style="color: #9aa7b5;">Backend ativo em <a style="color: #22c55e;" href="/api/v1/health">/api/v1/health</a></p>
|
|
49
|
+
<p style="color: #9aa7b5;">A UI React completa chega na versão 0.2.00.</p>
|
|
43
50
|
</div>
|
|
44
51
|
</body>
|
|
45
52
|
</html>`;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
// Encaminha /api/* para o backend FastAPI em 127.0.0.1:8000.
|
|
56
|
+
function proxyApi(req, res) {
|
|
57
|
+
const options = {
|
|
58
|
+
hostname: '127.0.0.1',
|
|
59
|
+
port: BACKEND_PORT,
|
|
60
|
+
path: req.url,
|
|
61
|
+
method: req.method,
|
|
62
|
+
headers: { ...req.headers, host: `127.0.0.1:${BACKEND_PORT}` },
|
|
63
|
+
};
|
|
64
|
+
const proxied = httpRequest(options, (upstream) => {
|
|
65
|
+
res.writeHead(upstream.statusCode || 502, upstream.headers);
|
|
66
|
+
upstream.pipe(res);
|
|
67
|
+
});
|
|
68
|
+
proxied.on('error', () => {
|
|
69
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
70
|
+
res.end(JSON.stringify({ detail: `Backend PandaNews não responde em 127.0.0.1:${BACKEND_PORT}` }));
|
|
71
|
+
});
|
|
72
|
+
req.pipe(proxied);
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
let uiServer = null;
|
|
49
76
|
|
|
50
77
|
function createUiServer() {
|
|
51
78
|
const distDir = join(PKG_ROOT, 'frontend', 'dist');
|
|
52
79
|
return createServer((req, res) => {
|
|
80
|
+
if (req.url.startsWith('/api')) {
|
|
81
|
+
proxyApi(req, res);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
53
84
|
const url = req.url.split('?')[0];
|
|
54
85
|
const filePath = join(distDir, url === '/' ? 'index.html' : url.slice(1));
|
|
55
86
|
if (!existsSync(filePath)) {
|
|
56
|
-
|
|
57
|
-
|
|
87
|
+
// SPA (BrowserRouter): rotas sem extensão caem no index.html quando o
|
|
88
|
+
// build existe; sem build, mostra o placeholder.
|
|
89
|
+
const indexHtml = join(distDir, 'index.html');
|
|
90
|
+
const hasBuild = existsSync(indexHtml);
|
|
91
|
+
const isAsset = extname(url) !== '';
|
|
92
|
+
if (hasBuild && !isAsset) {
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
94
|
+
res.end(readFileSync(indexHtml));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!hasBuild) {
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
99
|
+
res.end(placeholderPage());
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
103
|
+
res.end('Not found');
|
|
58
104
|
return;
|
|
59
105
|
}
|
|
60
106
|
res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
|
|
@@ -112,7 +158,11 @@ export async function runStart() {
|
|
|
112
158
|
const uvicorn = spawn(
|
|
113
159
|
venvBin(home),
|
|
114
160
|
['-m', 'uvicorn', 'backend.app.main:app', '--host', '127.0.0.1', '--port', String(BACKEND_PORT)],
|
|
115
|
-
{
|
|
161
|
+
{
|
|
162
|
+
cwd: PKG_ROOT,
|
|
163
|
+
stdio: 'inherit',
|
|
164
|
+
env: { ...process.env, PANDANEWS_HOME: home },
|
|
165
|
+
},
|
|
116
166
|
);
|
|
117
167
|
uvicorn.on('exit', (code) => {
|
|
118
168
|
if (code !== 0 && code !== null) {
|