primocode 8.38.0 → 8.39.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/lib/marca.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * marca.js — a logo e a identidade visual de uma empresa, puxadas do site.
3
+ *
4
+ * Pedido do dono: "puxe a logo e identidade visual da empresa". O agente
5
+ * fazia peça de marca com `marca.cor` chutada e sem logo, ou pedia o arquivo
6
+ * ao usuário — e o usuário, que só disse "faz um vídeo para a Paris Group",
7
+ * não estava ali para mandar arquivo nenhum. O site da empresa TEM a logo,
8
+ * as cores e as fontes; é só ler como uma pessoa leria.
9
+ *
10
+ * ── O QUE SE LÊ, E EM QUE ORDEM ──────────────────────────────────────────
11
+ * nome og:site_name → <title> (sem o " | slogan")
12
+ * frase meta description / og:description (o "sobre" em uma linha)
13
+ * logo <img> com "logo" no src/alt/class (a de verdade, no cabeçalho) →
14
+ * apple-touch-icon (180 px, o melhor ícone que todo site tem) →
15
+ * og:image (é a imagem de compartilhar, às vezes é a logo, às vezes
16
+ * uma foto — por isso vem por último) → favicon.
17
+ * SVG ganha de PNG no mesmo lugar: escala sem borrar.
18
+ * cores meta theme-color primeiro (a empresa DISSE qual é a cor), depois
19
+ * as cores mais repetidas no CSS (inline e nas folhas ligadas),
20
+ * fora branco/preto/cinza — o que sobra é a paleta.
21
+ * fontes os "family=" do Google Fonts e os font-family do CSS, fora as
22
+ * genéricas (sans-serif, Arial, system-ui).
23
+ *
24
+ * A logo é BAIXADA para o projeto e ENVIADA ao estúdio: volta como
25
+ * "/midia/…", pronta para `marca.logo`. O resto volta como `marca` pronta
26
+ * para colar no documento.
27
+ *
28
+ * Zero dependências: o `pegar` do referencia.js (texto e binário), e o
29
+ * chute de domínio dele quando a pessoa deu só o nome.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const referencia = require('./referencia.js');
37
+
38
+ const GENERICAS_FONTE = /^(sans-serif|serif|monospace|system-ui|-apple-system|blinkmacsystemfont|segoe ui|arial|helvetica( neue)?|roboto|inherit|initial|ui-sans-serif|ui-serif|ui-monospace|apple color emoji|segoe ui emoji|noto color emoji|times new roman|courier new|verdana|tahoma|georgia)$/i;
39
+
40
+ function absoluta(href, base) {
41
+ try { return new URL(String(href || '').replace(/&amp;/g, '&'), base).href; } catch { return null; }
42
+ }
43
+
44
+ function atributo(tag, nome) {
45
+ const m = new RegExp(`\\b${nome}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
46
+ return m ? (m[2] != null ? m[2] : m[3] != null ? m[3] : m[4]) : null;
47
+ }
48
+
49
+ function metas(html) {
50
+ const saida = {};
51
+ for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
52
+ const chave = atributo(tag, 'property') || atributo(tag, 'name');
53
+ const valor = atributo(tag, 'content');
54
+ if (chave && valor && saida[chave.toLowerCase()] == null) saida[chave.toLowerCase()] = valor.trim();
55
+ }
56
+ return saida;
57
+ }
58
+
59
+ /** As candidatas a logo, da melhor para a pior. */
60
+ function candidatasALogo(html, base) {
61
+ const lista = [];
62
+ // 1. <img> que se chama logo — no cabeçalho é a logo de verdade.
63
+ for (const tag of html.match(/<img\b[^>]*>/gi) || []) {
64
+ const src = atributo(tag, 'src') || atributo(tag, 'data-src') || '';
65
+ const alt = atributo(tag, 'alt') || '';
66
+ const cls = atributo(tag, 'class') || '';
67
+ const id = atributo(tag, 'id') || '';
68
+ if (/logo|brand|marca/i.test(`${src} ${alt} ${cls} ${id}`) && !/sponsor|partner|cliente|client|parceir/i.test(`${alt} ${cls} ${src}`)) {
69
+ const u = absoluta(src, base);
70
+ if (u && !/^data:/.test(u)) lista.push({ url: u, fonte: 'img-logo', nota: /\.svg(\?|$)/i.test(u) ? 30 : 20 });
71
+ }
72
+ }
73
+ // 1b. SVG inline no cabeçalho com "logo" no nome — não dá para baixar,
74
+ // mas o agente pode saber que existe (e usar o nome em texto).
75
+ // 2. Ícones declarados.
76
+ for (const tag of html.match(/<link\b[^>]*>/gi) || []) {
77
+ const rel = (atributo(tag, 'rel') || '').toLowerCase();
78
+ const href = atributo(tag, 'href');
79
+ if (!href) continue;
80
+ const u = absoluta(href, base);
81
+ if (!u) continue;
82
+ if (/apple-touch-icon/.test(rel)) lista.push({ url: u, fonte: 'apple-touch-icon', nota: 15 });
83
+ else if (/\bicon\b/.test(rel)) {
84
+ const tam = parseInt((atributo(tag, 'sizes') || '0').split('x')[0], 10) || 0;
85
+ lista.push({ url: u, fonte: 'icon', nota: /\.svg(\?|$)/i.test(u) ? 14 : (tam >= 128 ? 10 : 4) });
86
+ }
87
+ }
88
+ // 3. og:image — a imagem de compartilhar. Às vezes é a logo, às vezes é
89
+ // uma foto: fica atrás das declaradas.
90
+ const m = metas(html);
91
+ const og = m['og:image'] || m['twitter:image'];
92
+ if (og) { const u = absoluta(og, base); if (u) lista.push({ url: u, fonte: 'og:image', nota: 6 }); }
93
+ // 4. O favicon padrão, quando nada foi declarado.
94
+ lista.push({ url: absoluta('/favicon.ico', base), fonte: 'favicon', nota: 1 });
95
+ // Ordena e tira repetidos.
96
+ const vistos = new Set();
97
+ return lista.filter((c) => c.url && !vistos.has(c.url) && vistos.add(c.url)).sort((a, b) => b.nota - a.nota);
98
+ }
99
+
100
+ /** As cores de um CSS, contadas. Fora branco, preto e cinza. */
101
+ function coresDe(css, contagem) {
102
+ const re = /#([0-9a-f]{6}|[0-9a-f]{3})\b|rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/gi;
103
+ let m;
104
+ while ((m = re.exec(css))) {
105
+ let hex;
106
+ if (m[1]) {
107
+ hex = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
108
+ } else {
109
+ hex = [m[2], m[3], m[4]].map((n) => Math.min(255, +n).toString(16).padStart(2, '0')).join('');
110
+ }
111
+ hex = '#' + hex.toLowerCase();
112
+ const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
113
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
114
+ const sat = max === 0 ? 0 : (max - min) / max;
115
+ // Cinza (pouca saturação), quase branco e quase preto não são
116
+ // identidade — são o papel e a tinta de todo site.
117
+ if (sat < 0.18 || max < 40 || (min > 225)) continue;
118
+ contagem.set(hex, (contagem.get(hex) || 0) + 1);
119
+ }
120
+ }
121
+
122
+ function fontesDe(html, css) {
123
+ const achadas = new Map();
124
+ // Google Fonts: family=Plus+Jakarta+Sans:wght@400;700&family=Inter
125
+ for (const m of html.matchAll(/fonts\.googleapis\.com\/css2?\?([^"' >]+)/gi)) {
126
+ for (const f of m[1].matchAll(/family=([^&:]+)/g)) {
127
+ const nome = decodeURIComponent(f[1].replace(/\+/g, ' ')).trim();
128
+ if (nome) achadas.set(nome, (achadas.get(nome) || 0) + 10);
129
+ }
130
+ }
131
+ // Para em ; } e aspas: o CSS aqui inclui atributos style="…" da página,
132
+ // e sem a aspa como fim o nome da fonte engolia o atributo seguinte
133
+ // (medido: 'sans-serif" style="color:#7fa6ff').
134
+ for (const m of css.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
135
+ for (const parte of m[1].split(',')) {
136
+ const nome = parte.trim().replace(/\s*!important/, '').replace(/^["']|["']$/g, '').trim();
137
+ if (!nome || nome.length > 40 || GENERICAS_FONTE.test(nome) || /[(){}:="']/.test(nome)) continue;
138
+ achadas.set(nome, (achadas.get(nome) || 0) + 1);
139
+ }
140
+ }
141
+ return [...achadas.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n).slice(0, 3);
142
+ }
143
+
144
+ /** Descobre o site quando veio só o nome: o chute de domínio do referencia.js. */
145
+ async function acharSite(nome) {
146
+ const palpites = referencia.sitesProvaveis(`site da empresa "${nome}"`);
147
+ const { vivos, html } = await referencia.chutesVivos(palpites, 1);
148
+ if (vivos.length) return { url: vivos[0].url, html: html.get(vivos[0].url) };
149
+ // Sem chute vivo, o buscador.
150
+ const achados = await referencia.buscar(`${nome} site oficial`, 4);
151
+ for (const a of achados) {
152
+ if (/wikipedia|linkedin|instagram|facebook|youtube|twitter|x\.com/i.test(a.url)) continue;
153
+ const h = await referencia.pegar(a.url, { timeout: 8000 });
154
+ if (h && h.length > 500) return { url: a.url, html: h };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * A tool. `site` (URL) ou `nome` (a empresa). Devolve a identidade e a
161
+ * `marca` pronta para o documento do estúdio.
162
+ */
163
+ async function marcaDaEmpresa(args, { projectDir, studio }) {
164
+ const nome = String(args.nome || args.empresa || '').trim();
165
+ let site = String(args.site || args.url || '').trim();
166
+ if (!nome && !site) return { ok: false, error: 'Diga o nome da empresa (nome) ou o endereço do site (site).' };
167
+ if (site && !/^https?:\/\//i.test(site)) site = 'https://' + site;
168
+
169
+ let html = null;
170
+ if (site) html = await referencia.pegar(site, { timeout: 12000 });
171
+ if (!html && site && !/\/\/www\./i.test(site)) {
172
+ const comWww = site.replace(/^(https?:\/\/)/i, '$1www.');
173
+ html = await referencia.pegar(comWww, { timeout: 12000 });
174
+ if (html) site = comWww;
175
+ }
176
+ if (!html && nome) {
177
+ const achado = await acharSite(nome);
178
+ if (achado) { site = achado.url; html = achado.html; }
179
+ }
180
+ if (!html) {
181
+ return {
182
+ ok: false,
183
+ error: `Não achei o site de "${nome || site}".`,
184
+ comoCorrigir: 'Se souber o endereço, chame de novo com site:"https://…". Se não, siga SEM a logo: '
185
+ + 'use o nome em texto na assinatura ("tipo":"assinatura","nome":…) e uma cor de tema — '
186
+ + 'e diga ao usuário que a logo não foi encontrada para ele mandar o arquivo (studio_midia).',
187
+ };
188
+ }
189
+
190
+ const m = metas(html);
191
+ const titulo = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
192
+ // Sem og:site_name nem <title> (site montado por JavaScript), o nome vem
193
+ // do domínio: "nubank.com.br" → "Nubank". Melhor que campo vazio.
194
+ const dominio = ((site.match(/^https?:\/\/(?:www\.)?([^/]+)/i) || [])[1] || '').split('.')[0];
195
+ const nomeSite = (m['og:site_name'] || m['application-name'] || titulo.split(/\s[|–—-]\s|\s·\s/)[0] || nome
196
+ || (dominio ? dominio.charAt(0).toUpperCase() + dominio.slice(1) : '')).trim().slice(0, 80);
197
+ const frase = (m['description'] || m['og:description'] || '').trim().slice(0, 240);
198
+
199
+ // Cores: theme-color declarada, depois o CSS (inline + até 3 folhas).
200
+ const contagem = new Map();
201
+ let css = (html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/gi) || []).join('\n');
202
+ // Só o MIOLO do style="…": com o `style="` junto, o fim de um atributo
203
+ // virava começo do nome da fonte seguinte (medido: 'sans-serif" style=…').
204
+ css += ' ' + (html.match(/\sstyle\s*=\s*"[^"]*"/gi) || []).map((a) => a.replace(/^\sstyle\s*=\s*"|"$/g, '')).join('; ');
205
+ const folhas = [];
206
+ for (const tag of html.match(/<link\b[^>]*>/gi) || []) {
207
+ if (/stylesheet/i.test(atributo(tag, 'rel') || '')) {
208
+ const u = absoluta(atributo(tag, 'href'), site);
209
+ if (u && !/fonts\.googleapis/i.test(u)) folhas.push(u);
210
+ }
211
+ }
212
+ const conteudos = await Promise.all(folhas.slice(0, 3).map((u) => referencia.pegar(u, { timeout: 8000 })));
213
+ for (const c of conteudos) if (c) css += '\n' + c.slice(0, 300000);
214
+ coresDe(css, contagem);
215
+ const paleta = [...contagem.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c).slice(0, 6);
216
+ let principal = null;
217
+ const tema = m['theme-color'];
218
+ if (tema && /^#?[0-9a-f]{3,8}$/i.test(tema.replace('#', ''))) {
219
+ const h = tema.replace('#', '').toLowerCase();
220
+ principal = '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h.slice(0, 6));
221
+ const r = parseInt(principal.slice(1, 3), 16), g = parseInt(principal.slice(3, 5), 16), b = parseInt(principal.slice(5, 7), 16);
222
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
223
+ if (max === 0 || (max - min) / max < 0.18 || min > 225) principal = null; // theme-color branco/preto não é a cor da marca
224
+ }
225
+ if (!principal) principal = paleta[0] || null;
226
+
227
+ const fontes = fontesDe(html, css);
228
+
229
+ // A logo: tenta as candidatas em ordem até uma baixar de verdade.
230
+ let logo = null;
231
+ const candidatas = candidatasALogo(html, site);
232
+ const tentadas = [];
233
+ for (const c of candidatas.slice(0, 6)) {
234
+ const bytes = await referencia.pegar(c.url, { timeout: 10000, binario: true });
235
+ if (!bytes || bytes.length < 200) { tentadas.push(`${c.fonte}: não abriu`); continue; }
236
+ const cab = bytes.subarray(0, 16);
237
+ const ext = /^<\?xml|^<svg/i.test(bytes.subarray(0, 64).toString('utf8').trim()) ? '.svg'
238
+ : cab[0] === 0x89 && cab[1] === 0x50 ? '.png'
239
+ : cab[0] === 0xff && cab[1] === 0xd8 ? '.jpg'
240
+ : cab.subarray(0, 4).toString() === 'RIFF' ? '.webp'
241
+ : cab[0] === 0x00 && cab[1] === 0x00 && cab[2] === 0x01 && cab[3] === 0x00 ? '.ico'
242
+ : cab.subarray(0, 3).toString() === 'GIF' ? '.gif' : null;
243
+ if (!ext) { tentadas.push(`${c.fonte}: não é imagem`); continue; }
244
+ if (ext === '.ico' && candidatas.length > 1 && c.fonte === 'favicon') { tentadas.push('favicon: só .ico'); }
245
+ const pasta = path.join(projectDir || process.cwd(), 'midia');
246
+ fs.mkdirSync(pasta, { recursive: true });
247
+ const arquivo = path.join(pasta, `logo-${nomeSite.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'marca'}${ext}`);
248
+ fs.writeFileSync(arquivo, bytes);
249
+ let url = null;
250
+ if (studio && ext !== '.ico') {
251
+ const envio = await studio.midiaEnviar({ arquivo });
252
+ if (envio && envio.ok !== false) url = envio.url;
253
+ }
254
+ logo = { url: url || null, arquivoLocal: path.relative(projectDir || process.cwd(), arquivo), origem: c.url, fonte: c.fonte, formato: ext.slice(1), bytes: bytes.length };
255
+ break;
256
+ }
257
+
258
+ const marca = {};
259
+ if (principal) marca.cor = principal;
260
+ if (logo && logo.url) marca.logo = logo.url;
261
+ return {
262
+ ok: true,
263
+ nome: nomeSite || nome,
264
+ site,
265
+ frase: frase || null,
266
+ cores: { principal, paleta },
267
+ fontes,
268
+ logo,
269
+ marca,
270
+ comoUsar: (Object.keys(marca).length
271
+ ? `No documento do estúdio: "marca": ${JSON.stringify(marca)}. `
272
+ : 'Sem cor nem logo confiáveis: use o nome em texto na assinatura e o tema pelo assunto. ')
273
+ + (fontes.length ? `Fonte da marca: ${fontes[0]} (o estúdio usa a do tema; cite a fonte só se for pedir código/HTML). ` : '')
274
+ + (logo && !logo.url && logo.formato === 'ico' ? 'A única logo encontrada foi o favicon .ico, que o estúdio não usa — peça o arquivo ao usuário se a peça for de marca. ' : '')
275
+ + (frase ? `Uma linha sobre a empresa, do próprio site: "${frase}".` : ''),
276
+ tentativasDeLogo: tentadas.length ? tentadas : undefined,
277
+ };
278
+ }
279
+
280
+ module.exports = { marcaDaEmpresa, candidatasALogo, coresDe, fontesDe, metas, atributo };