vtex-audit 0.1.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 ADDED
@@ -0,0 +1,226 @@
1
+ # VTEX Audit 🚀
2
+
3
+ CLI profissional para auditoria de **Performance, SEO e Qualidade Técnica** em lojas **VTEX IO**, baseado no Lighthouse — com **diagnóstico acionável**, foco em **LCP**, **3rd‑party scripts** e **boas práticas reais de e‑commerce**.
4
+
5
+ > Não é apenas um wrapper do Lighthouse.
6
+ > É um **auditor especializado em VTEX IO**.
7
+
8
+ ---
9
+
10
+ ## ✨ Principais diferenciais
11
+
12
+ ✅ Auditoria **opiniada para VTEX IO**
13
+ ✅ Identificação automática do **Largest Contentful Paint (LCP)**
14
+ ✅ Descoberta do **elemento exato responsável pelo LCP**
15
+ ✅ Ranking dos **arquivos mais pesados**
16
+ ✅ Ranking das **requisições mais lentas**
17
+ ✅ Detecção de **scripts third‑party**
18
+ ✅ Sugestões automáticas de **correções prováveis**
19
+ ✅ Relatórios JSON e HTML
20
+ ✅ Interface de terminal profissional
21
+ ✅ Pronto para **CI/CD e pipelines**
22
+
23
+ ---
24
+
25
+ ## 📦 Instalação
26
+
27
+ ### Uso rápido (recomendado)
28
+
29
+ ```bash
30
+ npx vtex-audit --url https://www.sualoja.com.br
31
+ ```
32
+
33
+ ### Instalação global
34
+
35
+ ```bash
36
+ npm install -g vtex-audit
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 🚀 Uso
42
+
43
+ ### Mobile (default)
44
+
45
+ ```bash
46
+ vtex-audit --url https://www.sualoja.com.br
47
+ ```
48
+
49
+ ### Desktop
50
+
51
+ ```bash
52
+ vtex-audit --url https://www.sualoja.com.br --device desktop
53
+ ```
54
+
55
+ ### Gerar relatórios
56
+
57
+ ```bash
58
+ vtex-audit --url https://www.sualoja.com.br --json --html
59
+ ```
60
+
61
+ ### Diretório de saída
62
+
63
+ ```bash
64
+ vtex-audit --url https://www.sualoja.com.br --out ./audit
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 📊 Exemplo de saída
70
+
71
+ ```
72
+ VTEX Audit (mobile)
73
+ URL: https://www.loja.com.br
74
+
75
+ Scores (Lighthouse)
76
+ Performance: 51
77
+ SEO: 100
78
+ Accessibility: 91
79
+ Best Practices: 57
80
+
81
+ Core metrics
82
+ LCP: 16.7s
83
+ CLS: 0.004
84
+ TBT: 375ms
85
+ Requests: 168
86
+ Total weight: 2.4 MB
87
+
88
+ LCP details
89
+ Element: img.banner__img
90
+ Asset: https://.../banner-home.webp
91
+
92
+ Top culprits
93
+ Largest transfers:
94
+ - hero-banner.webp (420 KB)
95
+ - vendor.js (310 KB)
96
+
97
+ Slowest requests:
98
+ - google-analytics.js (1.8s)
99
+ - facebook-pixel.js (1.4s)
100
+
101
+ Top domains:
102
+ - pandorajoias.vtexassets.com
103
+ - www.googletagmanager.com ⚠️
104
+ - connect.facebook.net ⚠️
105
+
106
+ Likely fixes
107
+ → Priorizar banner LCP
108
+ → Converter imagens para WebP / AVIF
109
+ → Adiar scripts third‑party
110
+ → Reduzir apps globais VTEX
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🔍 O que o VTEX Audit analisa
116
+
117
+ ### Lighthouse
118
+ - Performance
119
+ - SEO
120
+ - Accessibility
121
+ - Best Practices
122
+
123
+ ### Métricas principais
124
+ - Largest Contentful Paint (LCP)
125
+ - Cumulative Layout Shift (CLS)
126
+ - Total Blocking Time (TBT)
127
+ - Time To Interactive (TTI)
128
+ - Speed Index
129
+
130
+ ---
131
+
132
+ ## 🔎 SEO custom (fora do Lighthouse)
133
+
134
+ - ❌ Página sem `<h1>`
135
+ - ❌ Imagens sem atributo `alt`
136
+ - ❌ Meta title ausente
137
+ - ❌ Meta description ausente
138
+ - ❌ Conteúdo vazio acima da dobra
139
+
140
+ ---
141
+
142
+ ## ⚡ Performance avançada
143
+
144
+ - Identificação do **elemento real do LCP**
145
+ - URL do asset responsável
146
+ - Top 10 arquivos por peso
147
+ - Top 10 requests mais lentas
148
+ - Ranking por domínio
149
+ - Destaque automático de **third‑party scripts**
150
+ - Diagnóstico focado em **VTEX IO**
151
+
152
+ ---
153
+
154
+ ## 🧠 Diferença para o Lighthouse tradicional
155
+
156
+ | Lighthouse | VTEX Audit |
157
+ |----------|-----------|
158
+ | Genérico | Especializado em VTEX |
159
+ | Interface web | CLI profissional |
160
+ | Apenas notas | Diagnóstico acionável |
161
+ | Sem ranking | Top culpados |
162
+ | Sem noção de apps | Identifica third‑party |
163
+ | Manual | Automatizável |
164
+ | Sem contexto | Sugestões práticas |
165
+
166
+ ---
167
+
168
+ ## 🤖 CI / GitHub Actions
169
+
170
+ ```yaml
171
+ - name: VTEX Audit
172
+ run: npx vtex-audit --url https://www.sualoja.com.br
173
+ ```
174
+
175
+ Ideal para validar performance antes de deploy.
176
+
177
+ ---
178
+
179
+ ## 📁 Estrutura dos relatórios
180
+
181
+ ```
182
+ ./vtex-audit
183
+ ├── mobile-home.json
184
+ ├── mobile-home.html
185
+ ├── seo-report.json
186
+ ```
187
+
188
+ ---
189
+
190
+ ## 🛠 Stack
191
+
192
+ - Node.js 18+
193
+ - TypeScript
194
+ - Lighthouse
195
+ - Chrome Launcher
196
+ - Ora
197
+ - Boxen
198
+ - CLI Table
199
+ - Pretty Bytes
200
+
201
+ ---
202
+
203
+ ## 🧭 Roadmap
204
+
205
+ - [ ] Auditoria por múltiplas rotas (Home, PLP, PDP)
206
+ - [ ] Comparação entre deploys
207
+ - [ ] Exportação Markdown
208
+ - [ ] Dashboard Web
209
+ - [ ] Integração com VTEX Admin
210
+ - [ ] GitHub Checks
211
+ - [ ] VTEX Toolbelt Plugin
212
+
213
+ ---
214
+
215
+ ## 👨‍💻 Autor
216
+
217
+ **Denis Palhares Gonçalves**
218
+ Senior Full Stack Developer
219
+ Especialista em VTEX IO
220
+ +10 anos em e‑commerce
221
+
222
+ ---
223
+
224
+ ## 📄 Licença
225
+
226
+ MIT
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+ import chalk from "chalk";
6
+ import boxen from "boxen";
7
+ import ora from "ora";
8
+ import prettyBytes from "pretty-bytes";
9
+ import Table from "cli-table3";
10
+
11
+ // src/audit/lighthouse.ts
12
+ import lighthouse from "lighthouse";
13
+ import { launch } from "chrome-launcher";
14
+ function safeHost(raw) {
15
+ try {
16
+ return new URL(raw).hostname;
17
+ } catch {
18
+ return "unknown";
19
+ }
20
+ }
21
+ function toNumberOrNull(v) {
22
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
23
+ }
24
+ async function runLighthouse(url2, device2) {
25
+ const chrome = await launch({
26
+ chromeFlags: ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
27
+ });
28
+ try {
29
+ const opts2 = {
30
+ port: chrome.port,
31
+ logLevel: "error",
32
+ output: "json",
33
+ onlyCategories: ["performance", "seo", "accessibility", "best-practices"],
34
+ emulatedFormFactor: device2,
35
+ maxWaitForLoad: 12e4
36
+ };
37
+ const runnerResult = await lighthouse(url2, opts2);
38
+ if (!runnerResult?.lhr) throw new Error("Lighthouse n\xE3o retornou lhr");
39
+ const lhr = runnerResult.lhr;
40
+ const audits = lhr.audits ?? {};
41
+ const scores = {
42
+ performance: Math.round((lhr.categories?.performance?.score ?? 0) * 100),
43
+ seo: Math.round((lhr.categories?.seo?.score ?? 0) * 100),
44
+ accessibility: Math.round((lhr.categories?.accessibility?.score ?? 0) * 100),
45
+ bestPractices: Math.round((lhr.categories?.["best-practices"]?.score ?? 0) * 100)
46
+ };
47
+ const metrics = {
48
+ lcpMs: Math.round(audits["largest-contentful-paint"]?.numericValue ?? 0),
49
+ cls: Number((audits["cumulative-layout-shift"]?.numericValue ?? 0).toFixed(3)),
50
+ tbtMs: Math.round(audits["total-blocking-time"]?.numericValue ?? 0),
51
+ ttiMs: Math.round(audits["interactive"]?.numericValue ?? 0),
52
+ speedIndexMs: Math.round(audits["speed-index"]?.numericValue ?? 0)
53
+ };
54
+ const networkItemsRaw = audits["network-requests"]?.details?.items ?? [];
55
+ const requests = Array.isArray(networkItemsRaw) ? networkItemsRaw.length : null;
56
+ const totalByteWeight = Math.round(audits["total-byte-weight"]?.numericValue ?? 0);
57
+ const runWarnings = Array.isArray(lhr.runWarnings) ? lhr.runWarnings : [];
58
+ const lcpElItem = audits["largest-contentful-paint-element"]?.details?.items?.[0];
59
+ const lcp = {
60
+ elementSelector: lcpElItem?.node?.selector ?? null,
61
+ elementSnippet: lcpElItem?.node?.snippet ?? null,
62
+ elementUrl: lcpElItem?.url ?? null
63
+ };
64
+ const normalized = Array.isArray(networkItemsRaw) ? networkItemsRaw.map((it) => {
65
+ const u = String(it?.url ?? "");
66
+ if (!u) return null;
67
+ return {
68
+ url: u,
69
+ hostname: safeHost(u),
70
+ resourceType: it?.resourceType ?? null,
71
+ transferSize: toNumberOrNull(it?.transferSize),
72
+ duration: toNumberOrNull(it?.duration)
73
+ };
74
+ }).filter(Boolean) : [];
75
+ const topBySize = [...normalized].sort((a, b) => (b.transferSize ?? 0) - (a.transferSize ?? 0)).slice(0, 10);
76
+ const topByTime = [...normalized].sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)).slice(0, 10);
77
+ const domainMap = /* @__PURE__ */ new Map();
78
+ for (const it of normalized) {
79
+ const key = it.hostname || "unknown";
80
+ const prev = domainMap.get(key) ?? { hostname: key, requests: 0, transferSize: 0 };
81
+ prev.requests += 1;
82
+ prev.transferSize += it.transferSize ?? 0;
83
+ domainMap.set(key, prev);
84
+ }
85
+ const topDomains = [...domainMap.values()].sort((a, b) => b.transferSize - a.transferSize).slice(0, 10);
86
+ return {
87
+ url: url2,
88
+ device: device2,
89
+ scores,
90
+ metrics,
91
+ requests,
92
+ totalByteWeight,
93
+ runWarnings,
94
+ lcp,
95
+ network: { topBySize, topByTime, topDomains },
96
+ lhr
97
+ };
98
+ } finally {
99
+ await chrome.kill();
100
+ }
101
+ }
102
+
103
+ // src/audit/seo.ts
104
+ import puppeteer from "puppeteer";
105
+ async function fetchText(url2) {
106
+ const res = await fetch(url2, { redirect: "follow" });
107
+ return { status: res.status, text: await res.text() };
108
+ }
109
+ async function runSeoChecks(url2) {
110
+ const issues = [];
111
+ const browser = await puppeteer.launch({
112
+ headless: true,
113
+ args: ["--no-sandbox", "--disable-setuid-sandbox"]
114
+ });
115
+ try {
116
+ const page = await browser.newPage();
117
+ await page.setViewport({ width: 390, height: 844 });
118
+ await page.goto(url2, { waitUntil: "networkidle2", timeout: 9e4 });
119
+ const data = await page.evaluate(() => {
120
+ const title = document.title || "";
121
+ const desc = document.querySelector('meta[name="description"]')?.getAttribute("content") || "";
122
+ const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute("href") || "";
123
+ const robots2 = document.querySelector('meta[name="robots"]')?.getAttribute("content") || "";
124
+ const h1s = Array.from(document.querySelectorAll("h1")).map((h) => (h.textContent || "").trim()).filter(Boolean);
125
+ const imgs = Array.from(document.querySelectorAll("img")).map((img) => ({
126
+ alt: (img.getAttribute("alt") || "").trim(),
127
+ src: img.getAttribute("src") || img.getAttribute("data-src") || ""
128
+ }));
129
+ const ldjson = Array.from(
130
+ document.querySelectorAll('script[type="application/ld+json"]')
131
+ ).map((s) => s.textContent || "").filter(Boolean);
132
+ return { title, desc, canonical, robots: robots2, h1s, imgs, ldjson };
133
+ });
134
+ if (!data.title.trim())
135
+ issues.push({ level: "error", message: "Sem <title>." });
136
+ else if (data.title.length > 65)
137
+ issues.push({
138
+ level: "warn",
139
+ message: `<title> muito longo (${data.title.length} chars).`
140
+ });
141
+ if (!data.desc.trim())
142
+ issues.push({ level: "warn", message: "Sem meta description." });
143
+ else if (data.desc.length > 160)
144
+ issues.push({
145
+ level: "warn",
146
+ message: `Description muito longa (${data.desc.length} chars).`
147
+ });
148
+ if (data.robots.toLowerCase().includes("noindex"))
149
+ issues.push({ level: "error", message: "Meta robots cont\xE9m NOINDEX." });
150
+ if (!data.canonical)
151
+ issues.push({ level: "warn", message: "Sem canonical." });
152
+ else {
153
+ try {
154
+ const c = new URL(data.canonical, url2);
155
+ const u = new URL(url2);
156
+ if (c.host !== u.host)
157
+ issues.push({
158
+ level: "warn",
159
+ message: `Canonical aponta para outro dom\xEDnio: ${c.host}`
160
+ });
161
+ } catch {
162
+ issues.push({ level: "warn", message: "Canonical inv\xE1lido." });
163
+ }
164
+ }
165
+ if (data.h1s.length === 0)
166
+ issues.push({ level: "warn", message: "Sem H1." });
167
+ if (data.h1s.length > 1)
168
+ issues.push({
169
+ level: "warn",
170
+ message: `M\xFAltiplos H1 (${data.h1s.length}).`
171
+ });
172
+ const imgsNoAlt = data.imgs.filter((i) => i.src && !i.alt);
173
+ if (imgsNoAlt.length)
174
+ issues.push({
175
+ level: "warn",
176
+ message: `${imgsNoAlt.length} imagens sem alt.`
177
+ });
178
+ let ldOk = 0;
179
+ let ldBad = 0;
180
+ for (const chunk of data.ldjson) {
181
+ try {
182
+ JSON.parse(chunk);
183
+ ldOk++;
184
+ } catch {
185
+ ldBad++;
186
+ }
187
+ }
188
+ if (ldBad)
189
+ issues.push({
190
+ level: "warn",
191
+ message: `JSON-LD inv\xE1lido em ${ldBad} bloco(s).`
192
+ });
193
+ if (!data.ldjson.length)
194
+ issues.push({ level: "warn", message: "Nenhum JSON-LD encontrado." });
195
+ const base = new URL(url2);
196
+ const robotsUrl = `${base.origin}/robots.txt`;
197
+ const sitemapUrl = `${base.origin}/sitemap.xml`;
198
+ const robots = await fetchText(robotsUrl);
199
+ if (robots.status >= 400)
200
+ issues.push({
201
+ level: "warn",
202
+ message: `robots.txt n\xE3o acess\xEDvel (HTTP ${robots.status}).`
203
+ });
204
+ const sitemap = await fetchText(sitemapUrl);
205
+ if (sitemap.status >= 400)
206
+ issues.push({
207
+ level: "warn",
208
+ message: `sitemap.xml n\xE3o acess\xEDvel (HTTP ${sitemap.status}).`
209
+ });
210
+ else if (!sitemap.text.includes("<urlset") && !sitemap.text.includes("<sitemapindex")) {
211
+ issues.push({
212
+ level: "warn",
213
+ message: "sitemap.xml existe mas n\xE3o parece v\xE1lido (sem urlset/sitemapindex)."
214
+ });
215
+ }
216
+ return { url: url2, issues, snapshot: data };
217
+ } finally {
218
+ await browser.close();
219
+ }
220
+ }
221
+
222
+ // src/audit/budgets.ts
223
+ import fs from "fs";
224
+ import { z } from "zod";
225
+ var BudgetSchema = z.object({
226
+ performanceMinScore: z.number().optional(),
227
+ seoMinScore: z.number().optional(),
228
+ maxTotalKB: z.number().optional(),
229
+ maxRequests: z.number().optional(),
230
+ maxLCPms: z.number().optional(),
231
+ maxCLS: z.number().optional(),
232
+ maxTBTms: z.number().optional()
233
+ });
234
+ function loadBudget(path2) {
235
+ const raw = JSON.parse(fs.readFileSync(path2, "utf-8"));
236
+ const parsed = BudgetSchema.safeParse(raw);
237
+ if (!parsed.success) throw new Error("budget.json inv\xE1lido");
238
+ return parsed.data;
239
+ }
240
+ function evaluateBudget(input, budget) {
241
+ if (!budget) return { ok: true, failures: [] };
242
+ const failures = [];
243
+ const lh = input.lighthouse;
244
+ const totalKB = Math.round((lh.totalByteWeight ?? 0) / 1024);
245
+ const requests = lh.pageStats?.requests ?? null;
246
+ if (budget.performanceMinScore != null && lh.scores.performance < budget.performanceMinScore)
247
+ failures.push(`Performance score ${lh.scores.performance} < ${budget.performanceMinScore}`);
248
+ if (budget.seoMinScore != null && lh.scores.seo < budget.seoMinScore)
249
+ failures.push(`SEO score ${lh.scores.seo} < ${budget.seoMinScore}`);
250
+ if (budget.maxTotalKB != null && totalKB > budget.maxTotalKB)
251
+ failures.push(`Total weight ${totalKB}KB > ${budget.maxTotalKB}KB`);
252
+ if (budget.maxRequests != null && requests != null && requests > budget.maxRequests)
253
+ failures.push(`Requests ${requests} > ${budget.maxRequests}`);
254
+ if (budget.maxLCPms != null && lh.metrics.lcpMs > budget.maxLCPms)
255
+ failures.push(`LCP ${lh.metrics.lcpMs}ms > ${budget.maxLCPms}ms`);
256
+ if (budget.maxCLS != null && lh.metrics.cls > budget.maxCLS)
257
+ failures.push(`CLS ${lh.metrics.cls} > ${budget.maxCLS}`);
258
+ if (budget.maxTBTms != null && lh.metrics.tbtMs > budget.maxTBTms)
259
+ failures.push(`TBT ${lh.metrics.tbtMs}ms > ${budget.maxTBTms}ms`);
260
+ const noindex = input.seo.issues.some((i) => i.level === "error" && i.message.includes("NOINDEX"));
261
+ if (noindex) failures.push("Meta robots NOINDEX");
262
+ return { ok: failures.length === 0, failures };
263
+ }
264
+
265
+ // src/audit/report.ts
266
+ import fs2 from "fs";
267
+ import path from "path";
268
+ async function saveReports(params) {
269
+ fs2.mkdirSync(params.outDir, { recursive: true });
270
+ const outBase = path.join(params.outDir, `${params.device}-${new URL(params.url).hostname}`);
271
+ if (params.wantJson) {
272
+ fs2.writeFileSync(
273
+ `${outBase}.json`,
274
+ JSON.stringify(
275
+ {
276
+ url: params.url,
277
+ device: params.device,
278
+ scores: params.lighthouse.scores,
279
+ metrics: params.lighthouse.metrics,
280
+ evaluation: params.evaluation,
281
+ seo: params.seo,
282
+ lighthouse: params.lighthouse.lhr
283
+ },
284
+ null,
285
+ 2
286
+ )
287
+ );
288
+ }
289
+ if (params.wantHtml) {
290
+ fs2.writeFileSync(`${outBase}.html`, "<!-- implementar output html do lighthouse -->");
291
+ }
292
+ }
293
+
294
+ // src/index.ts
295
+ function normalizeUrl(url2) {
296
+ const u = new URL(url2);
297
+ u.hash = "";
298
+ return u.toString();
299
+ }
300
+ function ms(v) {
301
+ if (v == null || !Number.isFinite(v)) return "-";
302
+ if (v >= 1e3) return `${(v / 1e3).toFixed(2)}s`;
303
+ return `${Math.round(v)}ms`;
304
+ }
305
+ function scoreBadge(n) {
306
+ if (n >= 90) return chalk.green(String(n));
307
+ if (n >= 70) return chalk.yellow(String(n));
308
+ return chalk.red(String(n));
309
+ }
310
+ function shortUrl(u, max = 90) {
311
+ if (u.length <= max) return u;
312
+ return u.slice(0, max - 1) + "\u2026";
313
+ }
314
+ function isThirdPartyDomain(host, siteHost) {
315
+ if (!host) return false;
316
+ if (host === siteHost) return false;
317
+ const vtexLike = ["vtexassets.com", "vteximg.com.br", "myvtex.com", "vtex.com", "vtexcommerce"];
318
+ if (vtexLike.some((x) => host.includes(x))) return false;
319
+ return true;
320
+ }
321
+ function likelyFixes(res) {
322
+ const fixes = [];
323
+ const host = new URL(res.url).hostname;
324
+ const lcpUrl = res.lcp.elementUrl ?? "";
325
+ const lcpIsImage = !!lcpUrl && /\.(png|jpe?g|webp|avif)(\?|$)/i.test(lcpUrl);
326
+ if (res.metrics.lcpMs > 4e3) {
327
+ if (lcpIsImage) {
328
+ fixes.push("LCP alto e parece ser imagem: priorize o banner/hero (evitar lazy no 1\xBA banner) e reduza o peso (WebP/AVIF + dimens\xF5es mobile).");
329
+ } else {
330
+ fixes.push("LCP alto: verifique render-blocking (CSS/JS), hidrata\xE7\xE3o/React acima do fold e scripts no head.");
331
+ }
332
+ }
333
+ if ((res.requests ?? 0) > 140) {
334
+ fixes.push("Requests altos: revise apps e scripts globais (pixels/chat/reviews), condicione por p\xE1gina e adie carregamento onde poss\xEDvel.");
335
+ }
336
+ const thirdParty = res.network.topDomains.filter((d) => isThirdPartyDomain(d.hostname, host));
337
+ if (thirdParty.length) {
338
+ fixes.push(`Terceiros pesando na rede: investigue dom\xEDnios como ${thirdParty.slice(0, 3).map((d) => d.hostname).join(", ")} (tags, pixels, chat, A/B).`);
339
+ }
340
+ if (res.scores.bestPractices < 70) {
341
+ fixes.push("Best Practices baixo: costuma envolver imagens/cookies/HTTPS/console errors. Veja o relat\xF3rio JSON para os audits com falha.");
342
+ }
343
+ return fixes;
344
+ }
345
+ var program = new Command();
346
+ program.name("vtex-audit").description("Audita uma URL p\xFAblica (Lighthouse + checks) com sa\xEDda amig\xE1vel.").requiredOption("--url <url>", "URL p\xFAblica para auditar (https://...)").option("--device <device>", "mobile|desktop (default: mobile)", "mobile").option("--budget <path>", "Arquivo budget.json").option("--json", "Salvar relat\xF3rio JSON").option("--html", "Salvar relat\xF3rio HTML do Lighthouse").option("--out <dir>", "Diret\xF3rio de sa\xEDda (default: ./vtex-audit)", "./vtex-audit").option("--verbose", "Mostra warnings e detalhes extras").parse(process.argv);
347
+ var opts = program.opts();
348
+ var url = normalizeUrl(opts.url);
349
+ var device = opts.device === "desktop" ? "desktop" : "mobile";
350
+ (async () => {
351
+ console.log(
352
+ boxen(
353
+ `${chalk.cyan.bold("VTEX Audit")}
354
+ ${chalk.gray("Device:")} ${chalk.white(device)}
355
+ ${chalk.gray("URL:")} ${chalk.white(url)}`,
356
+ { padding: 1, borderStyle: "round", borderColor: "cyan" }
357
+ )
358
+ );
359
+ const budget = opts.budget ? loadBudget(opts.budget) : null;
360
+ const sp1 = ora("Rodando Lighthouse\u2026").start();
361
+ const t0 = Date.now();
362
+ const lighthouseRes = await runLighthouse(url, device);
363
+ sp1.succeed(`Lighthouse conclu\xEDdo (${((Date.now() - t0) / 1e3).toFixed(1)}s)`);
364
+ const sp2 = ora("Executando checks de SEO\u2026").start();
365
+ const t1 = Date.now();
366
+ const seoRes = await runSeoChecks(url);
367
+ sp2.succeed(`Checks de SEO conclu\xEDdos (${((Date.now() - t1) / 1e3).toFixed(1)}s)`);
368
+ const sp3 = ora("Avaliando budget/limites\u2026").start();
369
+ const evaluation = evaluateBudget({ lighthouse: lighthouseRes, seo: seoRes }, budget);
370
+ sp3.succeed("Budget avaliado");
371
+ const scoreTable = new Table({
372
+ head: [chalk.bold("Categoria"), chalk.bold("Score")],
373
+ style: { head: [], border: [] }
374
+ });
375
+ scoreTable.push(
376
+ ["Performance", scoreBadge(lighthouseRes.scores.performance)],
377
+ ["SEO", scoreBadge(lighthouseRes.scores.seo)],
378
+ ["A11y", scoreBadge(lighthouseRes.scores.accessibility)],
379
+ ["Best Practices", scoreBadge(lighthouseRes.scores.bestPractices)]
380
+ );
381
+ console.log(chalk.bold("\nScores (Lighthouse)"));
382
+ console.log(scoreTable.toString());
383
+ const metricsTable = new Table({
384
+ head: [chalk.bold("M\xE9trica"), chalk.bold("Valor")],
385
+ style: { head: [], border: [] }
386
+ });
387
+ metricsTable.push(
388
+ ["LCP", ms(lighthouseRes.metrics.lcpMs)],
389
+ ["CLS", String(lighthouseRes.metrics.cls)],
390
+ ["TBT", ms(lighthouseRes.metrics.tbtMs)],
391
+ ["TTI", ms(lighthouseRes.metrics.ttiMs)],
392
+ ["Speed Index", ms(lighthouseRes.metrics.speedIndexMs)],
393
+ ["Requests", lighthouseRes.requests ?? "-"],
394
+ ["Total weight", prettyBytes(lighthouseRes.totalByteWeight)]
395
+ );
396
+ console.log(chalk.bold("\nCore metrics"));
397
+ console.log(metricsTable.toString());
398
+ console.log(chalk.bold("\nLCP details"));
399
+ if (lighthouseRes.lcp.elementSelector || lighthouseRes.lcp.elementUrl) {
400
+ console.log(`- Element: ${lighthouseRes.lcp.elementSelector ?? "(sem selector)"}`);
401
+ if (lighthouseRes.lcp.elementUrl) console.log(`- Asset: ${chalk.gray(lighthouseRes.lcp.elementUrl)}`);
402
+ } else {
403
+ console.log(chalk.yellow("- Element: (n\xE3o identificado no relat\xF3rio)"));
404
+ }
405
+ console.log(chalk.bold("\nTop culprits"));
406
+ const topSizeTable = new Table({
407
+ head: [chalk.bold("KB"), chalk.bold("Type"), chalk.bold("Host"), chalk.bold("URL")],
408
+ colWidths: [8, 10, 24, 90],
409
+ style: { head: [], border: [] },
410
+ wordWrap: true
411
+ });
412
+ for (const it of lighthouseRes.network.topBySize) {
413
+ topSizeTable.push([
414
+ it.transferSize != null ? String(Math.round(it.transferSize / 1024)) : "-",
415
+ it.resourceType ?? "-",
416
+ it.hostname,
417
+ shortUrl(it.url)
418
+ ]);
419
+ }
420
+ console.log(chalk.gray("\nLargest transfers (Top 10)"));
421
+ console.log(topSizeTable.toString());
422
+ const topTimeTable = new Table({
423
+ head: [chalk.bold("Time"), chalk.bold("Type"), chalk.bold("Host"), chalk.bold("URL")],
424
+ colWidths: [10, 10, 24, 90],
425
+ style: { head: [], border: [] },
426
+ wordWrap: true
427
+ });
428
+ for (const it of lighthouseRes.network.topByTime) {
429
+ topTimeTable.push([
430
+ ms(it.duration),
431
+ it.resourceType ?? "-",
432
+ it.hostname,
433
+ shortUrl(it.url)
434
+ ]);
435
+ }
436
+ console.log(chalk.gray("\nSlowest requests (Top 10)"));
437
+ console.log(topTimeTable.toString());
438
+ const domainsTable = new Table({
439
+ head: [chalk.bold("Host"), chalk.bold("Req"), chalk.bold("KB")],
440
+ colWidths: [42, 8, 10],
441
+ style: { head: [], border: [] }
442
+ });
443
+ const siteHost = new URL(url).hostname;
444
+ for (const d of lighthouseRes.network.topDomains) {
445
+ const hostLabel = isThirdPartyDomain(d.hostname, siteHost) ? chalk.yellow(d.hostname) : d.hostname;
446
+ domainsTable.push([hostLabel, String(d.requests), String(Math.round(d.transferSize / 1024))]);
447
+ }
448
+ console.log(chalk.gray("\nTop domains by transfer"));
449
+ console.log(domainsTable.toString());
450
+ console.log(chalk.gray("(dom\xEDnios amarelos = prov\xE1vel 3rd-party)"));
451
+ const fixes = likelyFixes(lighthouseRes);
452
+ if (fixes.length) {
453
+ console.log(chalk.bold("\nLikely fixes (VTEX IO)"));
454
+ for (const f of fixes) console.log(`- ${chalk.cyan("\u2192")} ${f}`);
455
+ }
456
+ console.log(chalk.bold("\nSEO checks"));
457
+ for (const i of seoRes.issues.slice(0, 12)) {
458
+ const tag = i.level === "error" ? chalk.red("\u2716") : chalk.yellow("\u25B2");
459
+ console.log(`${tag} ${i.message}`);
460
+ }
461
+ if (seoRes.issues.length > 12) console.log(chalk.gray(`\u2026 +${seoRes.issues.length - 12} itens`));
462
+ if (opts.verbose && lighthouseRes.runWarnings?.length) {
463
+ console.log(chalk.bold("\nLighthouse warnings"));
464
+ for (const w of lighthouseRes.runWarnings) console.log(chalk.yellow(`\u25B2 ${w}`));
465
+ }
466
+ const sp4 = ora("Salvando relat\xF3rios\u2026").start();
467
+ await saveReports({
468
+ outDir: opts.out,
469
+ url,
470
+ device,
471
+ wantJson: !!opts.json,
472
+ wantHtml: !!opts.html,
473
+ lighthouse: lighthouseRes,
474
+ seo: seoRes,
475
+ evaluation
476
+ });
477
+ sp4.succeed(`Relat\xF3rios salvos em: ${chalk.gray(opts.out)}`);
478
+ if (!evaluation.ok) {
479
+ console.log(chalk.red("\nFalhou no budget/limites."));
480
+ if (evaluation.failures?.length) for (const f of evaluation.failures) console.log(chalk.red(`- ${f}`));
481
+ process.exit(1);
482
+ }
483
+ console.log(chalk.green("\n\u2714 Passou."));
484
+ process.exit(0);
485
+ })().catch((err) => {
486
+ console.error(chalk.red("\nErro inesperado:"), err);
487
+ process.exit(1);
488
+ });
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "vtex-audit",
3
+ "version": "0.1.0",
4
+ "description": "Professional VTEX IO audit CLI (Performance, SEO, LCP, Lighthouse) with actionable diagnostics.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vtex-audit": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "prepare": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "vtex",
20
+ "vtex-io",
21
+ "lighthouse",
22
+ "seo",
23
+ "performance",
24
+ "audit",
25
+ "ecommerce",
26
+ "cli"
27
+ ],
28
+ "author": "Denis Palhares Gonçalves",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/SEU_USUARIO/vtex-audit.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/SEU_USUARIO/vtex-audit/issues"
36
+ },
37
+ "homepage": "https://github.com/SEU_USUARIO/vtex-audit#readme",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "boxen": "^8.0.1",
43
+ "chalk": "^5.3.0",
44
+ "chrome-launcher": "^1.1.0",
45
+ "cli-table3": "^0.6.5",
46
+ "commander": "^12.1.0",
47
+ "lighthouse": "^12.0.0",
48
+ "ora": "^9.1.0",
49
+ "pretty-bytes": "^7.1.0",
50
+ "puppeteer": "^23.0.0",
51
+ "zod": "^3.23.8"
52
+ },
53
+ "devDependencies": {
54
+ "tsup": "^8.2.4",
55
+ "typescript": "^5.9.3"
56
+ }
57
+ }