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 +226 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +488 -0
- package/package.json +57 -0
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
|
package/dist/index.d.ts
ADDED
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
|
+
}
|