primocode 8.42.1 → 9.0.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.
Files changed (102) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +599 -327
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +31 -192
  7. package/lib/catalogo.js +60 -60
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +545 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +606 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +13 -1
  23. package/lib/marca.js +4 -1
  24. package/lib/marketplace.js +189 -0
  25. package/lib/permissao.js +137 -0
  26. package/lib/registro.js +229 -0
  27. package/lib/skills.js +329 -0
  28. package/lib/tools.js +51 -181
  29. package/lib/ui.js +7 -3
  30. package/lib/voz.js +239 -0
  31. package/package.json +12 -3
  32. package/skills/acessibilidade/SKILL.md +28 -0
  33. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  34. package/skills/depurar-de-verdade/SKILL.md +29 -0
  35. package/skills/editar-meu-video/SKILL.md +59 -0
  36. package/skills/escrever-em-portugues/SKILL.md +28 -0
  37. package/skills/git-limpo/SKILL.md +28 -0
  38. package/skills/motion-de-primeira/SKILL.md +62 -0
  39. package/skills/pagina-que-converte/SKILL.md +28 -0
  40. package/skills/performance-web/SKILL.md +28 -0
  41. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  42. package/skills/slides-que-prendem/SKILL.md +40 -0
  43. package/studio/edge.py +16 -66
  44. package/studio/narrar.py +274 -0
  45. package/studio/pessoa.py +167 -0
  46. package/voz/primo_voz/__init__.py +0 -0
  47. package/voz/primo_voz/__main__.py +325 -0
  48. package/voz/primo_voz/escuta.py +235 -0
  49. package/voz/primo_voz/orbe.py +216 -0
  50. package/lib/audio-livre.js +0 -177
  51. package/lib/chaves.js +0 -149
  52. package/lib/claude-engine.js +0 -631
  53. package/lib/codex-engine.js +0 -208
  54. package/lib/remotion.js +0 -663
  55. package/lib/studio.js +0 -960
  56. package/studio/PRIMOCODE.md +0 -58
  57. package/studio/README.md +0 -154
  58. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  59. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  60. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  61. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  62. package/studio/ferramentas/00-protocolo.md +0 -125
  63. package/studio/ferramentas/01-modelos.md +0 -270
  64. package/studio/ferramentas/README.md +0 -32
  65. package/studio/ferramentas/corte.md +0 -919
  66. package/studio/ferramentas/grade.md +0 -92
  67. package/studio/ferramentas/prisma.md +0 -107
  68. package/studio/ferramentas/prosa.md +0 -86
  69. package/studio/ferramentas/tela.md +0 -93
  70. package/studio/ferramentas/traco.md +0 -79
  71. package/studio/server.py +0 -2254
  72. package/studio/studio.log +0 -57
  73. package/studio/voz.py +0 -388
  74. package/studio/web/analise.html +0 -52
  75. package/studio/web/core.css +0 -1167
  76. package/studio/web/fontes/bebas.woff2 +0 -0
  77. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  78. package/studio/web/fontes/inter-400.woff2 +0 -0
  79. package/studio/web/fontes/inter-700.woff2 +0 -0
  80. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  81. package/studio/web/fontes/playfair-700.woff2 +0 -0
  82. package/studio/web/fontes.css +0 -23
  83. package/studio/web/home.html +0 -217
  84. package/studio/web/js/analise.js +0 -510
  85. package/studio/web/js/audio.js +0 -607
  86. package/studio/web/js/editor.js +0 -1802
  87. package/studio/web/js/exportar.js +0 -222
  88. package/studio/web/js/ferramentas.js +0 -108
  89. package/studio/web/js/icones.js +0 -120
  90. package/studio/web/js/midia.js +0 -157
  91. package/studio/web/js/narracao.js +0 -325
  92. package/studio/web/js/player.js +0 -903
  93. package/studio/web/js/render-grade.js +0 -243
  94. package/studio/web/js/render-prosa.js +0 -104
  95. package/studio/web/js/render.js +0 -1852
  96. package/studio/web/js/sfx.js +0 -233
  97. package/studio/web/js/video.js +0 -464
  98. package/studio/web/js/voz-neural.js +0 -152
  99. package/studio/web/render.css +0 -1352
  100. package/studio/web/tf.html +0 -15
  101. package/studio/web/tool.html +0 -78
  102. package/studio/web/view.html +0 -27
@@ -0,0 +1,560 @@
1
+ /**
2
+ * identidade.js — o vídeo a partir de um link.
3
+ *
4
+ * "Eu devo poder mandar só um link: 'analise esse site e faça o vídeo'. A
5
+ * IA abre o site, analisa, extrai a identidade visual (cores, tipografia,
6
+ * logo, tom), monta o roteiro, planeja a estrutura cena a cena, produz e
7
+ * renderiza."
8
+ *
9
+ * ── O QUE ESTE MÓDULO FAZ, E O QUE ELE NÃO FAZ ───────────────────────────
10
+ * Ele FAZ a parte que é medição: baixa a página e as folhas de estilo dela,
11
+ * conta as cores que realmente aparecem, lê as famílias de fonte declaradas,
12
+ * acha o logo e recolhe o texto que a marca usa para se descrever.
13
+ *
14
+ * Ele NÃO escreve o roteiro nem monta as cenas. Isso é trabalho do modelo, e
15
+ * um módulo que tentasse fazê-lo viraria um gerador de vídeo padrão — o
16
+ * problema que este trabalho inteiro está desfazendo.
17
+ *
18
+ * A divisão importa: o que sai daqui é FATO sobre a marca (esta cor aparece
19
+ * 41 vezes, esta fonte é a dos títulos, este é o logo). Fato o modelo não
20
+ * inventa. O que ele faz com isso é dele.
21
+ *
22
+ * ── POR QUE CONTAR A FREQUÊNCIA, E NÃO PEGAR A PRIMEIRA COR ──────────────
23
+ * Uma folha de estilo tem dezenas de cores, e a primeira delas costuma ser um
24
+ * cinza de borda. A cor da MARCA é a que se repete — ela está no botão, no
25
+ * link, no cabeçalho e no rodapé. Contar ocorrências acha a cor certa sem
26
+ * ninguém precisar dizer qual é.
27
+ *
28
+ * Cinzas e quase-pretos são separados das cores com vida, porque um site
29
+ * qualquer tem muito mais cinza que cor — e a paleta da marca não é cinza.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const https = require('https');
37
+ const referencia = require('../referencia.js');
38
+ const assets = require('./assets.js');
39
+ const marca = require('../marca.js');
40
+ const acervo = require('../acervo.js');
41
+ const http = require('http');
42
+ const { URL } = require('url');
43
+
44
+ const PRAZO = 15000;
45
+ const MAX_BYTES = 3 * 1024 * 1024;
46
+
47
+ function buscar(endereco, { redirecoes = 3 } = {}) {
48
+ return new Promise((resolve, reject) => {
49
+ let u;
50
+ try { u = new URL(endereco); } catch { return reject(new Error(`"${endereco}" não é um endereço`)); }
51
+ const lib = u.protocol === 'https:' ? https : http;
52
+ const req = lib.get({
53
+ hostname: u.hostname,
54
+ port: u.port || undefined,
55
+ path: u.pathname + u.search,
56
+ headers: {
57
+ 'User-Agent': 'Mozilla/5.0 (compatible; PrimoCode-Studio/1.0)',
58
+ Accept: 'text/html,text/css,*/*',
59
+ },
60
+ }, (res) => {
61
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirecoes > 0) {
62
+ res.resume();
63
+ return resolve(buscar(new URL(res.headers.location, endereco).toString(), { redirecoes: redirecoes - 1 }));
64
+ }
65
+ if (res.statusCode >= 400) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
66
+ let corpo = '';
67
+ let tamanho = 0;
68
+ res.setEncoding('utf8');
69
+ res.on('data', (d) => {
70
+ tamanho += d.length;
71
+ // Página gigante não é útil: o que interessa está no começo,
72
+ // e ler 50 MB de HTML gerado trava o processo por nada.
73
+ if (tamanho > MAX_BYTES) { req.destroy(); return; }
74
+ corpo += d;
75
+ });
76
+ res.on('end', () => resolve({ corpo, url: endereco }));
77
+ });
78
+ const relogio = setTimeout(() => req.destroy(new Error('o site demorou demais')), PRAZO);
79
+ if (relogio.unref) relogio.unref();
80
+ req.on('close', () => clearTimeout(relogio));
81
+ req.on('error', reject);
82
+ });
83
+ }
84
+
85
+ // ── cores ────────────────────────────────────────────────────────────────
86
+
87
+ const HEX = /#([0-9a-f]{3}|[0-9a-f]{6})\b/gi;
88
+ const RGB = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/gi;
89
+
90
+ function normalizarHex(h) {
91
+ const s = h.replace('#', '').toLowerCase();
92
+ const cheio = s.length === 3 ? s.split('').map((x) => x + x).join('') : s;
93
+ return '#' + cheio;
94
+ }
95
+
96
+ function paraHex(r, g, b) {
97
+ return '#' + [r, g, b].map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('');
98
+ }
99
+
100
+ /** Saturação e luminosidade, para separar cor de cinza. */
101
+ function vivacidade(hex) {
102
+ const n = parseInt(hex.slice(1), 16);
103
+ const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
104
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
105
+ const lum = (max + min) / 2;
106
+ const sat = max === min ? 0 : (max - min) / (lum > 0.5 ? 2 - max - min : max + min);
107
+ return { sat, lum };
108
+ }
109
+
110
+ function colherCores(css) {
111
+ const conta = new Map();
112
+ const somar = (hex) => conta.set(hex, (conta.get(hex) || 0) + 1);
113
+
114
+ let m;
115
+ HEX.lastIndex = 0;
116
+ while ((m = HEX.exec(css))) somar(normalizarHex(m[0]));
117
+ RGB.lastIndex = 0;
118
+ while ((m = RGB.exec(css))) somar(paraHex(+m[1], +m[2], +m[3]));
119
+
120
+ const todas = [...conta.entries()]
121
+ .map(([hex, vezes]) => ({ hex, vezes, ...vivacidade(hex) }))
122
+ .sort((a, b) => b.vezes - a.vezes);
123
+
124
+ // A cor da marca tem saturação e não é quase branca nem quase preta.
125
+ const declaradas = new Set(variaveisDeCor(css).map((v) => v.cor));
126
+ // A cor que a marca declarou como tema entra na frente, por mais que ela
127
+ // apareça poucas vezes no arquivo.
128
+ const vivas = todas
129
+ .filter((c) => (c.sat > 0.22 && c.lum > 0.12 && c.lum < 0.9) || declaradas.has(c.hex))
130
+ .sort((a, b) => (declaradas.has(b.hex) - declaradas.has(a.hex)) || b.vezes - a.vezes);
131
+ const neutras = todas.filter((c) => c.sat <= 0.22);
132
+
133
+ return {
134
+ marca: vivas.slice(0, 5).map((c) => ({ cor: c.hex, vezes: c.vezes, declarada: declaradas.has(c.hex) })),
135
+ neutras: neutras.slice(0, 5).map((c) => ({ cor: c.hex, vezes: c.vezes })),
136
+ variaveis: variaveisDeCor(css).slice(0, 8),
137
+ };
138
+ }
139
+
140
+ // ── fontes ───────────────────────────────────────────────────────────────
141
+
142
+ const GENERICAS = new Set(['inherit', 'initial', 'unset', 'sans-serif', 'serif', 'monospace',
143
+ 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'cursive', 'fantasy',
144
+ '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica', 'arial',
145
+ 'helvetica neue', 'apple color emoji', 'segoe ui emoji', 'noto sans', 'liberation sans']);
146
+
147
+ function colherFontes(css) {
148
+ const conta = new Map();
149
+ const re = /font-family\s*:\s*([^;}"]+)/gi;
150
+ let m;
151
+ while ((m = re.exec(css))) {
152
+ for (const bruto of m[1].split(',')) {
153
+ const nome = bruto.trim().replace(/^["']|["']$/g, '');
154
+ if (!nome || GENERICAS.has(nome.toLowerCase())) continue;
155
+ if (nome.startsWith('var(') || nome.length > 40) continue;
156
+ conta.set(nome, (conta.get(nome) || 0) + 1);
157
+ }
158
+ }
159
+ // O Google Fonts aparece no @import e no <link>: é a pista mais confiável
160
+ // de qual fonte a marca escolheu, porque ela pagou o carregamento dela.
161
+ const doGoogle = new Set();
162
+ const g = /fonts\.googleapis\.com\/css2?\?([^"')]+)/gi;
163
+ while ((m = g.exec(css))) {
164
+ for (const par of m[1].split('&')) {
165
+ const f = par.match(/family=([^:&]+)/);
166
+ if (f) doGoogle.add(decodeURIComponent(f[1].replace(/\+/g, ' ')));
167
+ }
168
+ }
169
+ return {
170
+ declaradas: [...conta.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
171
+ .map(([nome, vezes]) => ({ fonte: nome, vezes })),
172
+ doGoogleFonts: [...doGoogle],
173
+ };
174
+ }
175
+
176
+ // ── o resto da identidade ────────────────────────────────────────────────
177
+
178
+ function tag(html, re) { const m = html.match(re); return m ? m[1].trim() : ''; }
179
+
180
+ function colherTexto(html) {
181
+ const titulo = tag(html, /<title[^>]*>([\s\S]{1,200}?)<\/title>/i);
182
+ const descricao = tag(html, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']{1,400})/i)
183
+ || tag(html, /<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{1,400})/i);
184
+ const marca = tag(html, /<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']{1,80})/i);
185
+
186
+ // Os títulos da página são o que a marca escolheu dizer primeiro — a
187
+ // matéria-prima do roteiro, e a única que não é invenção.
188
+ const titulos = [];
189
+ const re = /<h([12])[^>]*>([\s\S]{1,160}?)<\/h\1>/gi;
190
+ let m;
191
+ while ((m = re.exec(html)) && titulos.length < 14) {
192
+ const limpo = m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
193
+ if (limpo.length > 2) titulos.push(limpo);
194
+ }
195
+ return { titulo, descricao, marca: marca || titulo.split(/[|\-–—]/)[0].trim(), titulos };
196
+ }
197
+
198
+ function colherImagens(html, base) {
199
+ const absoluto = (u) => { try { return new URL(u, base).toString(); } catch { return null; } };
200
+ const logo = tag(html, /<link[^>]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)/i)
201
+ || tag(html, /<img[^>]+(?:class|id|alt)=["'][^"']*logo[^"']*["'][^>]*src=["']([^"']+)/i)
202
+ || tag(html, /<img[^>]+src=["']([^"']+)["'][^>]*(?:class|id|alt)=["'][^"']*logo/i);
203
+ const capa = tag(html, /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i);
204
+ return { logo: logo ? absoluto(logo) : null, capa: capa ? absoluto(capa) : null };
205
+ }
206
+
207
+ /**
208
+ * Os endereços das folhas de estilo.
209
+ *
210
+ * Pegar só `rel="stylesheet"` perde metade dos sites modernos: eles declaram
211
+ * `rel="preload" as="style"` para carregar antes, e os atributos vêm em
212
+ * qualquer ordem. Então a regra é mais simples e mais certeira: todo `<link>`
213
+ * cujo href termina em `.css`.
214
+ */
215
+ function folhasDe(html, base) {
216
+ const saida = [];
217
+ const re = /<link\b[^>]*href=["']([^"']+\.css[^"']*)["'][^>]*>/gi;
218
+ let m;
219
+ while ((m = re.exec(html)) && saida.length < 8) {
220
+ try { saida.push(new URL(m[1], base).toString()); } catch { /* ignora */ }
221
+ }
222
+ return [...new Set(saida)];
223
+ }
224
+
225
+ /**
226
+ * As cores declaradas como VARIÁVEL de tema (`--brand: #00c86f`).
227
+ *
228
+ * Valem mais que a contagem bruta: uma variável de tema é a marca dizendo,
229
+ * por escrito, qual é a cor dela. Numa folha minificada de 400 KB, a cor do
230
+ * botão aparece uma vez e o cinza de borda aparece oitenta — a contagem
231
+ * sozinha elegeria o cinza.
232
+ */
233
+ function variaveisDeCor(css) {
234
+ const achadas = [];
235
+ const re = /--([\w-]*(?:brand|primar|accent|main|theme|color|cor)[\w-]*)\s*:\s*(#[0-9a-f]{3,8}|rgba?\([^)]+\))/gi;
236
+ let m;
237
+ while ((m = re.exec(css)) && achadas.length < 12) {
238
+ const bruto = m[2].trim();
239
+ const hex = bruto.startsWith('#') ? normalizarHex(bruto)
240
+ : (() => { const n = bruto.match(/(\d+)\D+(\d+)\D+(\d+)/); return n ? paraHex(+n[1], +n[2], +n[3]) : null; })();
241
+ if (hex) achadas.push({ nome: '--' + m[1], cor: hex });
242
+ }
243
+ return achadas;
244
+ }
245
+
246
+ /**
247
+ * Lê um site e devolve a identidade dele, em FATOS.
248
+ *
249
+ * O CSS das folhas externas entra na conta junto com o de dentro do HTML:
250
+ * quase todo site sério põe o tema numa folha à parte, e olhar só o `<style>`
251
+ * inline devolveria a paleta do menu e mais nada.
252
+ */
253
+ async function analisar(endereco) {
254
+ let pagina;
255
+ try { pagina = await buscar(endereco); }
256
+ catch (e) { return { ok: false, error: `não consegui abrir o site: ${e.message}` }; }
257
+
258
+ const html = pagina.corpo;
259
+ let css = (html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || []).join('\n');
260
+ css += '\n' + (html.match(/style=["'][^"']+["']/gi) || []).join('\n');
261
+ css += '\n' + (html.match(/fonts\.googleapis\.com\/css2?\?[^"')]+/gi) || []).join('\n');
262
+
263
+ const folhas = folhasDe(html, pagina.url);
264
+ for (const folha of folhas.slice(0, 4)) {
265
+ try { css += '\n' + (await buscar(folha)).corpo; } catch { /* uma que falta não impede */ }
266
+ }
267
+
268
+ const texto = colherTexto(html);
269
+ const cores = colherCores(css);
270
+
271
+ /* Site que monta o visual por JavaScript (SPA) entrega HTML quase vazio:
272
+ a folha de estilo existe, mas o que ela pinta só aparece depois de o
273
+ navegador rodar o bundle. Medido: o PicPay devolve zero cor por aqui e
274
+ o Tailwind devolve a paleta inteira.
275
+ Quando a colheita volta vazia, o caminho não é insistir — é abrir de
276
+ verdade, e o agente já tem navegador. Dizer isso aqui evita que ele
277
+ conclua que o site não tem identidade visual. */
278
+ const porJavaScript = !cores.marca.length;
279
+
280
+ return {
281
+ ok: true,
282
+ ...(porJavaScript ? {
283
+ aviso: 'não achei cor de marca no CSS — este site pinta por JavaScript, '
284
+ + 'e o que ele mostra só existe depois de o navegador rodar.',
285
+ comoResolver: 'abra com browser_open e leia o que está NA TELA: '
286
+ + "browser_eval com getComputedStyle nos elementos principais, ou um print com browser_screenshot.",
287
+ } : {}),
288
+ url: pagina.url,
289
+ marca: texto.marca,
290
+ titulo: texto.titulo,
291
+ descricao: texto.descricao,
292
+ // Os títulos da página, que são o que a marca diz de si mesma.
293
+ frases: texto.titulos,
294
+ cores,
295
+ fontes: colherFontes(css),
296
+ imagens: colherImagens(html, pagina.url),
297
+ folhasLidas: folhas.slice(0, 4).length,
298
+ // O HTML cru sai junto para quem vai atrás da logo (pegarLogo) não
299
+ // precisar baixar a mesma página de novo. Não é para o modelo ver:
300
+ // `ferramentaSite` tira antes de responder.
301
+ html,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * O site de uma empresa, a partir do NOME dela.
307
+ *
308
+ * ── POR QUE ISTO PRECISA EXISTIR ─────────────────────────────────────────
309
+ * "Faz um vídeo para a Paris Group" não traz link nenhum. Sem este caminho, o
310
+ * agente ia inventar as cores da marca — e inventar identidade visual de uma
311
+ * empresa que existe é o tipo de erro que aparece pronto no vídeo entregue.
312
+ *
313
+ * ── POR QUE NÃO UM BUSCADOR NOVO ─────────────────────────────────────────
314
+ * O `referencia.js` já resolve isto, e resolve com um cuidado que custou
315
+ * medição: ele só chuta domínio quando o pedido dá SINAL de que fala de um
316
+ * negócio. Sem essa exigência, "como usar react hooks" viraria react.com e
317
+ * "como fazer um bolo" viraria fazer.com.
318
+ *
319
+ * Um segundo buscador aqui seria uma segunda heurística para manter, e a que
320
+ * ficasse velha erraria calada. Então o que este módulo faz é acrescentar o
321
+ * passo que faltava: dos chutes, ficar com o que RESPONDE, e ler a identidade
322
+ * dele.
323
+ */
324
+ async function acharSite(nome) {
325
+ const texto = String(nome || '').trim();
326
+ if (!texto) return { ok: false, error: 'diga o nome da empresa.' };
327
+
328
+ /* O `sitesProvaveis` exige sinal de negócio no texto. Quem chamou aqui já
329
+ disse que está falando de uma marca — o nome dela é o argumento — então
330
+ a palavra entra explícita, em vez de a heurística ter de adivinhar de
331
+ uma palavra solta. */
332
+ const chutes = referencia.sitesProvaveis(`a empresa "${texto}"`);
333
+ if (!chutes.length) {
334
+ return { ok: false, error: `não consegui derivar um domínio de "${texto}".`,
335
+ comoResolver: 'mande a URL do site direto, se você souber' };
336
+ }
337
+
338
+ const { vivos } = await referencia.chutesVivos(chutes, 2);
339
+ if (!vivos.length) {
340
+ return {
341
+ ok: false,
342
+ error: `nenhum dos domínios prováveis de "${texto}" respondeu.`,
343
+ tentados: chutes.slice(0, 6).map((c) => c.url),
344
+ comoResolver: 'mande a URL do site, ou peça para eu pesquisar com pesquisar_referencia',
345
+ };
346
+ }
347
+ return { ok: true, url: vivos[0].url, outros: vivos.slice(1).map((v) => v.url) };
348
+ }
349
+
350
+
351
+
352
+ /**
353
+ * Reordena as candidatas sabendo DE QUEM é a logo.
354
+ *
355
+ * `candidatasALogo` pontua pelo LUGAR onde a imagem estava (um <img> chamado
356
+ * "logo" vale mais que o favicon). Isso basta até o site pôr no cabeçalho uma
357
+ * imagem que É uma logo e NÃO é a dele: o selo do prêmio, o "as seen on", o
358
+ * parceiro. Medido no nubank.com.br: a primeira candidata era
359
+ * "brand-finance-wordmark-mono.svg" — a logo da consultoria que premiou o
360
+ * Nubank. Baixar aquilo e chamar de logo do cliente é erro que só aparece com
361
+ * o vídeo pronto na frente dele.
362
+ *
363
+ * ── ONDE O NOME DA MARCA CONTA, E ONDE ELE ENGANA ────────────────────────
364
+ * Conta no CAMINHO do arquivo e no `alt`: "/nubank-logo.svg", alt="Logo do
365
+ * Nubank". Engana no HOST — e engana sempre, porque o host é o mesmo para
366
+ * TODO asset do site. Na primeira versão disto o casamento valia na URL
367
+ * inteira, e o resultado foi a Stripe entregar uma foto da Hertz: o arquivo
368
+ * morava em `images.stripeassets.com`, onde "stripe" está no endereço de
369
+ * qualquer imagem do site, inclusive das que não são logo nenhuma.
370
+ *
371
+ * Por isso: o nome da marca é procurado no caminho e no alt, nunca no host;
372
+ * e o host serve só para a outra pergunta, a de origem — arquivo servido de
373
+ * outro domínio registrável, sem o nome da marca no caminho, é de terceiro
374
+ * até prova em contrário e cai para o fim da fila. Cai, não some: CDN da
375
+ * própria empresa também é outro domínio, e sem candidata melhor ela
376
+ * continua valendo.
377
+ */
378
+
379
+ // "nubank.com.br" tem raiz "nubank", não "com": sem esta lista, o sufixo
380
+ // composto vira o nome da marca — e aí "com" casa com QUALQUER endereço.
381
+ const SUFIXOS_COMPOSTOS = new Set(['com', 'co', 'org', 'net', 'gov', 'edu', 'ac', 'mil']);
382
+
383
+ function dominioRegistravel(hostname) {
384
+ const partes = String(hostname || '').replace(/^www\./, '').split('.');
385
+ if (partes.length <= 2) return partes.join('.');
386
+ return SUFIXOS_COMPOSTOS.has(partes[partes.length - 2])
387
+ ? partes.slice(-3).join('.') // nubank.com.br
388
+ : partes.slice(-2).join('.'); // www.stripe.com → stripe.com
389
+ }
390
+
391
+ function raizDoDominio(hostname) {
392
+ return dominioRegistravel(hostname).split('.')[0] || '';
393
+ }
394
+
395
+ // Palavras que aparecem em nome de empresa e não identificam ninguém.
396
+ const VAZIAS = new Set(['about', 'home', 'sobre', 'site', 'www', 'com', 'net', 'org', 'the', 'and',
397
+ 'ltda', 'sa', 'inc', 'group', 'grupo', 'brasil', 'brazil', 'oficial', 'official', 'page', 'pagina']);
398
+
399
+ function ordenarPelaMarca(candidatas, nome, urlDoSite) {
400
+ const tokens = new Set(String(nome || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
401
+ .split(/[^a-z0-9]+/).filter((t) => t.length >= 3 && !VAZIAS.has(t)));
402
+ let host = '';
403
+ try { host = new URL(urlDoSite).hostname; } catch { /* sem domínio: só o nome decide */ }
404
+ const raiz = raizDoDominio(host);
405
+ if (raiz.length >= 3 && !VAZIAS.has(raiz)) tokens.add(raiz);
406
+ const doSite = dominioRegistravel(host);
407
+ const lista = [...tokens];
408
+
409
+ return candidatas.map((c, i) => {
410
+ let caminho = c.url;
411
+ let deFora = false;
412
+ try {
413
+ const u = new URL(c.url);
414
+ caminho = u.pathname + u.search; // o host fica de FORA de propósito
415
+ deFora = dominioRegistravel(u.hostname) !== doSite;
416
+ } catch { /* url torta: compara o texto todo mesmo */ }
417
+ const alvo = `${caminho} ${c.alt || ''}`.toLowerCase();
418
+ // `nomeia` é o que separa "a logo deles" de "uma imagem no site
419
+ // deles". Quem usa isso é `pegarLogo`, para decidir entre entregar
420
+ // direto e guardar como reserva atrás do Wikimedia Commons.
421
+ const nomeia = lista.some((t) => alvo.includes(t));
422
+ let nota = c.nota || 0;
423
+ if (nomeia) nota += 100;
424
+ else if (deFora) nota -= 50;
425
+ return { c: { ...c, nomeia }, nota, i };
426
+ }).sort((a, b) => (b.nota - a.nota) || (a.i - b.i)).map((x) => x.c);
427
+ }
428
+
429
+ /**
430
+ * A LOGO da empresa, baixada para a pasta do projeto.
431
+ *
432
+ * ── POR QUE O SITE NÃO É A PRIMEIRA PORTA ────────────────────────────────
433
+ * A intuição diz "a logo está no site". Ela está — como <svg> INLINE dentro
434
+ * do cabeçalho, que é como todo site moderno serve a logo, e que não é um
435
+ * arquivo para baixar. O que sobra em <img> costuma ser outra coisa: medido
436
+ * ao vivo, o nubank.com.br devolveu a logo da consultoria que premiou o
437
+ * Nubank e depois uma foto de reunião (o card tinha "brand" na classe); a
438
+ * stripe.com devolveu uma foto da Hertz; a spotify.com devolveu o print da
439
+ * tela de download. Três marcas conhecidíssimas, três imagens erradas — e
440
+ * nenhuma delas erra de um jeito que dê para perceber sem abrir o arquivo.
441
+ *
442
+ * Então a ordem é por CONFIANÇA na fonte, não por proximidade do site:
443
+ *
444
+ * 1. SIMPLE ICONS (via Iconify) — a marca desenhada, em SVG, conferida por
445
+ * gente. Escala sem borrar e é monocromática: a cor entra na cena, o que
446
+ * para motion é melhor que um PNG com a cor cravada. Só tem marca
447
+ * conhecida, e é justamente onde o chute do site mais erra.
448
+ * 2. O SITE, quando a imagem DIZ o nome da marca no caminho ou no alt
449
+ * ("/nubank-logo.svg", alt="Logo do Nubank"). Aí é a logo de verdade,
450
+ * colorida, do dono.
451
+ * 3. apple-touch-icon — todo site tem, tem 180 px ou mais, e é sempre a
452
+ * marca (é o que vira ícone na tela do telefone).
453
+ * 4. WIKIMEDIA COMMONS — o wordmark colorido das marcas conhecidas.
454
+ * 5. og:image e favicon — só quando não sobrou nada, e a resposta AVISA o
455
+ * que é. Um borrão entregue calado vira um vídeo refeito.
456
+ *
457
+ * Nada disso adivinha: cada porta que falha entra em `tentativas`, e o
458
+ * `aviso` diz o que a logo escolhida tem de errado. O agente precisa poder
459
+ * decidir usar o nome em texto — que é melhor que uma logo errada.
460
+ */
461
+ async function pegarLogo(html, urlDoSite, { pasta, nome = 'marca' } = {}) {
462
+ const tentadas = [];
463
+ const guardar = (bytes, ext, extra) => {
464
+ const arquivo = path.join(pasta, 'midia', `logo-${limpar(nome)}${ext}`);
465
+ fs.mkdirSync(path.dirname(arquivo), { recursive: true });
466
+ fs.writeFileSync(arquivo, bytes);
467
+ fs.writeFileSync(arquivo + '.licenca.json', JSON.stringify({
468
+ titulo: `logo ${nome}`, origem: extra.origem, fonte: extra.achadaEm,
469
+ licenca: 'marca registrada do titular — o uso depende da autorização dele',
470
+ quando: new Date().toISOString(),
471
+ }, null, 2), 'utf8');
472
+ return {
473
+ ok: true, src: path.relative(pasta, arquivo).replace(/\\/g, '/'),
474
+ formato: ext.slice(1), ...extra,
475
+ tentativas: tentadas.length ? tentadas : undefined,
476
+ };
477
+ };
478
+
479
+ // 1. Simple Icons: a marca conferida, em SVG.
480
+ const slug = String(nome).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
481
+ .replace(/[^a-z0-9]/g, '');
482
+ if (slug.length >= 2) {
483
+ const r = await assets.icone(`simple-icons:${slug}`);
484
+ if (r.ok) {
485
+ return guardar(Buffer.from(r.svg, 'utf8'), '.svg', {
486
+ origem: `https://simpleicons.org/?q=${slug}`, achadaEm: 'simple-icons',
487
+ aviso: 'é o símbolo MONOCROMÁTICO da marca (currentColor): pinte na cena com a cor da '
488
+ + 'identidade. Se a peça exigir a logo colorida oficial, peça o arquivo ao usuário.',
489
+ });
490
+ }
491
+ tentadas.push(`simple-icons: não tem "${slug}"`);
492
+ }
493
+
494
+ // 2/3/5. O site — com a ordem corrigida por quem a imagem diz ser.
495
+ const candidatas = ordenarPelaMarca(marca.candidatasALogo(html, urlDoSite), nome, urlDoSite);
496
+ let deReserva = null;
497
+ for (const c of candidatas) {
498
+ // og:image e favicon ficam GUARDADOS: valem menos que o Commons.
499
+ const duvidosa = c.fonte === 'og:image' || c.fonte === 'favicon' || c.fonte === 'icon' || !c.nomeia;
500
+ const bytes = await referencia.pegar(c.url, { timeout: 20000, binario: true });
501
+ if (!bytes || bytes.length < 200) { tentadas.push(`${c.fonte}: não baixou`); continue; }
502
+ const ext = assets.extensaoPorConteudo(bytes);
503
+ if (!ext || !/^\.(svg|png|jpg|webp|gif|ico)$/.test(ext)) { tentadas.push(`${c.fonte}: não é imagem`); continue; }
504
+ if (duvidosa || ext === '.ico') {
505
+ if (!deReserva) deReserva = { c, bytes, ext };
506
+ tentadas.push(`${c.fonte}: não diz o nome da marca, guardado como reserva`);
507
+ continue;
508
+ }
509
+ return guardar(bytes, ext, { origem: c.url, achadaEm: c.fonte });
510
+ }
511
+
512
+ // 4. Wikimedia Commons.
513
+ try {
514
+ const doCommons = await acervo.wikimediaLogos(nome, 3);
515
+ if (doCommons && doCommons.length) {
516
+ const r = await assets.baixar({ ...doCommons[0], fonte: 'wikimedia' },
517
+ { pasta, nome: `logo-${limpar(nome)}` });
518
+ if (r.ok) {
519
+ return {
520
+ ok: true, src: r.relativo,
521
+ formato: (r.arquivo.match(/\.([a-z0-9]+)$/i) || [])[1] || null,
522
+ origem: doCommons[0].url, achadaEm: 'wikimedia-commons',
523
+ aviso: 'veio do Wikimedia Commons, não do site da empresa — confira se é a logo atual.',
524
+ tentativas: tentadas.length ? tentadas : undefined,
525
+ };
526
+ }
527
+ }
528
+ } catch { /* o Commons é porta de reserva; falhar nele não derruba a análise */ }
529
+
530
+ // 5. A reserva, dizendo o que ela é.
531
+ if (deReserva) {
532
+ return guardar(deReserva.bytes, deReserva.ext, {
533
+ origem: deReserva.c.url, achadaEm: deReserva.c.fonte,
534
+ aviso: deReserva.ext === '.ico'
535
+ ? 'é o FAVICON (.ico, quase sempre 32 px): num quadro 1920x1080 isso é um borrão. '
536
+ + 'Use só como referência de forma e cor; na peça, prefira o nome em texto.'
537
+ : 'esta imagem NÃO diz ser a logo (veio do og:image ou de um <img> que não nomeia a marca): '
538
+ + 'pode ser foto de campanha ou print de tela. ABRA o arquivo antes de pôr num fundo limpo — '
539
+ + 'se não for a logo, use o nome em texto com a fonte e a cor da identidade.',
540
+ });
541
+ }
542
+
543
+ return {
544
+ ok: false,
545
+ error: 'não achei a logo: nem no Simple Icons, nem no site, nem no Wikimedia Commons.',
546
+ comoResolver: 'siga sem ela: ponha o nome da empresa em texto, com a fonte e a cor que a análise trouxe. '
547
+ + 'Se a peça for de marca, peça o arquivo da logo ao usuário.',
548
+ tentativas: tentadas.length ? tentadas : undefined,
549
+ };
550
+ }
551
+
552
+ function limpar(t) {
553
+ return String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
554
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'marca';
555
+ }
556
+
557
+ module.exports = {
558
+ analisar, acharSite, pegarLogo,
559
+ colherCores, colherFontes, colherTexto, variaveisDeCor, folhasDe, buscar,
560
+ };