seo-intel 1.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/.env.example +41 -0
- package/LICENSE +75 -0
- package/README.md +243 -0
- package/Start SEO Intel.bat +9 -0
- package/Start SEO Intel.command +8 -0
- package/cli.js +3727 -0
- package/config/example.json +29 -0
- package/config/setup-wizard.js +522 -0
- package/crawler/index.js +566 -0
- package/crawler/robots.js +103 -0
- package/crawler/sanitize.js +124 -0
- package/crawler/schema-parser.js +168 -0
- package/crawler/sitemap.js +103 -0
- package/crawler/stealth.js +393 -0
- package/crawler/subdomain-discovery.js +341 -0
- package/db/db.js +213 -0
- package/db/schema.sql +120 -0
- package/exports/competitive.js +186 -0
- package/exports/heuristics.js +67 -0
- package/exports/queries.js +197 -0
- package/exports/suggestive.js +230 -0
- package/exports/technical.js +180 -0
- package/exports/templates.js +77 -0
- package/lib/gate.js +204 -0
- package/lib/license.js +369 -0
- package/lib/oauth.js +432 -0
- package/lib/updater.js +324 -0
- package/package.json +68 -0
- package/reports/generate-html.js +6194 -0
- package/reports/generate-site-graph.js +949 -0
- package/reports/gsc-loader.js +190 -0
- package/scheduler.js +142 -0
- package/seo-audit.js +619 -0
- package/seo-intel.png +0 -0
- package/server.js +602 -0
- package/setup/ROADMAP.md +109 -0
- package/setup/checks.js +483 -0
- package/setup/config-builder.js +227 -0
- package/setup/engine.js +65 -0
- package/setup/installers.js +197 -0
- package/setup/models.js +328 -0
- package/setup/openclaw-bridge.js +329 -0
- package/setup/validator.js +395 -0
- package/setup/web-routes.js +688 -0
- package/setup/wizard.html +2920 -0
- package/start-seo-intel.sh +8 -0
package/seo-audit.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* seo-audit <url>
|
|
4
|
+
* On-demand SEO audit for any URL — Ahrefs toolbar style.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
import { chromium } from 'playwright';
|
|
9
|
+
import fetch from 'node-fetch';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
const url = process.argv[2];
|
|
13
|
+
const jsonMode = process.argv.includes("--json");
|
|
14
|
+
const reportMode = process.argv.includes("--report");
|
|
15
|
+
|
|
16
|
+
if (!url) {
|
|
17
|
+
console.error('Usage: node seo-audit.js <url> [--json]');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ok = (t) => chalk.green('✅ ' + t);
|
|
22
|
+
const warn = (t) => chalk.yellow('⚠️ ' + t);
|
|
23
|
+
const fail = (t) => chalk.red('❌ ' + t);
|
|
24
|
+
const info = (t) => chalk.cyan(' ↳ ' + t);
|
|
25
|
+
const dim = (t) => chalk.gray(' ' + t);
|
|
26
|
+
const head = (t) => chalk.bold.white('\n' + t);
|
|
27
|
+
|
|
28
|
+
function titleLen(t) {
|
|
29
|
+
if (!t) return { status: 'fail', label: 'Missing' };
|
|
30
|
+
const l = t.length;
|
|
31
|
+
if (l < 30) return { status: 'warn', label: `Too short (${l}/60)` };
|
|
32
|
+
if (l > 60) return { status: 'warn', label: `Too long (${l}/60)` };
|
|
33
|
+
return { status: 'ok', label: `${l}/60` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function descLen(t) {
|
|
37
|
+
if (!t) return { status: 'fail', label: 'Missing' };
|
|
38
|
+
const l = t.length;
|
|
39
|
+
if (l < 50) return { status: 'warn', label: `Too short (${l}/160)` };
|
|
40
|
+
if (l > 160) return { status: 'warn', label: `Too long (${l}/160)` };
|
|
41
|
+
return { status: 'ok', label: `${l}/160` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderStatus(s, label) {
|
|
45
|
+
if (s === 'ok') return ok(label);
|
|
46
|
+
if (s === 'warn') return warn(label);
|
|
47
|
+
return fail(label);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkHeadingOrder(headings) {
|
|
51
|
+
const issues = [];
|
|
52
|
+
if (headings.length === 0) return { ok: false, issues: ['No headings found'] };
|
|
53
|
+
if (headings[0].level !== 1) issues.push(`First heading is H${headings[0].level}, not H1`);
|
|
54
|
+
for (let i = 1; i < headings.length; i++) {
|
|
55
|
+
if (headings[i].level > headings[i-1].level + 1) {
|
|
56
|
+
issues.push(`H${headings[i-1].level} → H${headings[i].level} skip near "${headings[i].text.slice(0,40)}"`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ok: issues.length === 0, issues };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function checkUrl(u) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(u, { method: 'HEAD', redirect: 'follow' }).catch(() => null);
|
|
65
|
+
return res?.ok ? 'ok' : 'fail';
|
|
66
|
+
} catch { return 'fail'; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchRaw(url) {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' },
|
|
72
|
+
redirect: 'follow',
|
|
73
|
+
});
|
|
74
|
+
const html = await res.text();
|
|
75
|
+
return { html, status: res.status, finalUrl: res.url };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseRawHtml(html) {
|
|
79
|
+
const schemas = [];
|
|
80
|
+
const schemaRe = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = schemaRe.exec(html)) !== null) {
|
|
83
|
+
try { schemas.push(JSON.parse(m[1])); } catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const og = {};
|
|
87
|
+
const ogRe = /<meta[^>]+property=["'](og:[^"']+)["'][^>]+content=["']([^"']*)["'][^>]*>/gi;
|
|
88
|
+
while ((m = ogRe.exec(html)) !== null) og[m[1]] = m[2];
|
|
89
|
+
// also try reversed attr order
|
|
90
|
+
const ogRe2 = /<meta[^>]+content=["']([^"']*)["'][^>]+property=["'](og:[^"']+)["'][^>]*>/gi;
|
|
91
|
+
while ((m = ogRe2.exec(html)) !== null) og[m[2]] = m[1];
|
|
92
|
+
|
|
93
|
+
const twitter = {};
|
|
94
|
+
const twRe = /<meta[^>]+name=["'](twitter:[^"']+)["'][^>]+content=["']([^"']*)["'][^>]*>/gi;
|
|
95
|
+
while ((m = twRe.exec(html)) !== null) twitter[m[1]] = m[2];
|
|
96
|
+
const twRe2 = /<meta[^>]+content=["']([^"']*)["'][^>]+name=["'](twitter:[^"']+)["'][^>]*>/gi;
|
|
97
|
+
while ((m = twRe2.exec(html)) !== null) twitter[m[2]] = m[1];
|
|
98
|
+
|
|
99
|
+
const canonicalMatch = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i)
|
|
100
|
+
|| html.match(/<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i);
|
|
101
|
+
const canonical = canonicalMatch?.[1] || null;
|
|
102
|
+
|
|
103
|
+
const robotsMatch = html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i);
|
|
104
|
+
const robotsMeta = robotsMatch?.[1] || null;
|
|
105
|
+
|
|
106
|
+
const imgRe = /<img([^>]*)>/gi;
|
|
107
|
+
const images = [];
|
|
108
|
+
while ((m = imgRe.exec(html)) !== null) {
|
|
109
|
+
const attrs = m[1];
|
|
110
|
+
const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
|
|
111
|
+
const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
|
|
112
|
+
images.push({ src: srcMatch?.[1] || '', alt: altMatch ? altMatch[1] : null });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hasFaq = html.toLowerCase().includes('faq') ||
|
|
116
|
+
schemas.some(s => s['@type'] === 'FAQPage') ||
|
|
117
|
+
html.toLowerCase().includes('frequently asked');
|
|
118
|
+
|
|
119
|
+
return { schemas, og, twitter, canonical, robotsMeta, images, hasFaq };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function crawlPage(url) {
|
|
123
|
+
const browser = await chromium.launch({ headless: true });
|
|
124
|
+
const context = await browser.newContext({
|
|
125
|
+
userAgent: 'Mozilla/5.0 (compatible; SEOAuditBot/1.0)',
|
|
126
|
+
ignoreHTTPSErrors: true,
|
|
127
|
+
});
|
|
128
|
+
const page = await context.newPage();
|
|
129
|
+
try {
|
|
130
|
+
const t0 = Date.now();
|
|
131
|
+
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
132
|
+
const loadMs = Date.now() - t0;
|
|
133
|
+
const status = res?.status() || 0;
|
|
134
|
+
|
|
135
|
+
const title = await page.title().catch(() => '');
|
|
136
|
+
const metaDesc = await page.$eval('meta[name="description"]', el => el.content).catch(() => '');
|
|
137
|
+
|
|
138
|
+
const headings = await page.$$eval('h1,h2,h3,h4,h5,h6', els =>
|
|
139
|
+
els.map(el => ({ level: parseInt(el.tagName[1]), text: el.innerText?.trim().slice(0, 120) }))
|
|
140
|
+
.filter(h => h.text)
|
|
141
|
+
).catch(() => []);
|
|
142
|
+
|
|
143
|
+
const wordCount = await page.$eval('body', el =>
|
|
144
|
+
el.innerText.split(/\s+/).filter(Boolean).length
|
|
145
|
+
).catch(() => 0);
|
|
146
|
+
|
|
147
|
+
const base = new URL(url);
|
|
148
|
+
const allLinks = await page.$$eval('a[href]', (els, baseHref) =>
|
|
149
|
+
els.map(el => {
|
|
150
|
+
try { return { href: new URL(el.href, baseHref).href, anchor: el.innerText?.trim().slice(0, 80) || '' }; }
|
|
151
|
+
catch { return null; }
|
|
152
|
+
}).filter(Boolean), base.href
|
|
153
|
+
).catch(() => []);
|
|
154
|
+
|
|
155
|
+
const internalLinks = allLinks.filter(l => { try { return new URL(l.href).hostname === base.hostname; } catch { return false; } });
|
|
156
|
+
const externalLinks = allLinks.filter(l => { try { return new URL(l.href).hostname !== base.hostname; } catch { return false; } });
|
|
157
|
+
|
|
158
|
+
const publishedDate = await page.evaluate(() => {
|
|
159
|
+
for (const sel of ['meta[property="article:published_time"]','meta[name="date"]','meta[itemprop="datePublished"]']) {
|
|
160
|
+
const el = document.querySelector(sel); if (el?.content) return el.content;
|
|
161
|
+
}
|
|
162
|
+
for (const el of document.querySelectorAll('script[type="application/ld+json"]')) {
|
|
163
|
+
try { const d = JSON.parse(el.textContent); if (d.datePublished) return d.datePublished; } catch {}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}).catch(() => null);
|
|
167
|
+
|
|
168
|
+
const modifiedDate = await page.evaluate(() => {
|
|
169
|
+
for (const sel of ['meta[property="article:modified_time"]','meta[name="last-modified"]','meta[itemprop="dateModified"]']) {
|
|
170
|
+
const el = document.querySelector(sel); if (el?.content) return el.content;
|
|
171
|
+
}
|
|
172
|
+
for (const el of document.querySelectorAll('script[type="application/ld+json"]')) {
|
|
173
|
+
try { const d = JSON.parse(el.textContent); if (d.dateModified) return d.dateModified; } catch {}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}).catch(() => null);
|
|
177
|
+
|
|
178
|
+
return { status, loadMs, title, metaDesc, headings, wordCount, internalLinks, externalLinks, publishedDate, modifiedDate };
|
|
179
|
+
} finally {
|
|
180
|
+
await browser.close().catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function audit(rawUrl) {
|
|
185
|
+
const parsedUrl = new URL(rawUrl);
|
|
186
|
+
const origin = parsedUrl.origin;
|
|
187
|
+
|
|
188
|
+
if (!jsonMode) process.stdout.write(chalk.gray('Auditing... (this takes ~10s)\n'));
|
|
189
|
+
|
|
190
|
+
const [raw, rendered, sitemapStatus, robotsStatus] = await Promise.all([
|
|
191
|
+
fetchRaw(rawUrl),
|
|
192
|
+
crawlPage(rawUrl),
|
|
193
|
+
checkUrl(`${origin}/sitemap.xml`),
|
|
194
|
+
checkUrl(`${origin}/robots.txt`),
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
const { schemas, og, twitter, canonical, robotsMeta, images, hasFaq } = parseRawHtml(raw.html);
|
|
198
|
+
const { status, loadMs, title, metaDesc, headings, wordCount, internalLinks, externalLinks, publishedDate, modifiedDate } = rendered;
|
|
199
|
+
|
|
200
|
+
const schemaTypes = schemas.map(s => s['@type']).filter(Boolean);
|
|
201
|
+
const missingAlt = images.filter(i => i.alt === null || i.alt === '').length;
|
|
202
|
+
const headingCheck = checkHeadingOrder(headings);
|
|
203
|
+
const titleInfo = titleLen(title);
|
|
204
|
+
const descInfo = descLen(metaDesc);
|
|
205
|
+
const isIndexable = !robotsMeta?.toLowerCase().includes('noindex');
|
|
206
|
+
|
|
207
|
+
if (jsonMode) {
|
|
208
|
+
console.log(JSON.stringify({ url: rawUrl, status, loadMs, title, metaDesc, headings, wordCount, canonical, robotsMeta, isIndexable, sitemapFound: sitemapStatus === 'ok', robotsFound: robotsStatus === 'ok', schemaTypes, hasFaq, og, twitter, images: { total: images.length, missingAlt }, internalLinks: internalLinks.length, externalLinks: externalLinks.length, publishedDate, modifiedDate }, null, 2));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(chalk.bold(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`));
|
|
213
|
+
console.log(chalk.bold.white(` 🔍 SEO Audit — ${rawUrl}`));
|
|
214
|
+
console.log(chalk.bold(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`));
|
|
215
|
+
|
|
216
|
+
// CONTENT
|
|
217
|
+
console.log(head('📄 CONTENT'));
|
|
218
|
+
console.log(renderStatus(titleInfo.status, `Meta Title — ${titleInfo.label}`));
|
|
219
|
+
if (title) console.log(dim(`"${title.slice(0, 70)}"`));
|
|
220
|
+
if (titleInfo.status === 'fail') console.log(info('Title is your #1 ranking signal. Missing = Google writes one for you (badly).'));
|
|
221
|
+
if (titleInfo.status === 'warn' && title.length > 60) console.log(info('Google truncates titles over 60 chars. Shorter = full display in search results.'));
|
|
222
|
+
if (titleInfo.status === 'warn' && title.length < 30) console.log(info('Short titles miss keyword opportunities. Aim 50-60 chars to fill the snippet.'));
|
|
223
|
+
|
|
224
|
+
console.log(renderStatus(descInfo.status, `Meta Description — ${descInfo.label}`));
|
|
225
|
+
if (metaDesc) console.log(dim(`"${metaDesc.slice(0, 90)}..."`));
|
|
226
|
+
if (descInfo.status === 'fail') console.log(info('No description = Google pulls random page text. Write one to control your snippet.'));
|
|
227
|
+
if (descInfo.status === 'warn' && metaDesc.length > 160) console.log(info('Truncated at ~160 chars. Your CTA at the end may get cut off.'));
|
|
228
|
+
|
|
229
|
+
console.log(publishedDate ? ok(`Published Date — ${publishedDate}`) : warn('Published Date — Missing'));
|
|
230
|
+
if (!publishedDate) console.log(info('Published date signals freshness. Helps content rank for recency queries.'));
|
|
231
|
+
|
|
232
|
+
console.log(modifiedDate ? ok(`Modified Date — ${modifiedDate}`) : warn('Modified Date — Missing'));
|
|
233
|
+
if (!modifiedDate) console.log(info('Updated date tells Google content is maintained. Important for competitive queries.'));
|
|
234
|
+
|
|
235
|
+
const wcStatus = wordCount >= 600 ? 'ok' : wordCount >= 300 ? 'warn' : 'fail';
|
|
236
|
+
console.log(renderStatus(wcStatus, `Word Count — ${wordCount.toLocaleString()} words`));
|
|
237
|
+
if (wcStatus === 'warn') console.log(info('300-600 words is thin. Competitors with deeper content tend to outrank on informational queries.'));
|
|
238
|
+
if (wcStatus === 'fail') console.log(info('Under 300 words. Google may flag as thin content. Add FAQ, features, or use cases.'));
|
|
239
|
+
|
|
240
|
+
// HEADINGS
|
|
241
|
+
console.log(head('📑 HEADINGS'));
|
|
242
|
+
console.log(headingCheck.ok ? ok('Heading structure correct') : warn('Heading structure issues detected'));
|
|
243
|
+
for (const issue of headingCheck.issues) console.log(info(issue));
|
|
244
|
+
if (!headingCheck.ok) console.log(info('H1→H2→H3 order helps Google understand hierarchy and boosts content relevance.'));
|
|
245
|
+
const topH = headings.slice(0, 6);
|
|
246
|
+
if (topH.length) {
|
|
247
|
+
console.log(dim('First 6 headings:'));
|
|
248
|
+
for (const h of topH) console.log(chalk.gray(` ${' '.repeat(h.level-1)}H${h.level}: ${h.text.slice(0,70)}`));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// INDEXABILITY
|
|
252
|
+
console.log(head('🔎 INDEXABILITY'));
|
|
253
|
+
console.log(renderStatus(status >= 200 && status < 300 ? 'ok' : 'fail', `HTTP Status — ${status}`));
|
|
254
|
+
console.log(isIndexable ? ok('Robots meta — index allowed') : fail('Robots meta — noindex set!'));
|
|
255
|
+
if (!isIndexable) console.log(info('Page blocked from Google by meta robots tag. Remove noindex to allow crawling.'));
|
|
256
|
+
if (robotsMeta) console.log(dim(`robots: "${robotsMeta}"`));
|
|
257
|
+
console.log(canonical ? ok(`Canonical — ${canonical}`) : warn('Canonical tag — Missing'));
|
|
258
|
+
if (!canonical) console.log(info('Without canonical, Google may index duplicate versions (http vs https, trailing slash, etc).'));
|
|
259
|
+
console.log(robotsStatus === 'ok' ? ok(`robots.txt — Found`) : fail(`robots.txt — Missing (${origin}/robots.txt)`));
|
|
260
|
+
if (robotsStatus !== 'ok') console.log(info('robots.txt tells crawlers what to index. Missing = Google guesses. Always have one.'));
|
|
261
|
+
console.log(sitemapStatus === 'ok' ? ok(`Sitemap — Found`) : fail(`Sitemap — Missing (${origin}/sitemap.xml)`));
|
|
262
|
+
if (sitemapStatus !== 'ok') console.log(info('Sitemap tells Google every URL to index. Critical for multi-page sites and fast discovery.'));
|
|
263
|
+
const loadStatus = loadMs < 1500 ? 'ok' : loadMs < 3000 ? 'warn' : 'fail';
|
|
264
|
+
console.log(renderStatus(loadStatus, `Load Time — ${loadMs}ms`));
|
|
265
|
+
if (loadMs > 3000) console.log(info('Over 3s loses rankings. Core Web Vitals are a ranking factor. Target under 1.5s.'));
|
|
266
|
+
if (loadMs > 1500 && loadMs <= 3000) console.log(info('Decent but not great. Under 1.5s is the Core Web Vitals gold standard.'));
|
|
267
|
+
|
|
268
|
+
// STRUCTURED DATA
|
|
269
|
+
console.log(head('🧩 STRUCTURED DATA'));
|
|
270
|
+
if (schemaTypes.length === 0) {
|
|
271
|
+
console.log(fail('No JSON-LD schema found'));
|
|
272
|
+
console.log(info('Schema enables rich results (FAQ dropdowns, star ratings, prices). No schema = plain blue link only.'));
|
|
273
|
+
} else {
|
|
274
|
+
for (const type of schemaTypes) console.log(ok(`Schema: ${type}`));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const wantedSchemas = {
|
|
278
|
+
'FAQPage': 'FAQ rich results expand snippet to 3-4x size — massive CTR boost.',
|
|
279
|
+
'SoftwareApplication': 'Tells Google this is an app (ratings, price, OS). Enables app-specific rich results.',
|
|
280
|
+
'Organization': 'Confirms brand identity, logo, social profiles to Google Knowledge Graph.',
|
|
281
|
+
'WebSite': 'Enables sitelinks search box for brand queries.',
|
|
282
|
+
'BreadcrumbList': 'Shows page path in search results (Home > Category > Page).',
|
|
283
|
+
};
|
|
284
|
+
const missingSch = Object.entries(wantedSchemas).filter(([t]) => !schemaTypes.includes(t));
|
|
285
|
+
if (missingSch.length) {
|
|
286
|
+
console.log(chalk.gray('\n Suggested schemas to add:'));
|
|
287
|
+
for (const [type, reason] of missingSch.slice(0, 4)) {
|
|
288
|
+
console.log(warn(`Missing: ${type}`));
|
|
289
|
+
console.log(info(reason));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// FAQ
|
|
294
|
+
console.log(head('❓ FAQ'));
|
|
295
|
+
if (schemas.some(s => s['@type'] === 'FAQPage')) {
|
|
296
|
+
console.log(ok('FAQPage schema present'));
|
|
297
|
+
console.log(info('FAQ schema shows Q&As inline in Google results. Can 2-3x your click-through rate.'));
|
|
298
|
+
} else if (hasFaq) {
|
|
299
|
+
console.log(warn('FAQ content detected — no FAQPage schema'));
|
|
300
|
+
console.log(info('You have FAQ content but no markup. Add FAQPage JSON-LD to unlock rich results for free.'));
|
|
301
|
+
} else {
|
|
302
|
+
console.log(fail('No FAQ content or schema found'));
|
|
303
|
+
console.log(info('FAQ sections are highest-ROI SEO additions. Answer common questions to capture featured snippets.'));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// SOCIAL
|
|
307
|
+
console.log(head('📣 SOCIAL TAGS'));
|
|
308
|
+
for (const key of ['og:title','og:description','og:image','og:type']) {
|
|
309
|
+
console.log(og[key] ? ok(`${key}`) : warn(`${key} — Missing`));
|
|
310
|
+
}
|
|
311
|
+
if (!og['og:image']) console.log(info('OG image shows when shared on LinkedIn, Slack, iMessage. Missing = no thumbnail preview.'));
|
|
312
|
+
for (const key of ['twitter:card','twitter:title','twitter:image']) {
|
|
313
|
+
console.log(twitter[key] ? ok(`${key}`) : warn(`${key} — Missing`));
|
|
314
|
+
}
|
|
315
|
+
if (!twitter['twitter:card']) console.log(info('"summary_large_image" card shows a big preview on X/Twitter. High CTR from shares.'));
|
|
316
|
+
|
|
317
|
+
// IMAGES
|
|
318
|
+
console.log(head('🖼️ IMAGES'));
|
|
319
|
+
console.log(ok(`Total images — ${images.length}`));
|
|
320
|
+
console.log(missingAlt === 0
|
|
321
|
+
? ok(`Alt text — All ${images.length} images have alt text`)
|
|
322
|
+
: warn(`Alt text — ${missingAlt}/${images.length} images missing alt text`));
|
|
323
|
+
if (missingAlt > 0) console.log(info('Alt text is read by Google Images + screen readers. Missing = invisible to image search.'));
|
|
324
|
+
|
|
325
|
+
// LINKS
|
|
326
|
+
console.log(head('🔗 LINKS'));
|
|
327
|
+
console.log(ok(`Internal links — ${internalLinks.length}`));
|
|
328
|
+
if (internalLinks.length < 3) console.log(info('Low internal links = poor link equity flow. Link to key pages from the homepage.'));
|
|
329
|
+
console.log(ok(`External links — ${externalLinks.length}`));
|
|
330
|
+
if (externalLinks.length > 30) console.log(info('Many outbound links can dilute PageRank. Use rel="nofollow" where appropriate.'));
|
|
331
|
+
|
|
332
|
+
// SUMMARY
|
|
333
|
+
console.log(chalk.bold(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`));
|
|
334
|
+
const fixes = [];
|
|
335
|
+
if (titleInfo.status !== 'ok') fixes.push('Fix meta title length');
|
|
336
|
+
if (descInfo.status !== 'ok') fixes.push('Write meta description');
|
|
337
|
+
if (!headingCheck.ok) fixes.push('Fix heading order (H1→H2→H3)');
|
|
338
|
+
if (sitemapStatus !== 'ok') fixes.push('Add sitemap.xml');
|
|
339
|
+
if (robotsStatus !== 'ok') fixes.push('Add robots.txt');
|
|
340
|
+
if (!schemas.some(s => s['@type'] === 'FAQPage')) fixes.push('Add FAQPage schema');
|
|
341
|
+
if (!schemaTypes.includes('Organization') && !schemaTypes.includes('SoftwareApplication')) fixes.push('Add Organization or SoftwareApplication schema');
|
|
342
|
+
if (missingAlt > 0) fixes.push(`Add alt text to ${missingAlt} images`);
|
|
343
|
+
if (!publishedDate) fixes.push('Add published date metadata');
|
|
344
|
+
if (!canonical) fixes.push('Add canonical tag');
|
|
345
|
+
|
|
346
|
+
if (fixes.length === 0) {
|
|
347
|
+
console.log(chalk.bold.green(' 🎉 No major issues found!'));
|
|
348
|
+
} else {
|
|
349
|
+
console.log(chalk.bold.white(` 🛠️ Top fixes (highest impact first):`));
|
|
350
|
+
for (const [i, fix] of fixes.slice(0, 6).entries()) {
|
|
351
|
+
console.log(chalk.white(` ${i + 1}. ${fix}`));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log(chalk.bold(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
// ── REPORT MODE ────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
async function generateReport(rawUrl) {
|
|
361
|
+
const { writeFileSync } = await import('fs');
|
|
362
|
+
const parsedUrl = new URL(rawUrl);
|
|
363
|
+
const origin = parsedUrl.origin;
|
|
364
|
+
const hostname = parsedUrl.hostname.replace(/\./g, '-');
|
|
365
|
+
const date = new Date().toISOString().split('T')[0];
|
|
366
|
+
const outFile = `seo-report-${hostname}-${date}.md`;
|
|
367
|
+
|
|
368
|
+
process.stdout.write(chalk.gray(`Auditing ${rawUrl} for report...\n`));
|
|
369
|
+
|
|
370
|
+
const [raw, rendered, sitemapStatus, robotsStatus] = await Promise.all([
|
|
371
|
+
fetchRaw(rawUrl),
|
|
372
|
+
crawlPage(rawUrl),
|
|
373
|
+
checkUrl(`${origin}/sitemap.xml`),
|
|
374
|
+
checkUrl(`${origin}/robots.txt`),
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
const { schemas, og, twitter, canonical, robotsMeta, images, hasFaq } = parseRawHtml(raw.html);
|
|
378
|
+
const { status, loadMs, title, metaDesc, headings, wordCount, internalLinks, externalLinks, publishedDate, modifiedDate } = rendered;
|
|
379
|
+
|
|
380
|
+
const schemaTypes = schemas.map(s => s['@type']).filter(Boolean);
|
|
381
|
+
const missingAlt = images.filter(i => i.alt === null || i.alt === '').length;
|
|
382
|
+
const headingCheck = checkHeadingOrder(headings);
|
|
383
|
+
const titleInfo = titleLen(title);
|
|
384
|
+
const descInfo = descLen(metaDesc);
|
|
385
|
+
const isIndexable = !robotsMeta?.toLowerCase().includes('noindex');
|
|
386
|
+
const loadStatus = loadMs < 1500 ? 'ok' : loadMs < 3000 ? 'warn' : 'fail';
|
|
387
|
+
|
|
388
|
+
// ── Build issues list with severity + fixes ──────────────────────────────
|
|
389
|
+
|
|
390
|
+
const issues = [];
|
|
391
|
+
|
|
392
|
+
// Title
|
|
393
|
+
if (!title) {
|
|
394
|
+
issues.push({ sev: '🔴', area: 'Content', problem: 'Meta title missing', fix: `Add a descriptive title, 50–60 chars. Example:\n \`${parsedUrl.hostname} — [primary keyword]\`` });
|
|
395
|
+
} else if (title.length < 30) {
|
|
396
|
+
issues.push({ sev: '🟡', area: 'Content', problem: `Meta title too short (${title.length}/60): "${title}"`, fix: `Expand to 50–60 chars. Include primary keyword. Current title misses ranking opportunity.` });
|
|
397
|
+
} else if (title.length > 60) {
|
|
398
|
+
issues.push({ sev: '🟡', area: 'Content', problem: `Meta title too long (${title.length}/60): "${title}"`, fix: `Trim to under 60 chars. Google truncates longer titles in search results — your CTA or brand may get cut off.` });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Description
|
|
402
|
+
if (!metaDesc) {
|
|
403
|
+
issues.push({ sev: '🔴', area: 'Content', problem: 'Meta description missing', fix: `Write a 120–160 char description that includes your primary keyword and a clear value prop. Without it, Google picks random page text.` });
|
|
404
|
+
} else if (metaDesc.length < 50) {
|
|
405
|
+
issues.push({ sev: '🔴', area: 'Content', problem: `Meta description too short (${metaDesc.length} chars): "${metaDesc}"`, fix: `Expand to 120–160 chars. Descriptions this short signal low effort to Google and get rewritten automatically.` });
|
|
406
|
+
} else if (metaDesc.length > 160) {
|
|
407
|
+
issues.push({ sev: '🟡', area: 'Content', problem: `Meta description too long (${metaDesc.length} chars)`, fix: `Trim to under 160 chars. Anything beyond gets truncated in search results — your CTA may be invisible.` });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Word count
|
|
411
|
+
if (wordCount < 300) {
|
|
412
|
+
issues.push({ sev: '🔴', area: 'Content', problem: `Very thin content — ${wordCount} words rendered`, fix: `Under 300 words risks being classified as thin content. Add: feature descriptions, a quick-start code block, an FAQ section, or a supported integrations list.` });
|
|
413
|
+
} else if (wordCount < 600) {
|
|
414
|
+
issues.push({ sev: '🟡', area: 'Content', problem: `Thin content — ${wordCount} words`, fix: `300–600 words is borderline. Competitors with deeper content tend to outrank for informational queries. Aim for 600+ words.` });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Dates
|
|
418
|
+
if (!publishedDate) {
|
|
419
|
+
issues.push({ sev: '🟢', area: 'Content', problem: 'Published date metadata missing', fix: `Add \`<meta property="article:published_time" content="YYYY-MM-DD" />\` or datePublished in JSON-LD. Signals freshness to Google.` });
|
|
420
|
+
}
|
|
421
|
+
if (!modifiedDate) {
|
|
422
|
+
issues.push({ sev: '🟢', area: 'Content', problem: 'Modified date metadata missing', fix: `Add \`<meta property="article:modified_time" content="YYYY-MM-DD" />\` or dateModified in JSON-LD. Tells Google the content is maintained.` });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Headings
|
|
426
|
+
if (!headingCheck.ok) {
|
|
427
|
+
for (const issue of headingCheck.issues) {
|
|
428
|
+
issues.push({ sev: '🟡', area: 'Headings', problem: `Heading structure: ${issue}`, fix: `Fix heading hierarchy to H1 → H2 → H3. Skipping levels confuses Google's content parser and weakens topical relevance signals.` });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Indexability
|
|
433
|
+
if (!isIndexable) {
|
|
434
|
+
issues.push({ sev: '🔴', area: 'Indexability', problem: 'Page blocked by noindex meta robots tag', fix: `Remove \`noindex\` from the robots meta tag. Currently Google will not index this page at all.` });
|
|
435
|
+
}
|
|
436
|
+
if (!canonical) {
|
|
437
|
+
issues.push({ sev: '🟡', area: 'Indexability', problem: 'Canonical tag missing', fix: `Add \`<link rel="canonical" href="${rawUrl}" />\`. Without it, Google may index duplicate URL variants (http/https, trailing slash, www/non-www).` });
|
|
438
|
+
} else if (canonical.includes('www.') && !rawUrl.includes('www.')) {
|
|
439
|
+
issues.push({ sev: '🟡', area: 'Indexability', problem: `Canonical mismatch — points to ${canonical} but page is served at ${rawUrl}`, fix: `Update canonical to match the served URL: \`<link rel="canonical" href="${rawUrl}" />\`` });
|
|
440
|
+
}
|
|
441
|
+
if (robotsStatus !== 'ok') {
|
|
442
|
+
issues.push({ sev: '🔴', area: 'Indexability', problem: `robots.txt missing at ${origin}/robots.txt`, fix: `Create a robots.txt at the domain root:\n \`User-agent: *\n Allow: /\n Sitemap: ${origin}/sitemap.xml\`` });
|
|
443
|
+
}
|
|
444
|
+
if (sitemapStatus !== 'ok') {
|
|
445
|
+
issues.push({ sev: '🔴', area: 'Indexability', problem: `sitemap.xml missing at ${origin}/sitemap.xml`, fix: `Generate and submit a sitemap. For Next.js: use next-sitemap. For Astro: npx astro add sitemap. Then submit at Google Search Console.` });
|
|
446
|
+
}
|
|
447
|
+
if (loadStatus === 'warn') {
|
|
448
|
+
issues.push({ sev: '🟡', area: 'Indexability', problem: `Load time ${loadMs}ms — above 1.5s target`, fix: `Investigate render-blocking resources, image sizes, and TTFB. Core Web Vitals affect rankings. Target under 1.5s.` });
|
|
449
|
+
}
|
|
450
|
+
if (loadStatus === 'fail') {
|
|
451
|
+
issues.push({ sev: '🔴', area: 'Indexability', problem: `Load time ${loadMs}ms — above 3s`, fix: `Critical performance issue. Google deprioritises slow pages. Audit with Lighthouse, compress images, defer JS, use a CDN.` });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Schema
|
|
455
|
+
if (schemaTypes.length === 0) {
|
|
456
|
+
issues.push({ sev: '🔴', area: 'Structured Data', problem: 'No JSON-LD schema found', fix: `Add at minimum Organization schema. Without any schema, Google only shows a plain blue link — no rich results possible.` });
|
|
457
|
+
}
|
|
458
|
+
if (!schemas.some(s => s['@type'] === 'FAQPage')) {
|
|
459
|
+
if (hasFaq) {
|
|
460
|
+
issues.push({ sev: '🔴', area: 'Structured Data', problem: 'FAQ content detected but no FAQPage schema', fix: `Wrap your FAQ section in FAQPage JSON-LD. This is a free upgrade — Google can show your Q&As inline in search results, expanding your listing 3-4x.` });
|
|
461
|
+
} else {
|
|
462
|
+
issues.push({ sev: '🟡', area: 'Structured Data', problem: 'No FAQ section or FAQPage schema', fix: `Add a FAQ section answering 3–5 common questions, then mark it up with FAQPage schema. One of the highest-ROI SEO additions for developer tools.` });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!schemaTypes.includes('Organization')) {
|
|
466
|
+
issues.push({ sev: '🟡', area: 'Structured Data', problem: 'Organization schema missing', fix: `Add Organization JSON-LD with name, url, logo, and sameAs (Twitter, GitHub). Builds brand entity in Google Knowledge Graph.` });
|
|
467
|
+
}
|
|
468
|
+
if (!schemaTypes.includes('SoftwareApplication') && !schemaTypes.includes('Product')) {
|
|
469
|
+
issues.push({ sev: '🟡', area: 'Structured Data', problem: 'No SoftwareApplication or Product schema', fix: `Add SoftwareApplication schema with applicationCategory, offers, and description. Enables app-specific rich results in Google.` });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// OG / Social
|
|
473
|
+
const ogKeys = ['og:title','og:description','og:image','og:type'];
|
|
474
|
+
for (const key of ogKeys) {
|
|
475
|
+
if (!og[key]) {
|
|
476
|
+
issues.push({ sev: '🟡', area: 'Social', problem: `Missing ${key}`, fix: `Add \`<meta property="${key}" content="..." />\`. ${key === 'og:image' ? 'OG image controls the thumbnail when shared on LinkedIn, Slack, iMessage, X. Use 1200×630px PNG.' : 'Controls how your link appears when shared on social platforms.'}` });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (og['og:image']?.includes('cdn.discordapp.com') || og['og:image']?.includes('pbs.twimg.com')) {
|
|
480
|
+
issues.push({ sev: '🔴', area: 'Social', problem: `OG image hosted on external CDN (${og['og:image']?.split('/')[2]}) — URL will expire`, fix: `Host the OG image on your own CDN or S3 bucket. Discord CDN URLs expire and will break social previews permanently.` });
|
|
481
|
+
}
|
|
482
|
+
if (!twitter['twitter:card']) {
|
|
483
|
+
issues.push({ sev: '🟡', area: 'Social', problem: 'Twitter card missing', fix: `Add \`<meta name="twitter:card" content="summary_large_image" />\` plus twitter:title, twitter:description, twitter:image. Controls appearance on X/Twitter.` });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Images
|
|
487
|
+
if (missingAlt > 0) {
|
|
488
|
+
issues.push({ sev: '🟡', area: 'Images', problem: `${missingAlt} image(s) missing alt text`, fix: `Add descriptive alt attributes to all images. Alt text feeds Google Images, screen readers, and is a minor ranking signal.` });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Internal links
|
|
492
|
+
if (internalLinks < 3) {
|
|
493
|
+
issues.push({ sev: '🟢', area: 'Links', problem: `Only ${internalLinks} internal link(s)`, fix: `Add links to key pages (docs, pricing, signup). Internal links distribute PageRank across your site and help Google discover deeper pages.` });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Sort by severity
|
|
497
|
+
const sevOrder = { '🔴': 0, '🟡': 1, '🟢': 2 };
|
|
498
|
+
issues.sort((a, b) => sevOrder[a.sev] - sevOrder[b.sev]);
|
|
499
|
+
|
|
500
|
+
const critical = issues.filter(i => i.sev === '🔴');
|
|
501
|
+
const warnings = issues.filter(i => i.sev === '🟡');
|
|
502
|
+
const info = issues.filter(i => i.sev === '🟢');
|
|
503
|
+
|
|
504
|
+
// ── Write markdown ────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
const lines = [];
|
|
507
|
+
|
|
508
|
+
lines.push(`# SEO Audit Report — ${rawUrl}`);
|
|
509
|
+
lines.push(`**Date:** ${date} `);
|
|
510
|
+
lines.push(`**Tool:** SEO Intel (froggo.pro)\n`);
|
|
511
|
+
lines.push(`---\n`);
|
|
512
|
+
|
|
513
|
+
// Score card
|
|
514
|
+
lines.push(`## Overview\n`);
|
|
515
|
+
lines.push(`| Metric | Value | Status |`);
|
|
516
|
+
lines.push(`|--------|-------|--------|`);
|
|
517
|
+
lines.push(`| HTTP Status | ${status} | ${status >= 200 && status < 300 ? '✅' : '❌'} |`);
|
|
518
|
+
lines.push(`| Load Time | ${loadMs}ms | ${loadStatus === 'ok' ? '✅' : loadStatus === 'warn' ? '⚠️' : '❌'} |`);
|
|
519
|
+
lines.push(`| Word Count | ${wordCount} | ${wordCount >= 600 ? '✅' : wordCount >= 300 ? '⚠️' : '❌'} |`);
|
|
520
|
+
lines.push(`| Meta Title | ${title ? `"${title.slice(0,50)}${title.length>50?'...':''}" (${title.length} chars)` : 'Missing'} | ${titleInfo.status === 'ok' ? '✅' : titleInfo.status === 'warn' ? '⚠️' : '❌'} |`);
|
|
521
|
+
lines.push(`| Meta Description | ${metaDesc ? `${metaDesc.length} chars` : 'Missing'} | ${descInfo.status === 'ok' ? '✅' : descInfo.status === 'warn' ? '⚠️' : '❌'} |`);
|
|
522
|
+
lines.push(`| Canonical | ${canonical || 'Missing'} | ${canonical ? '✅' : '❌'} |`);
|
|
523
|
+
lines.push(`| Indexable | ${isIndexable ? 'Yes' : 'No (noindex set!)'} | ${isIndexable ? '✅' : '❌'} |`);
|
|
524
|
+
lines.push(`| robots.txt | ${robotsStatus === 'ok' ? 'Found' : 'Missing'} | ${robotsStatus === 'ok' ? '✅' : '❌'} |`);
|
|
525
|
+
lines.push(`| sitemap.xml | ${sitemapStatus === 'ok' ? 'Found' : 'Missing'} | ${sitemapStatus === 'ok' ? '✅' : '❌'} |`);
|
|
526
|
+
lines.push(`| Schema types | ${schemaTypes.length ? schemaTypes.join(', ') : 'None'} | ${schemaTypes.length ? '⚠️' : '❌'} |`);
|
|
527
|
+
lines.push(`| FAQPage schema | ${schemas.some(s=>s['@type']==='FAQPage') ? 'Present' : 'Missing'} | ${schemas.some(s=>s['@type']==='FAQPage') ? '✅' : '❌'} |`);
|
|
528
|
+
lines.push(`| OG Tags | ${Object.keys(og).length} found | ${Object.keys(og).length >= 4 ? '✅' : '⚠️'} |`);
|
|
529
|
+
lines.push(`| Twitter Card | ${twitter['twitter:card'] || 'Missing'} | ${twitter['twitter:card'] ? '✅' : '❌'} |`);
|
|
530
|
+
lines.push(`| Images | ${images.length} total, ${missingAlt} missing alt | ${missingAlt === 0 ? '✅' : '⚠️'} |`);
|
|
531
|
+
lines.push(`| Internal Links | ${internalLinks} | ${internalLinks >= 3 ? '✅' : '⚠️'} |`);
|
|
532
|
+
lines.push(`| Published Date | ${publishedDate || 'Missing'} | ${publishedDate ? '✅' : '⚠️'} |`);
|
|
533
|
+
lines.push(`| Modified Date | ${modifiedDate || 'Missing'} | ${modifiedDate ? '✅' : '⚠️'} |\n`);
|
|
534
|
+
|
|
535
|
+
// Issue counts
|
|
536
|
+
lines.push(`**${critical.length} critical · ${warnings.length} warnings · ${info.length} info**\n`);
|
|
537
|
+
lines.push(`---\n`);
|
|
538
|
+
|
|
539
|
+
// Issues by severity
|
|
540
|
+
if (critical.length) {
|
|
541
|
+
lines.push(`## 🔴 Critical Issues\n`);
|
|
542
|
+
for (const issue of critical) {
|
|
543
|
+
lines.push(`### ${issue.area} — ${issue.problem}\n`);
|
|
544
|
+
lines.push(`**Fix:** ${issue.fix}\n`);
|
|
545
|
+
}
|
|
546
|
+
lines.push(`---\n`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (warnings.length) {
|
|
550
|
+
lines.push(`## 🟡 Warnings\n`);
|
|
551
|
+
for (const issue of warnings) {
|
|
552
|
+
lines.push(`### ${issue.area} — ${issue.problem}\n`);
|
|
553
|
+
lines.push(`**Fix:** ${issue.fix}\n`);
|
|
554
|
+
}
|
|
555
|
+
lines.push(`---\n`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (info.length) {
|
|
559
|
+
lines.push(`## 🟢 Info / Nice to Have\n`);
|
|
560
|
+
for (const issue of info) {
|
|
561
|
+
lines.push(`### ${issue.area} — ${issue.problem}\n`);
|
|
562
|
+
lines.push(`**Fix:** ${issue.fix}\n`);
|
|
563
|
+
}
|
|
564
|
+
lines.push(`---\n`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Headings snapshot
|
|
568
|
+
if (headings.length) {
|
|
569
|
+
lines.push(`## Heading Structure\n`);
|
|
570
|
+
lines.push('```');
|
|
571
|
+
for (const h of headings.slice(0, 10)) {
|
|
572
|
+
lines.push(`${' '.repeat(h.level - 1)}H${h.level}: ${h.text.slice(0, 80)}`);
|
|
573
|
+
}
|
|
574
|
+
if (headings.length > 10) lines.push(`... and ${headings.length - 10} more`);
|
|
575
|
+
lines.push('```\n');
|
|
576
|
+
lines.push(`---\n`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Schema snapshot
|
|
580
|
+
if (schemas.length) {
|
|
581
|
+
lines.push(`## Schema Found\n`);
|
|
582
|
+
for (const s of schemas) {
|
|
583
|
+
lines.push(`- **${s['@type']}** ${s.name ? `— ${s.name}` : ''}`);
|
|
584
|
+
}
|
|
585
|
+
lines.push('');
|
|
586
|
+
lines.push(`---\n`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Priority fix list
|
|
590
|
+
lines.push(`## Priority Fix List\n`);
|
|
591
|
+
lines.push(`| Priority | Area | Fix |`);
|
|
592
|
+
lines.push(`|----------|------|-----|`);
|
|
593
|
+
for (const [i, issue] of issues.slice(0, 10).entries()) {
|
|
594
|
+
const shortFix = issue.fix.split('\n')[0].slice(0, 80);
|
|
595
|
+
lines.push(`| ${issue.sev} ${i + 1} | ${issue.area} | ${shortFix} |`);
|
|
596
|
+
}
|
|
597
|
+
lines.push('');
|
|
598
|
+
lines.push(`---\n`);
|
|
599
|
+
lines.push(`*Generated by SEO Intel — froggo.pro*`);
|
|
600
|
+
|
|
601
|
+
const md = lines.join('\n');
|
|
602
|
+
writeFileSync(outFile, md);
|
|
603
|
+
|
|
604
|
+
console.log(chalk.bold.green(`\n✅ Report saved: ${outFile}`));
|
|
605
|
+
console.log(chalk.gray(` ${critical.length} critical · ${warnings.length} warnings · ${info.length} info\n`));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Route to correct mode ───────────────────────────────────────────────────
|
|
609
|
+
if (reportMode) {
|
|
610
|
+
generateReport(url).catch(err => {
|
|
611
|
+
console.error(chalk.red('Report failed:'), err.message);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
});
|
|
614
|
+
} else {
|
|
615
|
+
audit(url).catch(err => {
|
|
616
|
+
console.error(chalk.red('Audit failed:'), err.message);
|
|
617
|
+
process.exit(1);
|
|
618
|
+
});
|
|
619
|
+
}
|
package/seo-intel.png
ADDED
|
Binary file
|