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.
@@ -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 tentou = [];
71
- let feito = null;
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 };
@@ -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
- /** Nome de pasta a partir do que a pessoa pediu. */
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
- * Abre um projeto novo.
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 leva a DATA na frente. Não é enfeite: a pasta é listada em ordem
66
- * alfabética por quase tudo que lê disco, e com a data na frente essa ordem
67
- * já é a cronológica — que é como se procura um vídeo ("o de semana
68
- * passada"), não por nome.
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 dia = new Date().toISOString().slice(0, 10);
73
- const base = `${dia}-${apelidar(titulo || pedido)}`;
74
- let id = base;
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
- return path.join(PROJETOS, limpo);
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
- let nomes = [];
153
- try {
154
- nomes = fs.readdirSync(PROJETOS, { withFileTypes: true })
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 || (a.projeto.id < b.projeto.id ? 1 : -1));
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 };