primocode 10.5.0 → 10.6.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/README.md +2 -2
- package/bin/primocode.js +117 -24
- package/lib/act.js +141 -9
- package/lib/agents-md.js +140 -0
- package/lib/auditoria.js +122 -0
- package/lib/browser.js +48 -1
- package/lib/catalogo.js +24 -4
- package/lib/comandos-extra.js +2 -0
- package/lib/config.js +32 -4
- package/lib/entrada.js +33 -11
- package/lib/estudio/colecao.js +34 -1
- package/lib/estudio/diretor.js +1 -0
- package/lib/estudio/falantes.js +30 -6
- package/lib/estudio/hibrido.js +144 -8
- package/lib/estudio/index.js +151 -59
- package/lib/estudio/kit/mundo.jsx +1060 -0
- package/lib/estudio/kit/voxel.jsx +315 -70
- package/lib/estudio/kit.js +1 -1
- package/lib/estudio/kokoro.js +18 -4
- package/lib/estudio/margem.js +1 -1
- package/lib/estudio/mundo.js +305 -0
- package/lib/estudio/personagem.js +164 -11
- package/lib/estudio/producao.js +107 -17
- package/lib/estudio/projeto.js +77 -22
- package/lib/leitura.js +176 -0
- package/lib/local/cerebro.json +180 -3
- package/lib/local/entrada.js +61 -4
- package/lib/mcp.js +296 -12
- package/lib/memoria-conversa.js +32 -3
- package/lib/mencoes.js +5 -1
- package/lib/nome-curto.js +126 -0
- package/lib/nome-projeto.js +5 -2
- package/lib/outra-pasta.js +161 -0
- package/lib/perigo.js +34 -2
- package/lib/referencia.js +40 -11
- package/lib/repeticao.js +20 -1
- package/lib/tela.js +1 -1
- package/lib/tools.js +76 -6
- package/lib/travado.js +141 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
package/lib/estudio/producao.js
CHANGED
|
@@ -22,11 +22,22 @@
|
|
|
22
22
|
* provedor nenhum.
|
|
23
23
|
*
|
|
24
24
|
* Puro de propósito: a rede e o disco moram nos provedores, então o teste roda
|
|
25
|
-
* sem chave e sem internet.
|
|
25
|
+
* sem chave e sem internet. A exceção é o cacheDe(pasta), que guarda o que já
|
|
26
|
+
* foi produzido num projeto.
|
|
27
|
+
*
|
|
28
|
+
* ── UMA NECESSIDADE, UMA CORRENTE ────────────────────────────────────────
|
|
29
|
+
* produzirUma(pedido, provedores, { escolha, cache }) a mesma corrente para
|
|
30
|
+
* a voz, a trilha e os efeitos do index.js. `escolha` (do roteiro:
|
|
31
|
+
* `provedores: { voz: 'kokoro' }`) passa um provedor para a frente.
|
|
32
|
+
* O cache é por necessidade + provedor: trocar a voz só refaz a voz.
|
|
26
33
|
*/
|
|
27
34
|
|
|
28
35
|
'use strict';
|
|
29
36
|
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
|
|
30
41
|
// Quantas fotos cada tipo de parte do cinema costuma consumir.
|
|
31
42
|
const FOTOS = { montagem: 3, climax: 4, numeros: 2, escada: 2, dia: 1 };
|
|
32
43
|
|
|
@@ -63,28 +74,107 @@ function necessidades(roteiro, { falaDe = textoPadrao } = {}) {
|
|
|
63
74
|
return pedidos;
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/** O provedor escolhido no roteiro vai para a frente; o resto segue na ordem. */
|
|
78
|
+
function ordenar(provedores, escolha) {
|
|
79
|
+
const lista = (provedores || []).filter(Boolean);
|
|
80
|
+
const i = escolha ? lista.findIndex((pv) => pv.nome === String(escolha).toLowerCase()) : -1;
|
|
81
|
+
return i > 0 ? [lista[i], ...lista.slice(0, i), ...lista.slice(i + 1)] : lista;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A chave do cache: o que a cena pede (pedido.chave, ou o pedido todo) + quem gera. */
|
|
85
|
+
function chaveDe(pedido, nome) {
|
|
86
|
+
const { destino, ...resto } = pedido || {};
|
|
87
|
+
const quer = resto.chave !== undefined ? [resto.tipo, resto.chave] : resto;
|
|
88
|
+
return crypto.createHash('sha1').update(JSON.stringify([quer, nome])).digest('hex').slice(0, 16);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Uma necessidade pela corrente: o primeiro provedor que entrega ganha. Erro,
|
|
93
|
+
* null ou { ok: false } cai no próximo; indisponível nem é chamado. Antes de
|
|
94
|
+
* gerar, cada provedor olha o cache dele (a mesma necessidade, o mesmo provedor).
|
|
95
|
+
*/
|
|
96
|
+
async function produzirUma(pedido, provedores, { escolha, cache, log = () => {} } = {}) {
|
|
97
|
+
const tentou = [];
|
|
98
|
+
let ultimo = null;
|
|
99
|
+
for (const pv of ordenar(provedores, escolha)) {
|
|
100
|
+
if (pv.disponivel && !pv.disponivel()) continue;
|
|
101
|
+
const chave = chaveDe(pedido, pv.nome);
|
|
102
|
+
const guardado = cache && cache.ler(chave, pedido);
|
|
103
|
+
if (guardado) return { ...pedido, ...guardado, ok: true, quem: pv.nome, doCache: true, tentou };
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = await pv.gerar(pedido);
|
|
107
|
+
if (r && r.ok !== false) {
|
|
108
|
+
if (cache && !r.naoGuardar) cache.gravar(chave, r);
|
|
109
|
+
return { ...pedido, ...r, ok: true, quem: pv.nome, tentou };
|
|
110
|
+
}
|
|
111
|
+
tentou.push({ nome: pv.nome, motivo: (r && r.error) || 'não devolveu nada' });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
tentou.push({ nome: pv.nome, motivo: (e && e.message) || String(e) });
|
|
114
|
+
}
|
|
115
|
+
if (r) ultimo = r;
|
|
116
|
+
log(`${pedido.tipo}: ${pv.nome} falhou, próximo`);
|
|
117
|
+
}
|
|
118
|
+
return { ...pedido, ...(ultimo || {}), ok: false, error: (ultimo && ultimo.error) || `ninguém produziu ${pedido.tipo}`, tentou };
|
|
119
|
+
}
|
|
120
|
+
|
|
66
121
|
/** Cada pedido pela corrente dos provedores do tipo dele, na ordem da lista. */
|
|
67
|
-
async function produzir(pedidos, provedores, { log = () => {} } = {}) {
|
|
122
|
+
async function produzir(pedidos, provedores, { log = () => {}, escolhas = {}, cache } = {}) {
|
|
68
123
|
const itens = [];
|
|
69
124
|
for (const pedido of pedidos || []) {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
for (const pv of (provedores || []).filter((x) => x && x.tipo === pedido.tipo)) {
|
|
73
|
-
if (pv.disponivel && !pv.disponivel()) continue;
|
|
74
|
-
try {
|
|
75
|
-
const r = await pv.gerar(pedido);
|
|
76
|
-
if (r) { feito = { ...pedido, ...r, ok: true, quem: pv.nome, tentou }; break; }
|
|
77
|
-
tentou.push({ nome: pv.nome, motivo: 'não devolveu nada' });
|
|
78
|
-
} catch (e) {
|
|
79
|
-
tentou.push({ nome: pv.nome, motivo: (e && e.message) || String(e) });
|
|
80
|
-
}
|
|
81
|
-
log(`${pedido.tipo}: ${pv.nome} falhou, próximo`);
|
|
82
|
-
}
|
|
83
|
-
itens.push(feito || { ...pedido, ok: false, tentou });
|
|
125
|
+
const daqui = (provedores || []).filter((x) => x && x.tipo === pedido.tipo);
|
|
126
|
+
itens.push(await produzirUma(pedido, daqui, { escolha: escolhas[pedido.tipo], cache, log }));
|
|
84
127
|
}
|
|
85
128
|
return { itens, faltou: itens.filter((i) => !i.ok) };
|
|
86
129
|
}
|
|
87
130
|
|
|
131
|
+
/** Os créditos (licença aberta pede atribuição) de tudo o que foi produzido. */
|
|
132
|
+
function creditos(itens) {
|
|
133
|
+
return [...new Set((itens || []).filter((i) => i && i.ok).map((i) => i.credito).filter(Boolean))];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A linha do caderno: quem produziu cada coisa, e se veio do cache. */
|
|
137
|
+
function linhaCaderno(item) {
|
|
138
|
+
if (!item.ok) return `${item.tipo}: ninguém produziu (${(item.tentou || []).map((t) => t.nome).join(', ') || 'sem provedor'})`;
|
|
139
|
+
const antes = (item.tentou || []).length ? `; antes falhou: ${item.tentou.map((t) => t.nome).join(', ')}` : '';
|
|
140
|
+
return `${item.tipo}: ${item.quem} (${item.doCache ? 'do cache' : 'gerou'}${antes})${item.credito ? ' — ' + item.credito : ''}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* O cache de um projeto: midia/producao/cache.json. O arquivo de `r.arquivo`
|
|
145
|
+
* (a voz, que depois é acelerada no lugar) é copiado para lá e volta para o
|
|
146
|
+
* `pedido.destino` no acerto. Caminho "midia/..." que sumiu do disco é erro.
|
|
147
|
+
*/
|
|
148
|
+
function cacheDe(pasta) {
|
|
149
|
+
const dir = path.join(pasta, 'midia', 'producao');
|
|
150
|
+
const arq = path.join(dir, 'cache.json');
|
|
151
|
+
const ler = () => { try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return {}; } };
|
|
152
|
+
const caminhos = (v) => (typeof v === 'string' ? (/^midia\//.test(v) ? [v] : [])
|
|
153
|
+
: v && typeof v === 'object' ? Object.values(v).flatMap(caminhos) : []);
|
|
154
|
+
return {
|
|
155
|
+
ler(chave, pedido = {}) {
|
|
156
|
+
const e = ler()[chave];
|
|
157
|
+
if (!e) return null;
|
|
158
|
+
const { arquivoCache, ...r } = e;
|
|
159
|
+
if (caminhos(r).some((c) => !fs.existsSync(path.join(pasta, c)))) return null;
|
|
160
|
+
if (arquivoCache) {
|
|
161
|
+
if (!pedido.destino || !fs.existsSync(path.join(dir, arquivoCache))) return null;
|
|
162
|
+
fs.copyFileSync(path.join(dir, arquivoCache), pedido.destino);
|
|
163
|
+
}
|
|
164
|
+
return r;
|
|
165
|
+
},
|
|
166
|
+
gravar(chave, r) {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
const { arquivo, ...resto } = r;
|
|
170
|
+
const e = { ...resto };
|
|
171
|
+
if (arquivo && fs.existsSync(arquivo)) { e.arquivoCache = chave + path.extname(arquivo); fs.copyFileSync(arquivo, path.join(dir, e.arquivoCache)); }
|
|
172
|
+
fs.writeFileSync(arq, JSON.stringify({ ...ler(), [chave]: e }));
|
|
173
|
+
} catch { /* sem cache */ }
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
88
178
|
/** O resultado no formato que o montarCinema já recebe. */
|
|
89
179
|
function paraCinema({ itens = [] } = {}) {
|
|
90
180
|
const ok = itens.filter((i) => i.ok);
|
|
@@ -103,4 +193,4 @@ function paraCinema({ itens = [] } = {}) {
|
|
|
103
193
|
};
|
|
104
194
|
}
|
|
105
195
|
|
|
106
|
-
module.exports = { necessidades, produzir, paraCinema, FOTOS };
|
|
196
|
+
module.exports = { necessidades, produzir, produzirUma, ordenar, chaveDe, creditos, linhaCaderno, cacheDe, paraCinema, FOTOS };
|
package/lib/estudio/projeto.js
CHANGED
|
@@ -39,10 +39,13 @@ const os = require('os');
|
|
|
39
39
|
const path = require('path');
|
|
40
40
|
const crypto = require('crypto');
|
|
41
41
|
|
|
42
|
+
const { nomeCurto, semColisao } = require('../nome-curto.js');
|
|
43
|
+
|
|
42
44
|
const CASA = path.join(os.homedir(), '.primocode', 'studio');
|
|
43
45
|
const PROJETOS = path.join(CASA, 'projetos');
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
/* Nome de ARQUIVO (o mp4, o pdf da saída). A pasta do projeto não usa mais
|
|
48
|
+
isto: ver `criar` e lib/nome-curto.js. */
|
|
46
49
|
function apelidar(bruto) {
|
|
47
50
|
return String(bruto || '')
|
|
48
51
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
@@ -59,23 +62,64 @@ function garantirCasa() {
|
|
|
59
62
|
return PROJETOS;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
*
|
|
65
|
+
/* ── ONDE MORA CADA PROJETO ──────────────────────────────────────────────
|
|
66
|
+
* Antes: `projetos/2026-10-11-claude-e-humano-conversam-sobre-o-segund`. A
|
|
67
|
+
* data na frente servia para a lista sair em ordem, e o resto era a frase
|
|
68
|
+
* cortada no meio. O dono: "essa estrutura de pastas está horrível".
|
|
69
|
+
*
|
|
70
|
+
* Agora: `projetos/2026-10/claude-segundo-cerebro`. O MÊS vira pasta (a
|
|
71
|
+
* ordem cronológica continua de graça, e um mês cabe numa tela), e o nome é
|
|
72
|
+
* curto e legível (lib/nome-curto.js). O dia exato fica no projeto.json.
|
|
64
73
|
*
|
|
65
|
-
* O id
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
74
|
+
* O id continua sendo SÓ o nome da pasta — sem o mês. É ele que o modelo
|
|
75
|
+
* escreve, que vai na memória e que a pessoa cita; pôr barra nele faria o
|
|
76
|
+
* `pastaDe` recusar (e com razão). Por isso o nome é único entre TODOS os
|
|
77
|
+
* meses, e quem acha a pasta pelo id é o `pastaDe`, que olha os meses.
|
|
78
|
+
*
|
|
79
|
+
* Os projetos antigos (`projetos/2026-09-25-...`) ficam onde estão: nada é
|
|
80
|
+
* renomeado, e o id antigo continua abrindo o mesmo projeto. */
|
|
81
|
+
const MES = /^\d{4}-\d{2}$/;
|
|
82
|
+
|
|
83
|
+
function mesDe(data = new Date()) {
|
|
84
|
+
return `${data.getFullYear()}-${String(data.getMonth() + 1).padStart(2, '0')}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** As pastas de mês que existem, da mais nova para a mais velha. */
|
|
88
|
+
function meses() {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readdirSync(PROJETOS, { withFileTypes: true })
|
|
91
|
+
.filter((d) => d.isDirectory() && MES.test(d.name)).map((d) => d.name).sort().reverse();
|
|
92
|
+
} catch { return []; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Todos os ids que existem, antigos (soltos) e novos (dentro do mês). */
|
|
96
|
+
function idsTomados() {
|
|
97
|
+
const ids = [];
|
|
98
|
+
try {
|
|
99
|
+
for (const d of fs.readdirSync(PROJETOS, { withFileTypes: true })) {
|
|
100
|
+
if (d.isDirectory() && !MES.test(d.name)) ids.push(d.name);
|
|
101
|
+
}
|
|
102
|
+
} catch { /* casa vazia */ }
|
|
103
|
+
for (const m of meses()) {
|
|
104
|
+
try {
|
|
105
|
+
for (const d of fs.readdirSync(path.join(PROJETOS, m), { withFileTypes: true })) {
|
|
106
|
+
if (d.isDirectory()) ids.push(d.name);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* mês sumiu no meio */ }
|
|
109
|
+
}
|
|
110
|
+
return ids;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Abre um projeto novo, em `projetos/<AAAA-MM>/<nome-curto>`.
|
|
69
115
|
*/
|
|
70
116
|
function criar({ titulo, pedido, formato = 'video' }) {
|
|
71
117
|
garantirCasa();
|
|
72
|
-
const
|
|
73
|
-
const base =
|
|
74
|
-
|
|
75
|
-
let n = 2;
|
|
76
|
-
while (fs.existsSync(path.join(PROJETOS, id))) { id = `${base}-${n}`; n++; }
|
|
118
|
+
const texto = titulo || pedido;
|
|
119
|
+
const base = nomeCurto(texto) || 'projeto';
|
|
120
|
+
const id = semColisao(base, idsTomados(), `${titulo || ''} ${pedido || ''}`);
|
|
77
121
|
|
|
78
|
-
const pasta = path.join(PROJETOS, id);
|
|
122
|
+
const pasta = path.join(PROJETOS, mesDe(), id);
|
|
79
123
|
for (const sub of ['midia', 'saida', 'entrada']) {
|
|
80
124
|
fs.mkdirSync(path.join(pasta, sub), { recursive: true });
|
|
81
125
|
}
|
|
@@ -125,7 +169,18 @@ function pastaDe(id) {
|
|
|
125
169
|
if (typeof id !== 'string' || !id.trim()) return null;
|
|
126
170
|
const limpo = id.trim();
|
|
127
171
|
if (limpo.includes('/') || limpo.includes('\\') || limpo.includes('..')) return null;
|
|
128
|
-
|
|
172
|
+
// Um mês não é projeto: "2026-10" como id abriria a pasta do mês inteiro.
|
|
173
|
+
if (MES.test(limpo)) return null;
|
|
174
|
+
// O antigo, solto na casa (`2026-09-25-...`), continua valendo.
|
|
175
|
+
const solto = path.join(PROJETOS, limpo);
|
|
176
|
+
if (fs.existsSync(solto)) return solto;
|
|
177
|
+
for (const m of meses()) {
|
|
178
|
+
const dentro = path.join(PROJETOS, m, limpo);
|
|
179
|
+
if (fs.existsSync(dentro)) return dentro;
|
|
180
|
+
}
|
|
181
|
+
// Não existe ainda: o lugar de antes. Quem precisa que exista (`ler`)
|
|
182
|
+
// confere o projeto.json; este caminho só serve para dizer onde seria.
|
|
183
|
+
return solto;
|
|
129
184
|
}
|
|
130
185
|
|
|
131
186
|
/** O meta de um projeto, ou null. */
|
|
@@ -147,14 +202,13 @@ function salvar(id, mudancas) {
|
|
|
147
202
|
}
|
|
148
203
|
|
|
149
204
|
/** Todos, do mais novo para o mais velho. */
|
|
205
|
+
/* A ordem vinha do nome (a data na frente). Sem data no nome, ela vem do
|
|
206
|
+
`criado` do projeto.json — que todo projeto, velho ou novo, tem. */
|
|
150
207
|
function listar({ limite = 50 } = {}) {
|
|
151
208
|
garantirCasa();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
|
|
156
|
-
} catch { return []; }
|
|
157
|
-
return nomes.slice(0, limite).map(ler).filter(Boolean);
|
|
209
|
+
const todos = idsTomados().map(ler).filter(Boolean);
|
|
210
|
+
todos.sort((a, b) => String(b.criado || '').localeCompare(String(a.criado || '')) || (a.id < b.id ? 1 : -1));
|
|
211
|
+
return todos.slice(0, limite);
|
|
158
212
|
}
|
|
159
213
|
|
|
160
214
|
// ── o caderno ────────────────────────────────────────────────────────────
|
|
@@ -216,7 +270,8 @@ function procurar(texto, { limite = 5 } = {}) {
|
|
|
216
270
|
return { projeto: p, pontos };
|
|
217
271
|
}).filter((x) => x.pontos > 0);
|
|
218
272
|
|
|
219
|
-
pontuados.sort((a, b) => b.pontos - a.pontos
|
|
273
|
+
pontuados.sort((a, b) => b.pontos - a.pontos
|
|
274
|
+
|| String(b.projeto.criado || '').localeCompare(String(a.projeto.criado || '')));
|
|
220
275
|
return pontuados.slice(0, limite).map((x) => x.projeto);
|
|
221
276
|
}
|
|
222
277
|
|
|
@@ -278,7 +333,7 @@ function caminhoDeMidia(id, nome) {
|
|
|
278
333
|
|
|
279
334
|
module.exports = {
|
|
280
335
|
CASA, PROJETOS,
|
|
281
|
-
criar, ler, salvar, listar, procurar, pastaDe, apelidar,
|
|
336
|
+
criar, ler, salvar, listar, procurar, pastaDe, apelidar, mesDe,
|
|
282
337
|
anotar, caderno,
|
|
283
338
|
lerPlano, salvarPlano, versoes, voltarPara,
|
|
284
339
|
caminhoDeMidia, escreverJSON, lerJSON,
|
package/lib/leitura.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* leitura.js — ler uma página FORA do modelo, em cascata, com teste de tamanho.
|
|
3
|
+
*
|
|
4
|
+
* Ideia levada do `ax` (yusukebe/ax, "curl pra agente"): o modelo não precisa
|
|
5
|
+
* do HTML, precisa da ESTRUTURA — título, subtítulos, o texto principal, os
|
|
6
|
+
* links. Extrair isso aqui, de graça e de forma determinística, poupa a janela
|
|
7
|
+
* de contexto de um modelo gratuito, que é curta.
|
|
8
|
+
*
|
|
9
|
+
* A cascata para no primeiro passo que basta:
|
|
10
|
+
* 1. busca simples + extração local (sem navegador, sem modelo);
|
|
11
|
+
* 2. se saiu pouco texto (página que só existe depois do JavaScript), o
|
|
12
|
+
* navegador escondido de lib/browser.js renderiza e a extração roda de novo;
|
|
13
|
+
* 3. o que vai pro modelo cabe num orçamento de caracteres: corta por seção,
|
|
14
|
+
* e os subtítulos ficam todos — o modelo sabe o que existe na página
|
|
15
|
+
* mesmo quando o texto de uma seção não coube.
|
|
16
|
+
*
|
|
17
|
+
* A regra que vale mais que a ferramenta: o teste de "leu" é o TAMANHO do que
|
|
18
|
+
* saiu, não o status 200. Página que devolve 85 caracteres "deu certo" e não
|
|
19
|
+
* leu nada.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
// 400, o mesmo piso da pesquisa: casca de SPA deixa 200-300 caracteres de menu.
|
|
25
|
+
const MINIMO = 400;
|
|
26
|
+
const ORCAMENTO = 4500;
|
|
27
|
+
const MAX_LINKS = 12;
|
|
28
|
+
|
|
29
|
+
const ENTIDADES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", apos: "'" };
|
|
30
|
+
function desentidade(s) {
|
|
31
|
+
return String(s || '')
|
|
32
|
+
.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, (_, k) => ENTIDADES[k])
|
|
33
|
+
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(Number(n)); } catch { return ' '; } });
|
|
34
|
+
}
|
|
35
|
+
const limpo = (s) => desentidade(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
36
|
+
|
|
37
|
+
function atributo(tag, nome) {
|
|
38
|
+
const m = tag.match(new RegExp(`\\b${nome}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
|
39
|
+
return m ? desentidade(m[2] ?? m[3] ?? m[4] ?? '') : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** O conteúdo da primeira tag `nome` que tiver texto de verdade (main, article). */
|
|
43
|
+
function regiao(html, nome) {
|
|
44
|
+
const re = new RegExp(`<${nome}\\b[^>]*>([\\s\\S]*?)<\\/${nome}>`, 'gi');
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(html))) if (limpo(m[1]).length >= 200) return m[1];
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* HTML → estrutura legível. Sem dependência: expressão regular basta para o
|
|
52
|
+
* que o modelo precisa (não é um parser, e não tenta ser).
|
|
53
|
+
* @returns {{titulo:string, descricao:string, secoes:Array<{titulo:string,texto:string}>, texto:string, links:Array<{texto:string,url:string}>}}
|
|
54
|
+
*/
|
|
55
|
+
function estruturaDe(html, base) {
|
|
56
|
+
const bruto = String(html || '').replace(/<!--[\s\S]*?-->/g, ' ')
|
|
57
|
+
.replace(/<(script|style|noscript|svg|template|iframe)\b[\s\S]*?<\/\1>/gi, ' ');
|
|
58
|
+
const titulo = limpo((bruto.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1]);
|
|
59
|
+
const meta = (bruto.match(/<meta\b[^>]*(name|property)\s*=\s*["'](description|og:description)["'][^>]*>/i) || [])[0] || '';
|
|
60
|
+
const descricao = atributo(meta, 'content').trim();
|
|
61
|
+
|
|
62
|
+
// O miolo: <main>, senão <article>, senão o <body> sem menu, rodapé e lateral.
|
|
63
|
+
let corpo = regiao(bruto, 'main') || regiao(bruto, 'article')
|
|
64
|
+
|| ((bruto.match(/<body\b[^>]*>([\s\S]*)<\/body>/i) || [])[1] ?? bruto);
|
|
65
|
+
corpo = corpo.replace(/<(nav|footer|header|aside)\b[\s\S]*?<\/\1>/gi, ' ');
|
|
66
|
+
|
|
67
|
+
const links = [];
|
|
68
|
+
const vistos = new Set();
|
|
69
|
+
corpo.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_, attrs, dentro) => {
|
|
70
|
+
const href = atributo(attrs, 'href');
|
|
71
|
+
const texto = limpo(dentro);
|
|
72
|
+
if (!href || !texto || /^(#|javascript:|mailto:)/i.test(href) || links.length >= MAX_LINKS) return '';
|
|
73
|
+
let url = href;
|
|
74
|
+
try { url = new URL(href, base || undefined).href; } catch { /* fica relativo */ }
|
|
75
|
+
if (!vistos.has(url)) { vistos.add(url); links.push({ texto: texto.slice(0, 80), url }); }
|
|
76
|
+
return '';
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Subtítulo vira marcador; bloco vira quebra de linha; o resto some.
|
|
80
|
+
const marcado = corpo
|
|
81
|
+
.replace(/<h([1-3])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_, n, t) => `\n\u0001${limpo(t)}\n`)
|
|
82
|
+
.replace(/<(br|\/p|\/li|\/div|\/section|\/tr|\/h[4-6]|\/pre|\/blockquote)\b[^>]*>/gi, '\n')
|
|
83
|
+
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
84
|
+
.replace(/<[^>]+>/g, ' ');
|
|
85
|
+
const secoes = [{ titulo: '', texto: '' }];
|
|
86
|
+
for (const linhaCrua of desentidade(marcado).split('\n')) {
|
|
87
|
+
const linha = linhaCrua.replace(/\s+/g, ' ').trim();
|
|
88
|
+
if (!linha || linha === '-') continue;
|
|
89
|
+
if (linha.startsWith('\u0001')) secoes.push({ titulo: linha.slice(1).trim(), texto: '' });
|
|
90
|
+
else secoes[secoes.length - 1].texto += (secoes[secoes.length - 1].texto ? '\n' : '') + linha;
|
|
91
|
+
}
|
|
92
|
+
const uteis = secoes.filter((s) => s.titulo || s.texto);
|
|
93
|
+
const texto = uteis.map((s) => [s.titulo, s.texto].filter(Boolean).join('\n')).join('\n');
|
|
94
|
+
return { titulo, descricao, secoes: uteis, texto, links };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cortar(texto, max) {
|
|
98
|
+
if (texto.length <= max) return texto;
|
|
99
|
+
if (max < 20) return '';
|
|
100
|
+
const corte = texto.slice(0, max - 1);
|
|
101
|
+
const espaco = corte.lastIndexOf(' ');
|
|
102
|
+
return (espaco > max * 0.6 ? corte.slice(0, espaco) : corte) + '…';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Passo 3: o que vai pro modelo cabe em `orcamento` caracteres. Os subtítulos
|
|
107
|
+
* ficam TODOS; o texto de cada seção divide o que sobra por igual, e seção
|
|
108
|
+
* curta devolve a sobra pras outras (enchimento por nível).
|
|
109
|
+
*/
|
|
110
|
+
function caber(est, orcamento = ORCAMENTO) {
|
|
111
|
+
const cabeca = [est.titulo && `# ${est.titulo}`, est.descricao && `> ${est.descricao}`].filter(Boolean).join('\n');
|
|
112
|
+
const listaLinks = est.links.length
|
|
113
|
+
? '\nLinks:\n' + est.links.map((l) => `- ${l.texto} — ${l.url}`).join('\n') : '';
|
|
114
|
+
const linksCabem = cortar(listaLinks, Math.floor(orcamento * 0.15));
|
|
115
|
+
const titulos = est.secoes.reduce((n, s) => n + (s.titulo ? s.titulo.length + 5 : 0) + 1, 0);
|
|
116
|
+
let livre = Math.max(0, orcamento - cabeca.length - linksCabem.length - titulos - 2);
|
|
117
|
+
|
|
118
|
+
const cotas = new Array(est.secoes.length).fill(0);
|
|
119
|
+
let pendentes = est.secoes.map((_, i) => i).filter((i) => est.secoes[i].texto);
|
|
120
|
+
while (pendentes.length && livre > 0) {
|
|
121
|
+
const parte = Math.floor(livre / pendentes.length);
|
|
122
|
+
const curtas = pendentes.filter((i) => est.secoes[i].texto.length <= parte);
|
|
123
|
+
if (!curtas.length) { pendentes.forEach((i) => { cotas[i] = parte; }); break; }
|
|
124
|
+
curtas.forEach((i) => { cotas[i] = est.secoes[i].texto.length; livre -= cotas[i]; });
|
|
125
|
+
pendentes = pendentes.filter((i) => !curtas.includes(i));
|
|
126
|
+
}
|
|
127
|
+
let cortado = linksCabem.length < listaLinks.length;
|
|
128
|
+
const corpo = est.secoes.map((s, i) => {
|
|
129
|
+
const t = cortar(s.texto, cotas[i]);
|
|
130
|
+
if (t.length < s.texto.length) cortado = true;
|
|
131
|
+
return [s.titulo && `## ${s.titulo}`, t].filter(Boolean).join('\n');
|
|
132
|
+
}).join('\n');
|
|
133
|
+
const conteudo = [cabeca, corpo].filter(Boolean).join('\n\n') + linksCabem;
|
|
134
|
+
// A cerca final: nenhum caminho passa do orçamento.
|
|
135
|
+
return { conteudo: conteudo.slice(0, orcamento), cortado: cortado || conteudo.length > orcamento };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const bastante = (est, minimo) => !!est && est.texto.length >= minimo;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A cascata inteira. `pegar(url)` devolve o HTML da busca simples; `navegador(url)`
|
|
142
|
+
* devolve o HTML depois do JavaScript (opcional). `html` pula o passo 1 de rede
|
|
143
|
+
* quando quem chama já baixou a página.
|
|
144
|
+
* @returns {Promise<{ok:boolean, passo:number, como:string, chars:number, enviados:number, cortado:boolean, conteudo:string, titulo:string, texto:string}>}
|
|
145
|
+
*/
|
|
146
|
+
async function ler(url, { pegar, navegador, html, orcamento = ORCAMENTO, minimo = MINIMO } = {}) {
|
|
147
|
+
const monta = (passo, como, est) => {
|
|
148
|
+
const { conteudo, cortado } = caber(est, orcamento);
|
|
149
|
+
return { ok: true, url, passo, como, chars: est.texto.length, enviados: conteudo.length,
|
|
150
|
+
cortado, conteudo, titulo: est.titulo, texto: est.texto };
|
|
151
|
+
};
|
|
152
|
+
let e1 = null;
|
|
153
|
+
let semScript = false;
|
|
154
|
+
try {
|
|
155
|
+
const h = html != null ? html : (pegar ? await pegar(url) : null);
|
|
156
|
+
if (h) { e1 = estruturaDe(h, url); semScript = !/<script\b/i.test(h); }
|
|
157
|
+
} catch { /* passo 1 falhou: o navegador tenta */ }
|
|
158
|
+
if (bastante(e1, minimo)) return monta(1, 'busca simples', e1);
|
|
159
|
+
|
|
160
|
+
// Página sem nenhum <script> é curta de verdade: o navegador não acharia mais nada.
|
|
161
|
+
let e2 = null;
|
|
162
|
+
if (navegador && !semScript) {
|
|
163
|
+
try {
|
|
164
|
+
const h = await navegador(url);
|
|
165
|
+
if (h) e2 = estruturaDe(h, url);
|
|
166
|
+
} catch { /* sem navegador: fica o que o passo 1 trouxe */ }
|
|
167
|
+
if (bastante(e2, minimo)) return monta(2, 'navegador (a página precisa de JavaScript)', e2);
|
|
168
|
+
}
|
|
169
|
+
const melhor = [e1, e2].filter(Boolean).sort((a, b) => b.texto.length - a.texto.length)[0];
|
|
170
|
+
return { ok: false, url, passo: e2 ? 2 : 1, como: 'nenhum passo leu o bastante',
|
|
171
|
+
chars: melhor ? melhor.texto.length : 0, enviados: 0, cortado: false, conteudo: '',
|
|
172
|
+
titulo: melhor ? melhor.titulo : '', texto: melhor ? melhor.texto : '',
|
|
173
|
+
error: `a página deu ${melhor ? melhor.texto.length : 0} caracteres de texto (o mínimo é ${minimo})` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { ler, estruturaDe, caber, MINIMO, ORCAMENTO };
|