primocode 8.42.2 → 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.
- package/README.md +41 -37
- package/bin/primocode.js +598 -360
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +31 -192
- package/lib/catalogo.js +60 -60
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +545 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +606 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +13 -1
- package/lib/marca.js +4 -1
- package/lib/marketplace.js +189 -0
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +51 -181
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +167 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +325 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- 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
|
+
};
|