@djangocfg/seo 2.1.50
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 +192 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +3780 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/crawler/index.d.ts +88 -0
- package/dist/crawler/index.mjs +610 -0
- package/dist/crawler/index.mjs.map +1 -0
- package/dist/google-console/index.d.ts +95 -0
- package/dist/google-console/index.mjs +539 -0
- package/dist/google-console/index.mjs.map +1 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.mjs +3236 -0
- package/dist/index.mjs.map +1 -0
- package/dist/link-checker/index.d.ts +76 -0
- package/dist/link-checker/index.mjs +326 -0
- package/dist/link-checker/index.mjs.map +1 -0
- package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
- package/dist/reports/index.d.ts +24 -0
- package/dist/reports/index.mjs +836 -0
- package/dist/reports/index.mjs.map +1 -0
- package/dist/routes/index.d.ts +69 -0
- package/dist/routes/index.mjs +372 -0
- package/dist/routes/index.mjs.map +1 -0
- package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +114 -0
- package/src/analyzer.ts +256 -0
- package/src/cli/commands/audit.ts +260 -0
- package/src/cli/commands/content.ts +180 -0
- package/src/cli/commands/crawl.ts +32 -0
- package/src/cli/commands/index.ts +12 -0
- package/src/cli/commands/inspect.ts +60 -0
- package/src/cli/commands/links.ts +41 -0
- package/src/cli/commands/robots.ts +36 -0
- package/src/cli/commands/routes.ts +126 -0
- package/src/cli/commands/sitemap.ts +48 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/types.ts +40 -0
- package/src/config.ts +207 -0
- package/src/content/index.ts +51 -0
- package/src/content/link-checker.ts +182 -0
- package/src/content/link-fixer.ts +188 -0
- package/src/content/scanner.ts +200 -0
- package/src/content/sitemap-generator.ts +321 -0
- package/src/content/types.ts +140 -0
- package/src/crawler/crawler.ts +425 -0
- package/src/crawler/index.ts +10 -0
- package/src/crawler/robots-parser.ts +171 -0
- package/src/crawler/sitemap-validator.ts +204 -0
- package/src/google-console/analyzer.ts +317 -0
- package/src/google-console/auth.ts +100 -0
- package/src/google-console/client.ts +281 -0
- package/src/google-console/index.ts +9 -0
- package/src/index.ts +144 -0
- package/src/link-checker/index.ts +461 -0
- package/src/reports/claude-context.ts +149 -0
- package/src/reports/generator.ts +244 -0
- package/src/reports/index.ts +27 -0
- package/src/reports/json-report.ts +320 -0
- package/src/reports/markdown-report.ts +246 -0
- package/src/reports/split-report.ts +252 -0
- package/src/routes/analyzer.ts +324 -0
- package/src/routes/index.ts +25 -0
- package/src/routes/scanner.ts +298 -0
- package/src/types/index.ts +222 -0
- package/src/utils/index.ts +154 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,3780 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'util';
|
|
3
|
+
import consola3 from 'consola';
|
|
4
|
+
import chalk2 from 'chalk';
|
|
5
|
+
import fs4, { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync, statSync } from 'fs';
|
|
6
|
+
import path, { resolve, join, dirname } from 'path';
|
|
7
|
+
import { searchconsole } from '@googleapis/searchconsole';
|
|
8
|
+
import pLimit from 'p-limit';
|
|
9
|
+
import pRetry from 'p-retry';
|
|
10
|
+
import { JWT } from 'google-auth-library';
|
|
11
|
+
import { load } from 'cheerio';
|
|
12
|
+
import robotsParser from 'robots-parser';
|
|
13
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
14
|
+
import * as linkinator from 'linkinator';
|
|
15
|
+
|
|
16
|
+
var config = {
|
|
17
|
+
env: {
|
|
18
|
+
prod: void 0,
|
|
19
|
+
dev: void 0
|
|
20
|
+
},
|
|
21
|
+
cwd: process.cwd()
|
|
22
|
+
};
|
|
23
|
+
function parseEnvFile(filePath) {
|
|
24
|
+
const content = readFileSync(filePath, "utf-8");
|
|
25
|
+
const vars = {};
|
|
26
|
+
for (const line of content.split("\n")) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
29
|
+
const eqIndex = trimmed.indexOf("=");
|
|
30
|
+
if (eqIndex === -1) continue;
|
|
31
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
32
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
33
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
34
|
+
value = value.slice(1, -1);
|
|
35
|
+
}
|
|
36
|
+
vars[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return vars;
|
|
39
|
+
}
|
|
40
|
+
function loadEnvFiles(cwd) {
|
|
41
|
+
const workDir = process.cwd();
|
|
42
|
+
config.cwd = workDir;
|
|
43
|
+
const prodEnvPath = resolve(workDir, ".env.production");
|
|
44
|
+
if (existsSync(prodEnvPath)) {
|
|
45
|
+
const vars = parseEnvFile(prodEnvPath);
|
|
46
|
+
config.env.prod = vars.NEXT_PUBLIC_SITE_URL || vars.SITE_URL;
|
|
47
|
+
}
|
|
48
|
+
const devEnvPath = resolve(workDir, ".env.development");
|
|
49
|
+
if (existsSync(devEnvPath)) {
|
|
50
|
+
const vars = parseEnvFile(devEnvPath);
|
|
51
|
+
config.env.dev = vars.NEXT_PUBLIC_SITE_URL || vars.SITE_URL;
|
|
52
|
+
}
|
|
53
|
+
if (!config.env.prod && !config.env.dev) {
|
|
54
|
+
for (const envFile of [".env.local", ".env"]) {
|
|
55
|
+
const envPath = resolve(workDir, envFile);
|
|
56
|
+
if (existsSync(envPath)) {
|
|
57
|
+
const vars = parseEnvFile(envPath);
|
|
58
|
+
const url = vars.NEXT_PUBLIC_SITE_URL || vars.SITE_URL;
|
|
59
|
+
if (url) {
|
|
60
|
+
config.env.prod = url;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return config.env;
|
|
67
|
+
}
|
|
68
|
+
function getSiteUrl(options) {
|
|
69
|
+
if (options.site) {
|
|
70
|
+
return options.site;
|
|
71
|
+
}
|
|
72
|
+
const env = options.env || "prod";
|
|
73
|
+
const isProd = env === "prod" || env === "production";
|
|
74
|
+
const url = isProd ? config.env.prod : config.env.dev;
|
|
75
|
+
if (url) {
|
|
76
|
+
const envLabel = isProd ? "production" : "development";
|
|
77
|
+
consola3.info(`Using ${chalk2.cyan(envLabel)} URL: ${chalk2.bold(url)}`);
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
const fallbackUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || process.env.BASE_URL;
|
|
81
|
+
if (fallbackUrl) {
|
|
82
|
+
consola3.info(`Using URL from environment: ${fallbackUrl}`);
|
|
83
|
+
return fallbackUrl;
|
|
84
|
+
}
|
|
85
|
+
console.log("");
|
|
86
|
+
consola3.error("No site URL found!");
|
|
87
|
+
console.log("");
|
|
88
|
+
if (config.env.prod || config.env.dev) {
|
|
89
|
+
consola3.info("Available environments:");
|
|
90
|
+
if (config.env.prod) consola3.log(` ${chalk2.green("prod")}: ${config.env.prod}`);
|
|
91
|
+
if (config.env.dev) consola3.log(` ${chalk2.yellow("dev")}: ${config.env.dev}`);
|
|
92
|
+
console.log("");
|
|
93
|
+
consola3.info(`Use ${chalk2.cyan("--env prod")} or ${chalk2.cyan("--env dev")} to select`);
|
|
94
|
+
} else {
|
|
95
|
+
consola3.info("Create .env.production or .env.development with NEXT_PUBLIC_SITE_URL");
|
|
96
|
+
consola3.info("Or use --site https://example.com");
|
|
97
|
+
}
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
var GSC_KEY_FILENAME = "gsc-key.json";
|
|
101
|
+
function findGoogleServiceAccount(explicitPath) {
|
|
102
|
+
if (explicitPath) {
|
|
103
|
+
const resolved = resolve(config.cwd, explicitPath);
|
|
104
|
+
if (existsSync(resolved)) {
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
consola3.warn(`Service account file not found: ${explicitPath}`);
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
const defaultPath = resolve(config.cwd, GSC_KEY_FILENAME);
|
|
111
|
+
if (existsSync(defaultPath)) {
|
|
112
|
+
consola3.info(`Found Google service account: ${chalk2.cyan(GSC_KEY_FILENAME)}`);
|
|
113
|
+
return defaultPath;
|
|
114
|
+
}
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
function getGscKeyFilename() {
|
|
118
|
+
return GSC_KEY_FILENAME;
|
|
119
|
+
}
|
|
120
|
+
var SCOPES = [
|
|
121
|
+
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
122
|
+
"https://www.googleapis.com/auth/webmasters"
|
|
123
|
+
];
|
|
124
|
+
function loadCredentials(config2) {
|
|
125
|
+
if (config2.serviceAccountJson) {
|
|
126
|
+
return config2.serviceAccountJson;
|
|
127
|
+
}
|
|
128
|
+
if (config2.serviceAccountPath) {
|
|
129
|
+
if (!existsSync(config2.serviceAccountPath)) {
|
|
130
|
+
throw new Error(`Service account file not found: ${config2.serviceAccountPath}`);
|
|
131
|
+
}
|
|
132
|
+
const content = readFileSync(config2.serviceAccountPath, "utf-8");
|
|
133
|
+
return JSON.parse(content);
|
|
134
|
+
}
|
|
135
|
+
const envJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
|
136
|
+
if (envJson) {
|
|
137
|
+
return JSON.parse(envJson);
|
|
138
|
+
}
|
|
139
|
+
const defaultPath = "./service_account.json";
|
|
140
|
+
if (existsSync(defaultPath)) {
|
|
141
|
+
const content = readFileSync(defaultPath, "utf-8");
|
|
142
|
+
return JSON.parse(content);
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
"No service account credentials found. Provide serviceAccountPath, serviceAccountJson, or set GOOGLE_SERVICE_ACCOUNT_JSON env variable."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function createAuthClient(config2) {
|
|
149
|
+
const credentials = loadCredentials(config2);
|
|
150
|
+
const auth = new JWT({
|
|
151
|
+
email: credentials.client_email,
|
|
152
|
+
key: credentials.private_key,
|
|
153
|
+
scopes: SCOPES
|
|
154
|
+
});
|
|
155
|
+
auth._serviceAccountEmail = credentials.client_email;
|
|
156
|
+
return auth;
|
|
157
|
+
}
|
|
158
|
+
async function verifyAuth(auth, siteUrl) {
|
|
159
|
+
const email = auth._serviceAccountEmail || auth.email;
|
|
160
|
+
try {
|
|
161
|
+
await auth.authorize();
|
|
162
|
+
consola3.success("Google Search Console authentication verified");
|
|
163
|
+
consola3.info(`Service account: ${email}`);
|
|
164
|
+
if (siteUrl) {
|
|
165
|
+
const domain = new URL(siteUrl).hostname;
|
|
166
|
+
const gscUrl = `https://search.google.com/search-console/users?resource_id=sc-domain%3A${domain}`;
|
|
167
|
+
consola3.info(`Ensure this email has Full access in GSC: ${gscUrl}`);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
consola3.error("Authentication failed");
|
|
172
|
+
consola3.info(`Service account email: ${email}`);
|
|
173
|
+
consola3.info("Make sure this email is added to GSC with Full access");
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/google-console/client.ts
|
|
179
|
+
var GoogleConsoleClient = class {
|
|
180
|
+
auth;
|
|
181
|
+
searchconsole;
|
|
182
|
+
siteUrl;
|
|
183
|
+
gscSiteUrl;
|
|
184
|
+
// Format for GSC API (may be sc-domain:xxx)
|
|
185
|
+
limit = pLimit(2);
|
|
186
|
+
// Max 2 concurrent requests (Cloudflare-friendly)
|
|
187
|
+
requestDelay = 500;
|
|
188
|
+
// Delay between requests in ms
|
|
189
|
+
constructor(config2) {
|
|
190
|
+
this.auth = createAuthClient(config2);
|
|
191
|
+
this.searchconsole = searchconsole({ version: "v1", auth: this.auth });
|
|
192
|
+
this.siteUrl = config2.siteUrl;
|
|
193
|
+
if (config2.gscSiteUrl) {
|
|
194
|
+
this.gscSiteUrl = config2.gscSiteUrl;
|
|
195
|
+
} else {
|
|
196
|
+
const domain = new URL(config2.siteUrl).hostname;
|
|
197
|
+
this.gscSiteUrl = `sc-domain:${domain}`;
|
|
198
|
+
}
|
|
199
|
+
consola3.debug(`GSC site URL: ${this.gscSiteUrl}`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Delay helper for rate limiting
|
|
203
|
+
*/
|
|
204
|
+
delay(ms) {
|
|
205
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Verify the client is authenticated
|
|
209
|
+
*/
|
|
210
|
+
async verify() {
|
|
211
|
+
return verifyAuth(this.auth, this.siteUrl);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* List all sites in Search Console
|
|
215
|
+
*/
|
|
216
|
+
async listSites() {
|
|
217
|
+
try {
|
|
218
|
+
const response = await this.searchconsole.sites.list();
|
|
219
|
+
return response.data.siteEntry?.map((site) => site.siteUrl || "") || [];
|
|
220
|
+
} catch (error) {
|
|
221
|
+
consola3.error("Failed to list sites:", error);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Inspect a single URL
|
|
227
|
+
*/
|
|
228
|
+
async inspectUrl(url) {
|
|
229
|
+
return this.limit(async () => {
|
|
230
|
+
return pRetry(
|
|
231
|
+
async () => {
|
|
232
|
+
const response = await this.searchconsole.urlInspection.index.inspect({
|
|
233
|
+
requestBody: {
|
|
234
|
+
inspectionUrl: url,
|
|
235
|
+
siteUrl: this.gscSiteUrl,
|
|
236
|
+
languageCode: "en-US"
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
const result = response.data.inspectionResult;
|
|
240
|
+
if (!result?.indexStatusResult) {
|
|
241
|
+
throw new Error(`No inspection result for URL: ${url}`);
|
|
242
|
+
}
|
|
243
|
+
return this.mapInspectionResult(url, result);
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
retries: 2,
|
|
247
|
+
minTimeout: 2e3,
|
|
248
|
+
maxTimeout: 1e4,
|
|
249
|
+
factor: 2,
|
|
250
|
+
// Exponential backoff
|
|
251
|
+
onFailedAttempt: (ctx) => {
|
|
252
|
+
if (ctx.retriesLeft === 0) {
|
|
253
|
+
consola3.warn(`Failed: ${url}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Inspect multiple URLs in batch
|
|
262
|
+
* Stops early if too many consecutive errors (likely rate limiting)
|
|
263
|
+
*/
|
|
264
|
+
async inspectUrls(urls) {
|
|
265
|
+
consola3.info(`Inspecting ${urls.length} URLs...`);
|
|
266
|
+
const results = [];
|
|
267
|
+
const errors = [];
|
|
268
|
+
let consecutiveErrors = 0;
|
|
269
|
+
const maxConsecutiveErrors = 3;
|
|
270
|
+
for (const url of urls) {
|
|
271
|
+
try {
|
|
272
|
+
const result = await this.inspectUrl(url);
|
|
273
|
+
results.push(result);
|
|
274
|
+
consecutiveErrors = 0;
|
|
275
|
+
await this.delay(this.requestDelay);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const err = error;
|
|
278
|
+
errors.push({ url, error: err });
|
|
279
|
+
consecutiveErrors++;
|
|
280
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
281
|
+
console.log("");
|
|
282
|
+
consola3.error(`Stopping after ${maxConsecutiveErrors} consecutive failures`);
|
|
283
|
+
this.showRateLimitHelp();
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (errors.length > 0 && consecutiveErrors < maxConsecutiveErrors) {
|
|
289
|
+
consola3.warn(`Failed to inspect ${errors.length} URLs`);
|
|
290
|
+
}
|
|
291
|
+
if (results.length > 0) {
|
|
292
|
+
consola3.success(`Successfully inspected ${results.length}/${urls.length} URLs`);
|
|
293
|
+
} else if (errors.length > 0) {
|
|
294
|
+
consola3.warn("No URLs were successfully inspected");
|
|
295
|
+
}
|
|
296
|
+
return results;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Show help message for rate limiting issues
|
|
300
|
+
*/
|
|
301
|
+
showRateLimitHelp() {
|
|
302
|
+
consola3.info("Possible causes:");
|
|
303
|
+
consola3.info(" 1. Google API quota exceeded (2000 requests/day)");
|
|
304
|
+
consola3.info(" 2. Cloudflare blocking Google's crawler");
|
|
305
|
+
consola3.info(" 3. Service account not added to GSC");
|
|
306
|
+
console.log("");
|
|
307
|
+
consola3.info("Solutions:");
|
|
308
|
+
consola3.info(" \u2022 Check GSC access: https://search.google.com/search-console/users");
|
|
309
|
+
console.log("");
|
|
310
|
+
consola3.info(" \u2022 Cloudflare WAF rule to allow Googlebot:");
|
|
311
|
+
consola3.info(" 1. Dashboard \u2192 Security \u2192 WAF \u2192 Custom rules \u2192 Create rule");
|
|
312
|
+
consola3.info(' 2. Name: "Allow Googlebot"');
|
|
313
|
+
consola3.info(' 3. Field: "Known Bots" | Operator: "equals" | Value: "true"');
|
|
314
|
+
consola3.info(' 4. Or click "Edit expression" and paste: (cf.client.bot)');
|
|
315
|
+
consola3.info(" 5. Action: Skip \u2192 check all rules");
|
|
316
|
+
consola3.info(" 6. Deploy");
|
|
317
|
+
consola3.info(" Docs: https://developers.cloudflare.com/waf/custom-rules/use-cases/allow-traffic-from-verified-bots/");
|
|
318
|
+
console.log("");
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Get search analytics data
|
|
322
|
+
*/
|
|
323
|
+
async getSearchAnalytics(options) {
|
|
324
|
+
try {
|
|
325
|
+
const response = await this.searchconsole.searchanalytics.query({
|
|
326
|
+
siteUrl: this.gscSiteUrl,
|
|
327
|
+
requestBody: {
|
|
328
|
+
startDate: options.startDate,
|
|
329
|
+
endDate: options.endDate,
|
|
330
|
+
dimensions: options.dimensions || ["page"],
|
|
331
|
+
rowLimit: options.rowLimit || 1e3
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return response.data.rows || [];
|
|
335
|
+
} catch (error) {
|
|
336
|
+
consola3.error("Failed to get search analytics:", error);
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Get list of sitemaps
|
|
342
|
+
*/
|
|
343
|
+
async getSitemaps() {
|
|
344
|
+
try {
|
|
345
|
+
const response = await this.searchconsole.sitemaps.list({
|
|
346
|
+
siteUrl: this.gscSiteUrl
|
|
347
|
+
});
|
|
348
|
+
return response.data.sitemap || [];
|
|
349
|
+
} catch (error) {
|
|
350
|
+
consola3.error("Failed to get sitemaps:", error);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Map API response to our types
|
|
356
|
+
*/
|
|
357
|
+
mapInspectionResult(url, result) {
|
|
358
|
+
const indexStatus = result.indexStatusResult;
|
|
359
|
+
return {
|
|
360
|
+
url,
|
|
361
|
+
inspectionResultLink: result.inspectionResultLink || void 0,
|
|
362
|
+
indexStatusResult: {
|
|
363
|
+
verdict: indexStatus.verdict || "VERDICT_UNSPECIFIED",
|
|
364
|
+
coverageState: indexStatus.coverageState || "COVERAGE_STATE_UNSPECIFIED",
|
|
365
|
+
indexingState: indexStatus.indexingState || "INDEXING_STATE_UNSPECIFIED",
|
|
366
|
+
robotsTxtState: indexStatus.robotsTxtState || "ROBOTS_TXT_STATE_UNSPECIFIED",
|
|
367
|
+
pageFetchState: indexStatus.pageFetchState || "PAGE_FETCH_STATE_UNSPECIFIED",
|
|
368
|
+
lastCrawlTime: indexStatus.lastCrawlTime || void 0,
|
|
369
|
+
crawledAs: indexStatus.crawledAs,
|
|
370
|
+
googleCanonical: indexStatus.googleCanonical || void 0,
|
|
371
|
+
userCanonical: indexStatus.userCanonical || void 0,
|
|
372
|
+
sitemap: indexStatus.sitemap || void 0,
|
|
373
|
+
referringUrls: indexStatus.referringUrls || void 0
|
|
374
|
+
},
|
|
375
|
+
mobileUsabilityResult: result.mobileUsabilityResult ? {
|
|
376
|
+
verdict: result.mobileUsabilityResult.verdict || "VERDICT_UNSPECIFIED",
|
|
377
|
+
issues: result.mobileUsabilityResult.issues?.map((issue) => ({
|
|
378
|
+
issueType: issue.issueType || "UNKNOWN",
|
|
379
|
+
message: issue.message || ""
|
|
380
|
+
}))
|
|
381
|
+
} : void 0,
|
|
382
|
+
richResultsResult: result.richResultsResult ? {
|
|
383
|
+
verdict: result.richResultsResult.verdict || "VERDICT_UNSPECIFIED",
|
|
384
|
+
detectedItems: result.richResultsResult.detectedItems?.map((item) => ({
|
|
385
|
+
richResultType: item.richResultType || "UNKNOWN",
|
|
386
|
+
items: item.items?.map((i) => ({
|
|
387
|
+
name: i.name || "",
|
|
388
|
+
issues: i.issues?.map((issue) => ({
|
|
389
|
+
issueMessage: issue.issueMessage || "",
|
|
390
|
+
severity: issue.severity || "WARNING"
|
|
391
|
+
}))
|
|
392
|
+
}))
|
|
393
|
+
}))
|
|
394
|
+
} : void 0
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// src/google-console/analyzer.ts
|
|
400
|
+
function analyzeInspectionResults(results) {
|
|
401
|
+
const issues = [];
|
|
402
|
+
for (const result of results) {
|
|
403
|
+
issues.push(...analyzeUrlInspection(result));
|
|
404
|
+
}
|
|
405
|
+
return issues.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
|
|
406
|
+
}
|
|
407
|
+
function analyzeUrlInspection(result) {
|
|
408
|
+
const issues = [];
|
|
409
|
+
const { indexStatusResult, mobileUsabilityResult, richResultsResult } = result;
|
|
410
|
+
switch (indexStatusResult.coverageState) {
|
|
411
|
+
case "CRAWLED_CURRENTLY_NOT_INDEXED":
|
|
412
|
+
issues.push({
|
|
413
|
+
id: `crawled-not-indexed-${hash(result.url)}`,
|
|
414
|
+
url: result.url,
|
|
415
|
+
category: "indexing",
|
|
416
|
+
severity: "error",
|
|
417
|
+
title: "Page crawled but not indexed",
|
|
418
|
+
description: "Google crawled this page but decided not to index it. This often indicates low content quality or duplicate content.",
|
|
419
|
+
recommendation: "Improve content quality, ensure uniqueness, add more valuable information, and check for duplicate content issues.",
|
|
420
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
421
|
+
metadata: { coverageState: indexStatusResult.coverageState }
|
|
422
|
+
});
|
|
423
|
+
break;
|
|
424
|
+
case "DISCOVERED_CURRENTLY_NOT_INDEXED":
|
|
425
|
+
issues.push({
|
|
426
|
+
id: `discovered-not-indexed-${hash(result.url)}`,
|
|
427
|
+
url: result.url,
|
|
428
|
+
category: "indexing",
|
|
429
|
+
severity: "warning",
|
|
430
|
+
title: "Page discovered but not crawled",
|
|
431
|
+
description: "Google discovered this URL but has not crawled it yet. This may indicate crawl budget issues or low priority.",
|
|
432
|
+
recommendation: "Improve internal linking to this page, submit URL through Google Search Console, or add to sitemap.",
|
|
433
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
434
|
+
metadata: { coverageState: indexStatusResult.coverageState }
|
|
435
|
+
});
|
|
436
|
+
break;
|
|
437
|
+
case "DUPLICATE_WITHOUT_USER_SELECTED_CANONICAL":
|
|
438
|
+
issues.push({
|
|
439
|
+
id: `duplicate-no-canonical-${hash(result.url)}`,
|
|
440
|
+
url: result.url,
|
|
441
|
+
category: "indexing",
|
|
442
|
+
severity: "warning",
|
|
443
|
+
title: "Duplicate page without canonical",
|
|
444
|
+
description: "This page is considered a duplicate but no canonical URL has been specified. Google chose a canonical for you.",
|
|
445
|
+
recommendation: "Add a canonical tag pointing to the preferred version of this page.",
|
|
446
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
447
|
+
metadata: {
|
|
448
|
+
coverageState: indexStatusResult.coverageState,
|
|
449
|
+
googleCanonical: indexStatusResult.googleCanonical
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
case "DUPLICATE_GOOGLE_CHOSE_DIFFERENT_CANONICAL":
|
|
454
|
+
issues.push({
|
|
455
|
+
id: `canonical-mismatch-${hash(result.url)}`,
|
|
456
|
+
url: result.url,
|
|
457
|
+
category: "indexing",
|
|
458
|
+
severity: "warning",
|
|
459
|
+
title: "Google chose different canonical",
|
|
460
|
+
description: "You specified a canonical URL, but Google chose a different one. This may cause indexing issues.",
|
|
461
|
+
recommendation: "Review canonical tags and ensure they point to the correct URL. Check for duplicate content.",
|
|
462
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
463
|
+
metadata: {
|
|
464
|
+
coverageState: indexStatusResult.coverageState,
|
|
465
|
+
userCanonical: indexStatusResult.userCanonical,
|
|
466
|
+
googleCanonical: indexStatusResult.googleCanonical
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
switch (indexStatusResult.indexingState) {
|
|
472
|
+
case "BLOCKED_BY_META_TAG":
|
|
473
|
+
issues.push({
|
|
474
|
+
id: `blocked-meta-noindex-${hash(result.url)}`,
|
|
475
|
+
url: result.url,
|
|
476
|
+
category: "indexing",
|
|
477
|
+
severity: "error",
|
|
478
|
+
title: "Blocked by noindex meta tag",
|
|
479
|
+
description: "This page has a noindex meta tag preventing it from being indexed.",
|
|
480
|
+
recommendation: "Remove the noindex meta tag if you want this page to be indexed. If intentional, no action needed.",
|
|
481
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
482
|
+
metadata: { indexingState: indexStatusResult.indexingState }
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
case "BLOCKED_BY_HTTP_HEADER":
|
|
486
|
+
issues.push({
|
|
487
|
+
id: `blocked-http-header-${hash(result.url)}`,
|
|
488
|
+
url: result.url,
|
|
489
|
+
category: "indexing",
|
|
490
|
+
severity: "error",
|
|
491
|
+
title: "Blocked by X-Robots-Tag header",
|
|
492
|
+
description: "This page has a noindex directive in the X-Robots-Tag HTTP header.",
|
|
493
|
+
recommendation: "Remove the X-Robots-Tag: noindex header if you want this page to be indexed.",
|
|
494
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
495
|
+
metadata: { indexingState: indexStatusResult.indexingState }
|
|
496
|
+
});
|
|
497
|
+
break;
|
|
498
|
+
case "BLOCKED_BY_ROBOTS_TXT":
|
|
499
|
+
issues.push({
|
|
500
|
+
id: `blocked-robots-txt-${hash(result.url)}`,
|
|
501
|
+
url: result.url,
|
|
502
|
+
category: "crawling",
|
|
503
|
+
severity: "error",
|
|
504
|
+
title: "Blocked by robots.txt",
|
|
505
|
+
description: "This page is blocked from crawling by robots.txt rules.",
|
|
506
|
+
recommendation: "Update robots.txt to allow crawling if you want this page to be indexed.",
|
|
507
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
508
|
+
metadata: { indexingState: indexStatusResult.indexingState }
|
|
509
|
+
});
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
switch (indexStatusResult.pageFetchState) {
|
|
513
|
+
case "SOFT_404":
|
|
514
|
+
issues.push({
|
|
515
|
+
id: `soft-404-${hash(result.url)}`,
|
|
516
|
+
url: result.url,
|
|
517
|
+
category: "technical",
|
|
518
|
+
severity: "error",
|
|
519
|
+
title: "Soft 404 error",
|
|
520
|
+
description: "This page returns a 200 status but Google detected it as a 404 page (empty or low-value content).",
|
|
521
|
+
recommendation: "Either return a proper 404 status code or add meaningful content to this page.",
|
|
522
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
523
|
+
metadata: { pageFetchState: indexStatusResult.pageFetchState }
|
|
524
|
+
});
|
|
525
|
+
break;
|
|
526
|
+
case "NOT_FOUND":
|
|
527
|
+
issues.push({
|
|
528
|
+
id: `404-error-${hash(result.url)}`,
|
|
529
|
+
url: result.url,
|
|
530
|
+
category: "technical",
|
|
531
|
+
severity: "error",
|
|
532
|
+
title: "404 Not Found",
|
|
533
|
+
description: "This page returns a 404 error.",
|
|
534
|
+
recommendation: "Either restore the page content or set up a redirect to a relevant page.",
|
|
535
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
536
|
+
metadata: { pageFetchState: indexStatusResult.pageFetchState }
|
|
537
|
+
});
|
|
538
|
+
break;
|
|
539
|
+
case "SERVER_ERROR":
|
|
540
|
+
issues.push({
|
|
541
|
+
id: `server-error-${hash(result.url)}`,
|
|
542
|
+
url: result.url,
|
|
543
|
+
category: "technical",
|
|
544
|
+
severity: "critical",
|
|
545
|
+
title: "Server error (5xx)",
|
|
546
|
+
description: "This page returns a server error when Google tries to crawl it.",
|
|
547
|
+
recommendation: "Fix the server-side error. Check server logs for details.",
|
|
548
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
549
|
+
metadata: { pageFetchState: indexStatusResult.pageFetchState }
|
|
550
|
+
});
|
|
551
|
+
break;
|
|
552
|
+
case "REDIRECT_ERROR":
|
|
553
|
+
issues.push({
|
|
554
|
+
id: `redirect-error-${hash(result.url)}`,
|
|
555
|
+
url: result.url,
|
|
556
|
+
category: "technical",
|
|
557
|
+
severity: "error",
|
|
558
|
+
title: "Redirect error",
|
|
559
|
+
description: "There is a redirect issue with this page (redirect loop, too many redirects, or invalid redirect).",
|
|
560
|
+
recommendation: "Fix the redirect chain. Ensure redirects point to valid, accessible pages.",
|
|
561
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
562
|
+
metadata: { pageFetchState: indexStatusResult.pageFetchState }
|
|
563
|
+
});
|
|
564
|
+
break;
|
|
565
|
+
case "ACCESS_DENIED":
|
|
566
|
+
case "ACCESS_FORBIDDEN":
|
|
567
|
+
issues.push({
|
|
568
|
+
id: `access-denied-${hash(result.url)}`,
|
|
569
|
+
url: result.url,
|
|
570
|
+
category: "technical",
|
|
571
|
+
severity: "error",
|
|
572
|
+
title: "Access denied (401/403)",
|
|
573
|
+
description: "Google cannot access this page due to authentication requirements.",
|
|
574
|
+
recommendation: "Ensure the page is publicly accessible without authentication for Googlebot.",
|
|
575
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
576
|
+
metadata: { pageFetchState: indexStatusResult.pageFetchState }
|
|
577
|
+
});
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
if (mobileUsabilityResult?.verdict === "FAIL" && mobileUsabilityResult.issues) {
|
|
581
|
+
for (const issue of mobileUsabilityResult.issues) {
|
|
582
|
+
issues.push({
|
|
583
|
+
id: `mobile-${issue.issueType}-${hash(result.url)}`,
|
|
584
|
+
url: result.url,
|
|
585
|
+
category: "mobile",
|
|
586
|
+
severity: "warning",
|
|
587
|
+
title: `Mobile usability: ${formatIssueType(issue.issueType)}`,
|
|
588
|
+
description: issue.message || "Mobile usability issue detected.",
|
|
589
|
+
recommendation: getMobileRecommendation(issue.issueType),
|
|
590
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
591
|
+
metadata: { issueType: issue.issueType }
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (richResultsResult?.verdict === "FAIL" && richResultsResult.detectedItems) {
|
|
596
|
+
for (const item of richResultsResult.detectedItems) {
|
|
597
|
+
for (const i of item.items || []) {
|
|
598
|
+
for (const issueDetail of i.issues || []) {
|
|
599
|
+
issues.push({
|
|
600
|
+
id: `rich-result-${item.richResultType}-${hash(result.url)}`,
|
|
601
|
+
url: result.url,
|
|
602
|
+
category: "structured-data",
|
|
603
|
+
severity: issueDetail.severity === "ERROR" ? "error" : "warning",
|
|
604
|
+
title: `${item.richResultType}: ${i.name}`,
|
|
605
|
+
description: issueDetail.issueMessage,
|
|
606
|
+
recommendation: "Fix the structured data markup according to Google guidelines.",
|
|
607
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
608
|
+
metadata: { richResultType: item.richResultType }
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return issues;
|
|
615
|
+
}
|
|
616
|
+
function severityOrder(severity) {
|
|
617
|
+
const order = {
|
|
618
|
+
critical: 0,
|
|
619
|
+
error: 1,
|
|
620
|
+
warning: 2,
|
|
621
|
+
info: 3
|
|
622
|
+
};
|
|
623
|
+
return order[severity];
|
|
624
|
+
}
|
|
625
|
+
function hash(str) {
|
|
626
|
+
let hash5 = 0;
|
|
627
|
+
for (let i = 0; i < str.length; i++) {
|
|
628
|
+
const char = str.charCodeAt(i);
|
|
629
|
+
hash5 = (hash5 << 5) - hash5 + char;
|
|
630
|
+
hash5 = hash5 & hash5;
|
|
631
|
+
}
|
|
632
|
+
return Math.abs(hash5).toString(36);
|
|
633
|
+
}
|
|
634
|
+
function formatIssueType(type) {
|
|
635
|
+
return type.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
|
636
|
+
}
|
|
637
|
+
function getMobileRecommendation(issueType) {
|
|
638
|
+
const recommendations = {
|
|
639
|
+
MOBILE_FRIENDLY_RULE_USES_INCOMPATIBLE_PLUGINS: "Remove Flash or other incompatible plugins. Use HTML5 alternatives.",
|
|
640
|
+
MOBILE_FRIENDLY_RULE_CONFIGURE_VIEWPORT: 'Add a viewport meta tag: <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
641
|
+
MOBILE_FRIENDLY_RULE_CONTENT_NOT_SIZED_TO_VIEWPORT: "Ensure content width fits the viewport. Use responsive CSS.",
|
|
642
|
+
MOBILE_FRIENDLY_RULE_TAP_TARGETS_TOO_SMALL: "Increase the size of touch targets (buttons, links) to at least 48x48 pixels.",
|
|
643
|
+
MOBILE_FRIENDLY_RULE_TEXT_TOO_SMALL: "Use at least 16px font size for body text."
|
|
644
|
+
};
|
|
645
|
+
return recommendations[issueType] || "Fix the mobile usability issue according to Google guidelines.";
|
|
646
|
+
}
|
|
647
|
+
var DEFAULT_CONFIG = {
|
|
648
|
+
maxPages: 100,
|
|
649
|
+
maxDepth: 3,
|
|
650
|
+
concurrency: 5,
|
|
651
|
+
timeout: 3e4,
|
|
652
|
+
userAgent: "DjangoCFG-SEO-Crawler/1.0 (+https://djangocfg.com/bot)",
|
|
653
|
+
respectRobotsTxt: true,
|
|
654
|
+
includePatterns: [],
|
|
655
|
+
excludePatterns: [
|
|
656
|
+
"/api/",
|
|
657
|
+
"/admin/",
|
|
658
|
+
"/_next/",
|
|
659
|
+
"/static/",
|
|
660
|
+
".pdf",
|
|
661
|
+
".jpg",
|
|
662
|
+
".png",
|
|
663
|
+
".gif",
|
|
664
|
+
".svg",
|
|
665
|
+
".css",
|
|
666
|
+
".js"
|
|
667
|
+
]
|
|
668
|
+
};
|
|
669
|
+
var SiteCrawler = class {
|
|
670
|
+
config;
|
|
671
|
+
baseUrl;
|
|
672
|
+
visited = /* @__PURE__ */ new Set();
|
|
673
|
+
queue = [];
|
|
674
|
+
results = [];
|
|
675
|
+
limit;
|
|
676
|
+
constructor(siteUrl, config2) {
|
|
677
|
+
this.config = { ...DEFAULT_CONFIG, ...config2 };
|
|
678
|
+
this.baseUrl = new URL(siteUrl);
|
|
679
|
+
this.limit = pLimit(this.config.concurrency);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Start crawling the site
|
|
683
|
+
*/
|
|
684
|
+
async crawl() {
|
|
685
|
+
consola3.info(`Starting crawl of ${this.baseUrl.origin}`);
|
|
686
|
+
consola3.info(`Config: maxPages=${this.config.maxPages}, maxDepth=${this.config.maxDepth}`);
|
|
687
|
+
this.queue.push({ url: this.baseUrl.href, depth: 0 });
|
|
688
|
+
while (this.queue.length > 0 && this.results.length < this.config.maxPages) {
|
|
689
|
+
const batch = this.queue.splice(0, this.config.concurrency);
|
|
690
|
+
const promises = batch.map(
|
|
691
|
+
({ url, depth }) => this.limit(() => this.crawlPage(url, depth))
|
|
692
|
+
);
|
|
693
|
+
await Promise.all(promises);
|
|
694
|
+
}
|
|
695
|
+
consola3.success(`Crawl complete. Crawled ${this.results.length} pages.`);
|
|
696
|
+
return this.results;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Crawl a single page
|
|
700
|
+
*/
|
|
701
|
+
async crawlPage(url, depth) {
|
|
702
|
+
const normalizedUrl = this.normalizeUrl(url);
|
|
703
|
+
if (this.visited.has(normalizedUrl)) return;
|
|
704
|
+
if (this.shouldExclude(normalizedUrl)) return;
|
|
705
|
+
this.visited.add(normalizedUrl);
|
|
706
|
+
const startTime = Date.now();
|
|
707
|
+
const result = {
|
|
708
|
+
url: normalizedUrl,
|
|
709
|
+
statusCode: 0,
|
|
710
|
+
links: { internal: [], external: [] },
|
|
711
|
+
images: [],
|
|
712
|
+
loadTime: 0,
|
|
713
|
+
errors: [],
|
|
714
|
+
warnings: [],
|
|
715
|
+
crawledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
716
|
+
};
|
|
717
|
+
try {
|
|
718
|
+
const controller = new AbortController();
|
|
719
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
720
|
+
const response = await fetch(normalizedUrl, {
|
|
721
|
+
headers: {
|
|
722
|
+
"User-Agent": this.config.userAgent,
|
|
723
|
+
Accept: "text/html,application/xhtml+xml"
|
|
724
|
+
},
|
|
725
|
+
signal: controller.signal,
|
|
726
|
+
redirect: "follow"
|
|
727
|
+
});
|
|
728
|
+
result.ttfb = Date.now() - startTime;
|
|
729
|
+
clearTimeout(timeoutId);
|
|
730
|
+
result.statusCode = response.status;
|
|
731
|
+
result.contentType = response.headers.get("content-type") || void 0;
|
|
732
|
+
result.contentLength = Number(response.headers.get("content-length")) || void 0;
|
|
733
|
+
if (response.ok && result.contentType?.includes("text/html")) {
|
|
734
|
+
const html = await response.text();
|
|
735
|
+
this.parseHtml(html, result, normalizedUrl, depth);
|
|
736
|
+
} else if (!response.ok) {
|
|
737
|
+
result.errors.push(`HTTP ${response.status}: ${response.statusText}`);
|
|
738
|
+
}
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (error instanceof Error) {
|
|
741
|
+
if (error.name === "AbortError") {
|
|
742
|
+
result.errors.push("Request timeout");
|
|
743
|
+
} else {
|
|
744
|
+
result.errors.push(error.message);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
result.loadTime = Date.now() - startTime;
|
|
749
|
+
this.results.push(result);
|
|
750
|
+
consola3.debug(`Crawled: ${normalizedUrl} (${result.statusCode}) - ${result.loadTime}ms`);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Parse HTML and extract SEO-relevant data
|
|
754
|
+
*/
|
|
755
|
+
parseHtml(html, result, pageUrl, depth) {
|
|
756
|
+
const $ = load(html);
|
|
757
|
+
result.title = $("title").first().text().trim() || void 0;
|
|
758
|
+
if (!result.title) {
|
|
759
|
+
result.warnings.push("Missing title tag");
|
|
760
|
+
} else if (result.title.length > 60) {
|
|
761
|
+
result.warnings.push(`Title too long (${result.title.length} chars, recommended: <60)`);
|
|
762
|
+
}
|
|
763
|
+
result.metaDescription = $('meta[name="description"]').attr("content")?.trim() || void 0;
|
|
764
|
+
if (!result.metaDescription) {
|
|
765
|
+
result.warnings.push("Missing meta description");
|
|
766
|
+
} else if (result.metaDescription.length > 160) {
|
|
767
|
+
result.warnings.push(
|
|
768
|
+
`Meta description too long (${result.metaDescription.length} chars, recommended: <160)`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
result.metaRobots = $('meta[name="robots"]').attr("content")?.trim() || void 0;
|
|
772
|
+
const xRobots = $('meta[http-equiv="X-Robots-Tag"]').attr("content")?.trim();
|
|
773
|
+
if (xRobots) {
|
|
774
|
+
result.metaRobots = result.metaRobots ? `${result.metaRobots}, ${xRobots}` : xRobots;
|
|
775
|
+
}
|
|
776
|
+
result.canonicalUrl = $('link[rel="canonical"]').attr("href")?.trim() || void 0;
|
|
777
|
+
if (!result.canonicalUrl) {
|
|
778
|
+
result.warnings.push("Missing canonical tag");
|
|
779
|
+
}
|
|
780
|
+
result.h1 = $("h1").map((_, el) => $(el).text().trim()).get();
|
|
781
|
+
result.h2 = $("h2").map((_, el) => $(el).text().trim()).get();
|
|
782
|
+
if (result.h1.length === 0) {
|
|
783
|
+
result.warnings.push("Missing H1 tag");
|
|
784
|
+
} else if (result.h1.length > 1) {
|
|
785
|
+
result.warnings.push(`Multiple H1 tags (${result.h1.length})`);
|
|
786
|
+
}
|
|
787
|
+
$("a[href]").each((_, el) => {
|
|
788
|
+
const href = $(el).attr("href");
|
|
789
|
+
if (!href) return;
|
|
790
|
+
try {
|
|
791
|
+
const linkUrl = new URL(href, pageUrl);
|
|
792
|
+
if (linkUrl.hostname === this.baseUrl.hostname) {
|
|
793
|
+
const internalUrl = this.normalizeUrl(linkUrl.href);
|
|
794
|
+
result.links.internal.push(internalUrl);
|
|
795
|
+
if (depth < this.config.maxDepth && !this.visited.has(internalUrl)) {
|
|
796
|
+
this.queue.push({ url: internalUrl, depth: depth + 1 });
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
result.links.external.push(linkUrl.href);
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
$("img").each((_, el) => {
|
|
805
|
+
const src = $(el).attr("src");
|
|
806
|
+
const alt = $(el).attr("alt");
|
|
807
|
+
if (src) {
|
|
808
|
+
result.images.push({
|
|
809
|
+
src,
|
|
810
|
+
alt,
|
|
811
|
+
hasAlt: alt !== void 0 && alt.trim().length > 0
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
const imagesWithoutAlt = result.images.filter((img) => !img.hasAlt);
|
|
816
|
+
if (imagesWithoutAlt.length > 0) {
|
|
817
|
+
result.warnings.push(`${imagesWithoutAlt.length} images without alt text`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Normalize URL for deduplication
|
|
822
|
+
*/
|
|
823
|
+
normalizeUrl(url) {
|
|
824
|
+
try {
|
|
825
|
+
const parsed = new URL(url, this.baseUrl.href);
|
|
826
|
+
parsed.hash = "";
|
|
827
|
+
let pathname = parsed.pathname;
|
|
828
|
+
if (pathname.endsWith("/") && pathname !== "/") {
|
|
829
|
+
pathname = pathname.slice(0, -1);
|
|
830
|
+
}
|
|
831
|
+
parsed.pathname = pathname;
|
|
832
|
+
return parsed.href;
|
|
833
|
+
} catch {
|
|
834
|
+
return url;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Check if URL should be excluded
|
|
839
|
+
*/
|
|
840
|
+
shouldExclude(url) {
|
|
841
|
+
if (this.config.includePatterns.length > 0) {
|
|
842
|
+
const included = this.config.includePatterns.some(
|
|
843
|
+
(pattern) => url.includes(pattern)
|
|
844
|
+
);
|
|
845
|
+
if (!included) return true;
|
|
846
|
+
}
|
|
847
|
+
return this.config.excludePatterns.some((pattern) => url.includes(pattern));
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
function analyzeCrawlResults(results) {
|
|
851
|
+
const issues = [];
|
|
852
|
+
for (const result of results) {
|
|
853
|
+
if (result.statusCode >= 400) {
|
|
854
|
+
issues.push({
|
|
855
|
+
id: `http-error-${hash2(result.url)}`,
|
|
856
|
+
url: result.url,
|
|
857
|
+
category: "technical",
|
|
858
|
+
severity: result.statusCode >= 500 ? "critical" : "error",
|
|
859
|
+
title: `HTTP ${result.statusCode} error`,
|
|
860
|
+
description: `Page returns ${result.statusCode} status code.`,
|
|
861
|
+
recommendation: result.statusCode === 404 ? "Either restore the content or set up a redirect." : "Fix the server error and ensure the page is accessible.",
|
|
862
|
+
detectedAt: result.crawledAt,
|
|
863
|
+
metadata: { statusCode: result.statusCode }
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (!result.title && result.statusCode === 200) {
|
|
867
|
+
issues.push({
|
|
868
|
+
id: `missing-title-${hash2(result.url)}`,
|
|
869
|
+
url: result.url,
|
|
870
|
+
category: "content",
|
|
871
|
+
severity: "error",
|
|
872
|
+
title: "Missing title tag",
|
|
873
|
+
description: "This page does not have a title tag.",
|
|
874
|
+
recommendation: "Add a unique, descriptive title tag (50-60 characters).",
|
|
875
|
+
detectedAt: result.crawledAt
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (!result.metaDescription && result.statusCode === 200) {
|
|
879
|
+
issues.push({
|
|
880
|
+
id: `missing-meta-desc-${hash2(result.url)}`,
|
|
881
|
+
url: result.url,
|
|
882
|
+
category: "content",
|
|
883
|
+
severity: "warning",
|
|
884
|
+
title: "Missing meta description",
|
|
885
|
+
description: "This page does not have a meta description.",
|
|
886
|
+
recommendation: "Add a unique meta description (120-160 characters).",
|
|
887
|
+
detectedAt: result.crawledAt
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (result.h1 && result.h1.length === 0 && result.statusCode === 200) {
|
|
891
|
+
issues.push({
|
|
892
|
+
id: `missing-h1-${hash2(result.url)}`,
|
|
893
|
+
url: result.url,
|
|
894
|
+
category: "content",
|
|
895
|
+
severity: "warning",
|
|
896
|
+
title: "Missing H1 heading",
|
|
897
|
+
description: "This page does not have an H1 heading.",
|
|
898
|
+
recommendation: "Add a single H1 heading that describes the page content.",
|
|
899
|
+
detectedAt: result.crawledAt
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
if (result.h1 && result.h1.length > 1) {
|
|
903
|
+
issues.push({
|
|
904
|
+
id: `multiple-h1-${hash2(result.url)}`,
|
|
905
|
+
url: result.url,
|
|
906
|
+
category: "content",
|
|
907
|
+
severity: "warning",
|
|
908
|
+
title: "Multiple H1 headings",
|
|
909
|
+
description: `This page has ${result.h1.length} H1 headings.`,
|
|
910
|
+
recommendation: "Use only one H1 heading per page.",
|
|
911
|
+
detectedAt: result.crawledAt,
|
|
912
|
+
metadata: { h1Count: result.h1.length }
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const imagesWithoutAlt = result.images.filter((img) => !img.hasAlt);
|
|
916
|
+
if (imagesWithoutAlt.length > 0) {
|
|
917
|
+
issues.push({
|
|
918
|
+
id: `images-no-alt-${hash2(result.url)}`,
|
|
919
|
+
url: result.url,
|
|
920
|
+
category: "content",
|
|
921
|
+
severity: "info",
|
|
922
|
+
title: "Images without alt text",
|
|
923
|
+
description: `${imagesWithoutAlt.length} images are missing alt text.`,
|
|
924
|
+
recommendation: "Add descriptive alt text to all images for accessibility and SEO.",
|
|
925
|
+
detectedAt: result.crawledAt,
|
|
926
|
+
metadata: { count: imagesWithoutAlt.length }
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
if (result.loadTime > 3e3) {
|
|
930
|
+
issues.push({
|
|
931
|
+
id: `slow-page-${hash2(result.url)}`,
|
|
932
|
+
url: result.url,
|
|
933
|
+
category: "performance",
|
|
934
|
+
severity: result.loadTime > 5e3 ? "error" : "warning",
|
|
935
|
+
title: "Slow page load time",
|
|
936
|
+
description: `Page took ${result.loadTime}ms to load.`,
|
|
937
|
+
recommendation: "Optimize page load time. Target under 3 seconds.",
|
|
938
|
+
detectedAt: result.crawledAt,
|
|
939
|
+
metadata: { loadTime: result.loadTime }
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
if (result.ttfb && result.ttfb > 800) {
|
|
943
|
+
issues.push({
|
|
944
|
+
id: `slow-ttfb-${hash2(result.url)}`,
|
|
945
|
+
url: result.url,
|
|
946
|
+
category: "performance",
|
|
947
|
+
severity: result.ttfb > 1500 ? "error" : "warning",
|
|
948
|
+
title: "Slow Time to First Byte",
|
|
949
|
+
description: `TTFB is ${result.ttfb}ms. Server responded slowly.`,
|
|
950
|
+
recommendation: "Optimize server response. Target TTFB under 800ms. Consider CDN, caching, or server upgrades.",
|
|
951
|
+
detectedAt: result.crawledAt,
|
|
952
|
+
metadata: { ttfb: result.ttfb }
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (result.metaRobots?.includes("noindex")) {
|
|
956
|
+
issues.push({
|
|
957
|
+
id: `noindex-${hash2(result.url)}`,
|
|
958
|
+
url: result.url,
|
|
959
|
+
category: "indexing",
|
|
960
|
+
severity: "info",
|
|
961
|
+
title: "Page marked as noindex",
|
|
962
|
+
description: "This page has a noindex directive.",
|
|
963
|
+
recommendation: "Verify this is intentional. Remove noindex if the page should be indexed.",
|
|
964
|
+
detectedAt: result.crawledAt,
|
|
965
|
+
metadata: { metaRobots: result.metaRobots }
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return issues;
|
|
970
|
+
}
|
|
971
|
+
function hash2(str) {
|
|
972
|
+
let hash5 = 0;
|
|
973
|
+
for (let i = 0; i < str.length; i++) {
|
|
974
|
+
const char = str.charCodeAt(i);
|
|
975
|
+
hash5 = (hash5 << 5) - hash5 + char;
|
|
976
|
+
hash5 = hash5 & hash5;
|
|
977
|
+
}
|
|
978
|
+
return Math.abs(hash5).toString(36);
|
|
979
|
+
}
|
|
980
|
+
async function analyzeRobotsTxt(siteUrl) {
|
|
981
|
+
const robotsUrl = new URL("/robots.txt", siteUrl).href;
|
|
982
|
+
const analysis = {
|
|
983
|
+
exists: false,
|
|
984
|
+
sitemaps: [],
|
|
985
|
+
allowedPaths: [],
|
|
986
|
+
disallowedPaths: [],
|
|
987
|
+
issues: []
|
|
988
|
+
};
|
|
989
|
+
try {
|
|
990
|
+
const response = await fetch(robotsUrl);
|
|
991
|
+
if (!response.ok) {
|
|
992
|
+
analysis.issues.push({
|
|
993
|
+
id: "missing-robots-txt",
|
|
994
|
+
url: robotsUrl,
|
|
995
|
+
category: "technical",
|
|
996
|
+
severity: "warning",
|
|
997
|
+
title: "Missing robots.txt",
|
|
998
|
+
description: `No robots.txt file found (HTTP ${response.status}).`,
|
|
999
|
+
recommendation: "Create a robots.txt file to control crawler access.",
|
|
1000
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1001
|
+
});
|
|
1002
|
+
return analysis;
|
|
1003
|
+
}
|
|
1004
|
+
analysis.exists = true;
|
|
1005
|
+
analysis.content = await response.text();
|
|
1006
|
+
const robots = robotsParser(robotsUrl, analysis.content);
|
|
1007
|
+
analysis.sitemaps = robots.getSitemaps();
|
|
1008
|
+
if (analysis.sitemaps.length === 0) {
|
|
1009
|
+
analysis.issues.push({
|
|
1010
|
+
id: "no-sitemap-in-robots",
|
|
1011
|
+
url: robotsUrl,
|
|
1012
|
+
category: "technical",
|
|
1013
|
+
severity: "info",
|
|
1014
|
+
title: "No sitemap in robots.txt",
|
|
1015
|
+
description: "No sitemap URL is declared in robots.txt.",
|
|
1016
|
+
recommendation: "Add a Sitemap directive pointing to your XML sitemap.",
|
|
1017
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const lines = analysis.content.split("\n");
|
|
1021
|
+
let currentUserAgent = "*";
|
|
1022
|
+
for (const line of lines) {
|
|
1023
|
+
const trimmed = line.trim().toLowerCase();
|
|
1024
|
+
if (trimmed.startsWith("user-agent:")) {
|
|
1025
|
+
currentUserAgent = trimmed.replace("user-agent:", "").trim();
|
|
1026
|
+
} else if (trimmed.startsWith("disallow:")) {
|
|
1027
|
+
const path6 = line.trim().replace(/disallow:/i, "").trim();
|
|
1028
|
+
if (path6) {
|
|
1029
|
+
analysis.disallowedPaths.push(path6);
|
|
1030
|
+
}
|
|
1031
|
+
} else if (trimmed.startsWith("allow:")) {
|
|
1032
|
+
const path6 = line.trim().replace(/allow:/i, "").trim();
|
|
1033
|
+
if (path6) {
|
|
1034
|
+
analysis.allowedPaths.push(path6);
|
|
1035
|
+
}
|
|
1036
|
+
} else if (trimmed.startsWith("crawl-delay:")) {
|
|
1037
|
+
const delay = parseInt(trimmed.replace("crawl-delay:", "").trim(), 10);
|
|
1038
|
+
if (!isNaN(delay)) {
|
|
1039
|
+
analysis.crawlDelay = delay;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const importantPaths = ["/", "/sitemap.xml"];
|
|
1044
|
+
for (const path6 of importantPaths) {
|
|
1045
|
+
if (!robots.isAllowed(new URL(path6, siteUrl).href, "Googlebot")) {
|
|
1046
|
+
analysis.issues.push({
|
|
1047
|
+
id: `blocked-important-path-${path6.replace(/\//g, "-")}`,
|
|
1048
|
+
url: siteUrl,
|
|
1049
|
+
category: "crawling",
|
|
1050
|
+
severity: "error",
|
|
1051
|
+
title: `Important path blocked: ${path6}`,
|
|
1052
|
+
description: `The path ${path6} is blocked in robots.txt.`,
|
|
1053
|
+
recommendation: `Ensure ${path6} is accessible to search engines.`,
|
|
1054
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1055
|
+
metadata: { path: path6 }
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (analysis.disallowedPaths.includes("/")) {
|
|
1060
|
+
analysis.issues.push({
|
|
1061
|
+
id: "all-blocked",
|
|
1062
|
+
url: robotsUrl,
|
|
1063
|
+
category: "crawling",
|
|
1064
|
+
severity: "critical",
|
|
1065
|
+
title: "Entire site blocked",
|
|
1066
|
+
description: "robots.txt blocks access to the entire site (Disallow: /).",
|
|
1067
|
+
recommendation: "Remove or modify this rule if you want your site to be indexed.",
|
|
1068
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
consola3.debug(`Analyzed robots.txt: ${analysis.disallowedPaths.length} disallow rules`);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
consola3.error("Failed to fetch robots.txt:", error);
|
|
1074
|
+
analysis.issues.push({
|
|
1075
|
+
id: "robots-txt-error",
|
|
1076
|
+
url: robotsUrl,
|
|
1077
|
+
category: "technical",
|
|
1078
|
+
severity: "warning",
|
|
1079
|
+
title: "Failed to fetch robots.txt",
|
|
1080
|
+
description: `Error fetching robots.txt: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1081
|
+
recommendation: "Ensure robots.txt is accessible.",
|
|
1082
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
return analysis;
|
|
1086
|
+
}
|
|
1087
|
+
async function analyzeSitemap(sitemapUrl) {
|
|
1088
|
+
const analysis = {
|
|
1089
|
+
url: sitemapUrl,
|
|
1090
|
+
exists: false,
|
|
1091
|
+
type: "unknown",
|
|
1092
|
+
urls: [],
|
|
1093
|
+
childSitemaps: [],
|
|
1094
|
+
issues: []
|
|
1095
|
+
};
|
|
1096
|
+
try {
|
|
1097
|
+
const response = await fetch(sitemapUrl, {
|
|
1098
|
+
headers: {
|
|
1099
|
+
Accept: "application/xml, text/xml, */*"
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
if (!response.ok) {
|
|
1103
|
+
analysis.issues.push({
|
|
1104
|
+
id: `sitemap-not-found-${hash3(sitemapUrl)}`,
|
|
1105
|
+
url: sitemapUrl,
|
|
1106
|
+
category: "technical",
|
|
1107
|
+
severity: "error",
|
|
1108
|
+
title: "Sitemap not accessible",
|
|
1109
|
+
description: `Sitemap returned HTTP ${response.status}.`,
|
|
1110
|
+
recommendation: "Ensure the sitemap URL is correct and accessible.",
|
|
1111
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1112
|
+
metadata: { statusCode: response.status }
|
|
1113
|
+
});
|
|
1114
|
+
return analysis;
|
|
1115
|
+
}
|
|
1116
|
+
analysis.exists = true;
|
|
1117
|
+
const content = await response.text();
|
|
1118
|
+
const contentType = response.headers.get("content-type") || "";
|
|
1119
|
+
if (!contentType.includes("xml") && !content.trim().startsWith("<?xml")) {
|
|
1120
|
+
analysis.issues.push({
|
|
1121
|
+
id: `sitemap-not-xml-${hash3(sitemapUrl)}`,
|
|
1122
|
+
url: sitemapUrl,
|
|
1123
|
+
category: "technical",
|
|
1124
|
+
severity: "warning",
|
|
1125
|
+
title: "Sitemap is not XML",
|
|
1126
|
+
description: "The sitemap does not have an XML content type.",
|
|
1127
|
+
recommendation: "Ensure sitemap is served with Content-Type: application/xml.",
|
|
1128
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1129
|
+
metadata: { contentType }
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
const $ = load(content, { xmlMode: true });
|
|
1133
|
+
const sitemapIndex = $("sitemapindex");
|
|
1134
|
+
if (sitemapIndex.length > 0) {
|
|
1135
|
+
analysis.type = "sitemap-index";
|
|
1136
|
+
$("sitemap").each((_, el) => {
|
|
1137
|
+
const loc = $("loc", el).text().trim();
|
|
1138
|
+
if (loc) {
|
|
1139
|
+
analysis.childSitemaps.push(loc);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
consola3.debug(`Sitemap index contains ${analysis.childSitemaps.length} sitemaps`);
|
|
1143
|
+
} else {
|
|
1144
|
+
analysis.type = "sitemap";
|
|
1145
|
+
$("url").each((_, el) => {
|
|
1146
|
+
const loc = $("loc", el).text().trim();
|
|
1147
|
+
if (loc) {
|
|
1148
|
+
analysis.urls.push(loc);
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
const lastmod = $("url lastmod").first().text().trim();
|
|
1152
|
+
if (lastmod) {
|
|
1153
|
+
analysis.lastmod = lastmod;
|
|
1154
|
+
}
|
|
1155
|
+
consola3.debug(`Sitemap contains ${analysis.urls.length} URLs`);
|
|
1156
|
+
}
|
|
1157
|
+
if (analysis.type === "sitemap" && analysis.urls.length === 0) {
|
|
1158
|
+
analysis.issues.push({
|
|
1159
|
+
id: `sitemap-empty-${hash3(sitemapUrl)}`,
|
|
1160
|
+
url: sitemapUrl,
|
|
1161
|
+
category: "technical",
|
|
1162
|
+
severity: "warning",
|
|
1163
|
+
title: "Sitemap is empty",
|
|
1164
|
+
description: "The sitemap contains no URLs.",
|
|
1165
|
+
recommendation: "Add URLs to your sitemap or remove it if not needed.",
|
|
1166
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
if (analysis.urls.length > 5e4) {
|
|
1170
|
+
analysis.issues.push({
|
|
1171
|
+
id: `sitemap-too-large-${hash3(sitemapUrl)}`,
|
|
1172
|
+
url: sitemapUrl,
|
|
1173
|
+
category: "technical",
|
|
1174
|
+
severity: "error",
|
|
1175
|
+
title: "Sitemap exceeds URL limit",
|
|
1176
|
+
description: `Sitemap contains ${analysis.urls.length} URLs. Maximum is 50,000.`,
|
|
1177
|
+
recommendation: "Split the sitemap into multiple files using a sitemap index.",
|
|
1178
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1179
|
+
metadata: { urlCount: analysis.urls.length }
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
const sizeInMB = new Blob([content]).size / (1024 * 1024);
|
|
1183
|
+
if (sizeInMB > 50) {
|
|
1184
|
+
analysis.issues.push({
|
|
1185
|
+
id: `sitemap-too-large-size-${hash3(sitemapUrl)}`,
|
|
1186
|
+
url: sitemapUrl,
|
|
1187
|
+
category: "technical",
|
|
1188
|
+
severity: "error",
|
|
1189
|
+
title: "Sitemap exceeds size limit",
|
|
1190
|
+
description: `Sitemap is ${sizeInMB.toFixed(2)}MB. Maximum is 50MB.`,
|
|
1191
|
+
recommendation: "Split the sitemap or compress it.",
|
|
1192
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1193
|
+
metadata: { sizeMB: sizeInMB }
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
consola3.error("Failed to analyze sitemap:", error);
|
|
1198
|
+
analysis.issues.push({
|
|
1199
|
+
id: `sitemap-error-${hash3(sitemapUrl)}`,
|
|
1200
|
+
url: sitemapUrl,
|
|
1201
|
+
category: "technical",
|
|
1202
|
+
severity: "error",
|
|
1203
|
+
title: "Failed to parse sitemap",
|
|
1204
|
+
description: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1205
|
+
recommendation: "Check sitemap validity using Google Search Console.",
|
|
1206
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
return analysis;
|
|
1210
|
+
}
|
|
1211
|
+
async function analyzeAllSitemaps(sitemapUrl, maxDepth = 3) {
|
|
1212
|
+
const results = [];
|
|
1213
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1214
|
+
async function analyze(url, depth) {
|
|
1215
|
+
if (depth > maxDepth || visited.has(url)) return;
|
|
1216
|
+
visited.add(url);
|
|
1217
|
+
const analysis = await analyzeSitemap(url);
|
|
1218
|
+
results.push(analysis);
|
|
1219
|
+
for (const childUrl of analysis.childSitemaps) {
|
|
1220
|
+
await analyze(childUrl, depth + 1);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
await analyze(sitemapUrl, 0);
|
|
1224
|
+
return results;
|
|
1225
|
+
}
|
|
1226
|
+
function hash3(str) {
|
|
1227
|
+
let hash5 = 0;
|
|
1228
|
+
for (let i = 0; i < str.length; i++) {
|
|
1229
|
+
const char = str.charCodeAt(i);
|
|
1230
|
+
hash5 = (hash5 << 5) - hash5 + char;
|
|
1231
|
+
hash5 = hash5 & hash5;
|
|
1232
|
+
}
|
|
1233
|
+
return Math.abs(hash5).toString(36);
|
|
1234
|
+
}
|
|
1235
|
+
var DEFAULT_SKIP_PATTERN = [
|
|
1236
|
+
"github.com",
|
|
1237
|
+
"twitter.com",
|
|
1238
|
+
"linkedin.com",
|
|
1239
|
+
"x.com",
|
|
1240
|
+
"127.0.0.1",
|
|
1241
|
+
"localhost:[0-9]+",
|
|
1242
|
+
"api\\.localhost",
|
|
1243
|
+
"demo\\.localhost",
|
|
1244
|
+
"cdn-cgi",
|
|
1245
|
+
// Cloudflare email protection
|
|
1246
|
+
"mailto:",
|
|
1247
|
+
// Email links
|
|
1248
|
+
"tel:",
|
|
1249
|
+
// Phone links
|
|
1250
|
+
"javascript:"
|
|
1251
|
+
// JavaScript links
|
|
1252
|
+
].join("|");
|
|
1253
|
+
function getSiteUrl2(options) {
|
|
1254
|
+
if (options.url) {
|
|
1255
|
+
return options.url;
|
|
1256
|
+
}
|
|
1257
|
+
const envUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || process.env.BASE_URL;
|
|
1258
|
+
if (envUrl) {
|
|
1259
|
+
return envUrl;
|
|
1260
|
+
}
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
"URL is required. Provide it via options.url or set NEXT_PUBLIC_SITE_URL environment variable."
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
function isExternalUrl(linkUrl, baseUrl) {
|
|
1266
|
+
try {
|
|
1267
|
+
const link = new URL(linkUrl);
|
|
1268
|
+
const base = new URL(baseUrl);
|
|
1269
|
+
return link.hostname !== base.hostname;
|
|
1270
|
+
} catch {
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
async function checkLinks(options) {
|
|
1275
|
+
const url = getSiteUrl2(options);
|
|
1276
|
+
const {
|
|
1277
|
+
timeout = 6e4,
|
|
1278
|
+
skipPattern = DEFAULT_SKIP_PATTERN,
|
|
1279
|
+
showOnlyBroken = true,
|
|
1280
|
+
concurrency = 50,
|
|
1281
|
+
outputFile,
|
|
1282
|
+
reportFormat = "text",
|
|
1283
|
+
verbose = true
|
|
1284
|
+
} = options;
|
|
1285
|
+
const startTime = Date.now();
|
|
1286
|
+
if (verbose) {
|
|
1287
|
+
console.log(chalk2.cyan(`
|
|
1288
|
+
\u{1F50D} Starting link check for: ${chalk2.bold(url)}`));
|
|
1289
|
+
console.log(chalk2.dim(` Timeout: ${timeout}ms | Concurrency: ${concurrency}`));
|
|
1290
|
+
console.log("");
|
|
1291
|
+
}
|
|
1292
|
+
const skipRegex = new RegExp(skipPattern);
|
|
1293
|
+
const checkOptions = {
|
|
1294
|
+
path: url,
|
|
1295
|
+
recurse: true,
|
|
1296
|
+
timeout,
|
|
1297
|
+
concurrency,
|
|
1298
|
+
linksToSkip: (link) => {
|
|
1299
|
+
return Promise.resolve(skipRegex.test(link));
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
const broken = [];
|
|
1303
|
+
const internalErrors = [];
|
|
1304
|
+
const externalErrors = [];
|
|
1305
|
+
let total = 0;
|
|
1306
|
+
try {
|
|
1307
|
+
const results = await linkinator.check(checkOptions);
|
|
1308
|
+
for (const result2 of results.links) {
|
|
1309
|
+
total++;
|
|
1310
|
+
const status = result2.status || 0;
|
|
1311
|
+
const isExternal = isExternalUrl(result2.url, url);
|
|
1312
|
+
if (status < 200 || status >= 400 || result2.state === "BROKEN") {
|
|
1313
|
+
const statusValue = status || "TIMEOUT";
|
|
1314
|
+
if (statusValue === "TIMEOUT" && isExternal) {
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
const brokenLink = {
|
|
1318
|
+
url: result2.url,
|
|
1319
|
+
status: statusValue,
|
|
1320
|
+
reason: result2.state === "BROKEN" ? "BROKEN" : void 0,
|
|
1321
|
+
isExternal,
|
|
1322
|
+
sourceUrl: result2.parent
|
|
1323
|
+
};
|
|
1324
|
+
broken.push(brokenLink);
|
|
1325
|
+
if (isExternal) {
|
|
1326
|
+
externalErrors.push(brokenLink);
|
|
1327
|
+
} else {
|
|
1328
|
+
internalErrors.push(brokenLink);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const success = internalErrors.length === 0;
|
|
1333
|
+
if (!showOnlyBroken || broken.length > 0) {
|
|
1334
|
+
if (success && externalErrors.length === 0) {
|
|
1335
|
+
console.log(`\u2705 All links are valid!`);
|
|
1336
|
+
console.log(` Checked ${total} links.`);
|
|
1337
|
+
} else {
|
|
1338
|
+
if (internalErrors.length > 0) {
|
|
1339
|
+
console.log(chalk2.red(`\u274C Found ${internalErrors.length} broken internal links:`));
|
|
1340
|
+
for (const { url: linkUrl, status, reason } of internalErrors.slice(0, 20)) {
|
|
1341
|
+
console.log(` [${status}] ${linkUrl}${reason ? ` (${reason})` : ""}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (internalErrors.length > 20) {
|
|
1344
|
+
console.log(chalk2.dim(` ... and ${internalErrors.length - 20} more`));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (externalErrors.length > 0) {
|
|
1348
|
+
console.log("");
|
|
1349
|
+
console.log(chalk2.yellow(`\u26A0\uFE0F Found ${externalErrors.length} broken external links:`));
|
|
1350
|
+
for (const { url: linkUrl, status } of externalErrors.slice(0, 10)) {
|
|
1351
|
+
console.log(` [${status}] ${linkUrl}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (externalErrors.length > 10) {
|
|
1354
|
+
console.log(chalk2.dim(` ... and ${externalErrors.length - 10} more`));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const duration = Date.now() - startTime;
|
|
1360
|
+
const result = {
|
|
1361
|
+
success,
|
|
1362
|
+
broken: broken.length,
|
|
1363
|
+
total,
|
|
1364
|
+
errors: broken,
|
|
1365
|
+
internalErrors,
|
|
1366
|
+
externalErrors,
|
|
1367
|
+
url,
|
|
1368
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1369
|
+
duration
|
|
1370
|
+
};
|
|
1371
|
+
if (outputFile) {
|
|
1372
|
+
await saveReport(result, outputFile, reportFormat);
|
|
1373
|
+
console.log(chalk2.green(`
|
|
1374
|
+
\u{1F4C4} Report saved to: ${chalk2.cyan(outputFile)}`));
|
|
1375
|
+
}
|
|
1376
|
+
return result;
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1379
|
+
const errorName = error instanceof Error ? error.name : "UnknownError";
|
|
1380
|
+
if (errorMessage.includes("timeout") || errorMessage.includes("TimeoutError") || errorName === "TimeoutError" || errorMessage.includes("aborted")) {
|
|
1381
|
+
console.warn(chalk2.yellow(`\u26A0\uFE0F Some links timed out after ${timeout}ms`));
|
|
1382
|
+
console.warn(chalk2.dim(` This is normal for slow or protected URLs.`));
|
|
1383
|
+
if (total > 0) {
|
|
1384
|
+
console.warn(chalk2.dim(` Checked ${total} links before timeout.`));
|
|
1385
|
+
}
|
|
1386
|
+
if (broken.length > 0) {
|
|
1387
|
+
console.log(chalk2.red(`
|
|
1388
|
+
\u274C Found ${broken.length} broken links:`));
|
|
1389
|
+
for (const { url: url2, status, reason } of broken) {
|
|
1390
|
+
const statusColor = typeof status === "number" && status >= 500 ? chalk2.red : chalk2.yellow;
|
|
1391
|
+
console.log(
|
|
1392
|
+
` ${statusColor(`[${status}]`)} ${chalk2.cyan(url2)}${reason ? chalk2.dim(` (${reason})`) : ""}`
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
} else {
|
|
1397
|
+
console.error(chalk2.red(`\u274C Error checking links: ${errorMessage}`));
|
|
1398
|
+
}
|
|
1399
|
+
const duration = Date.now() - startTime;
|
|
1400
|
+
const result = {
|
|
1401
|
+
success: internalErrors.length === 0 && total > 0,
|
|
1402
|
+
broken: broken.length,
|
|
1403
|
+
total,
|
|
1404
|
+
errors: broken,
|
|
1405
|
+
internalErrors,
|
|
1406
|
+
externalErrors,
|
|
1407
|
+
url,
|
|
1408
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1409
|
+
duration
|
|
1410
|
+
};
|
|
1411
|
+
if (outputFile) {
|
|
1412
|
+
try {
|
|
1413
|
+
await saveReport(result, outputFile, reportFormat);
|
|
1414
|
+
console.log(chalk2.green(`
|
|
1415
|
+
\u{1F4C4} Report saved to: ${chalk2.cyan(outputFile)}`));
|
|
1416
|
+
} catch (saveError) {
|
|
1417
|
+
console.warn(
|
|
1418
|
+
chalk2.yellow(
|
|
1419
|
+
`
|
|
1420
|
+
\u26A0\uFE0F Failed to save report: ${saveError instanceof Error ? saveError.message : String(saveError)}`
|
|
1421
|
+
)
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return result;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
function linkResultsToSeoIssues(result) {
|
|
1429
|
+
const issues = [];
|
|
1430
|
+
for (const error of result.internalErrors) {
|
|
1431
|
+
issues.push({
|
|
1432
|
+
id: `broken-internal-link-${hash4(error.url)}`,
|
|
1433
|
+
url: error.url,
|
|
1434
|
+
category: "technical",
|
|
1435
|
+
severity: typeof error.status === "number" && error.status >= 500 ? "critical" : "error",
|
|
1436
|
+
title: `Broken internal link: ${error.status}`,
|
|
1437
|
+
description: `Internal link returned ${error.status} status${error.reason ? ` (${error.reason})` : ""}.`,
|
|
1438
|
+
recommendation: "Fix the internal link. This affects user experience and SEO.",
|
|
1439
|
+
detectedAt: result.timestamp,
|
|
1440
|
+
metadata: {
|
|
1441
|
+
status: error.status,
|
|
1442
|
+
reason: error.reason,
|
|
1443
|
+
sourceUrl: error.sourceUrl || result.url,
|
|
1444
|
+
isExternal: false
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
for (const error of result.externalErrors) {
|
|
1449
|
+
issues.push({
|
|
1450
|
+
id: `broken-external-link-${hash4(error.url)}`,
|
|
1451
|
+
url: error.url,
|
|
1452
|
+
category: "technical",
|
|
1453
|
+
severity: "warning",
|
|
1454
|
+
title: `Broken external link: ${error.status}`,
|
|
1455
|
+
description: `External link returned ${error.status} status.`,
|
|
1456
|
+
recommendation: "Consider removing or updating the external link.",
|
|
1457
|
+
detectedAt: result.timestamp,
|
|
1458
|
+
metadata: {
|
|
1459
|
+
status: error.status,
|
|
1460
|
+
reason: error.reason,
|
|
1461
|
+
sourceUrl: error.sourceUrl || result.url,
|
|
1462
|
+
isExternal: true
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return issues;
|
|
1467
|
+
}
|
|
1468
|
+
async function saveReport(result, filePath, format) {
|
|
1469
|
+
const dir = dirname(filePath);
|
|
1470
|
+
if (dir !== ".") {
|
|
1471
|
+
await mkdir(dir, { recursive: true });
|
|
1472
|
+
}
|
|
1473
|
+
let content;
|
|
1474
|
+
switch (format) {
|
|
1475
|
+
case "json":
|
|
1476
|
+
content = JSON.stringify(result, null, 2);
|
|
1477
|
+
break;
|
|
1478
|
+
case "markdown":
|
|
1479
|
+
content = generateMarkdownReport(result);
|
|
1480
|
+
break;
|
|
1481
|
+
case "text":
|
|
1482
|
+
default:
|
|
1483
|
+
content = generateTextReport(result);
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
await writeFile(filePath, content, "utf-8");
|
|
1487
|
+
}
|
|
1488
|
+
function generateMarkdownReport(result) {
|
|
1489
|
+
const lines = [];
|
|
1490
|
+
lines.push("# Link Check Report");
|
|
1491
|
+
lines.push("");
|
|
1492
|
+
lines.push(`**URL:** ${result.url}`);
|
|
1493
|
+
lines.push(`**Timestamp:** ${result.timestamp}`);
|
|
1494
|
+
if (result.duration) {
|
|
1495
|
+
lines.push(`**Duration:** ${(result.duration / 1e3).toFixed(2)}s`);
|
|
1496
|
+
}
|
|
1497
|
+
lines.push("");
|
|
1498
|
+
lines.push(
|
|
1499
|
+
`**Status:** ${result.success ? "\u2705 All links valid" : "\u274C Broken links found"}`
|
|
1500
|
+
);
|
|
1501
|
+
lines.push(`**Total links:** ${result.total}`);
|
|
1502
|
+
lines.push(`**Broken links:** ${result.broken}`);
|
|
1503
|
+
lines.push("");
|
|
1504
|
+
if (result.errors.length > 0) {
|
|
1505
|
+
lines.push("## Broken Links");
|
|
1506
|
+
lines.push("");
|
|
1507
|
+
lines.push("| Status | URL | Reason |");
|
|
1508
|
+
lines.push("|--------|-----|--------|");
|
|
1509
|
+
for (const { url, status, reason } of result.errors) {
|
|
1510
|
+
lines.push(`| ${status} | ${url} | ${reason || "-"} |`);
|
|
1511
|
+
}
|
|
1512
|
+
lines.push("");
|
|
1513
|
+
}
|
|
1514
|
+
return lines.join("\n");
|
|
1515
|
+
}
|
|
1516
|
+
function generateTextReport(result) {
|
|
1517
|
+
const lines = [];
|
|
1518
|
+
lines.push("Link Check Report");
|
|
1519
|
+
lines.push("=".repeat(50));
|
|
1520
|
+
lines.push(`URL: ${result.url}`);
|
|
1521
|
+
lines.push(`Timestamp: ${result.timestamp}`);
|
|
1522
|
+
if (result.duration) {
|
|
1523
|
+
lines.push(`Duration: ${(result.duration / 1e3).toFixed(2)}s`);
|
|
1524
|
+
}
|
|
1525
|
+
lines.push("");
|
|
1526
|
+
lines.push(
|
|
1527
|
+
`Status: ${result.success ? "\u2705 All links valid" : "\u274C Broken links found"}`
|
|
1528
|
+
);
|
|
1529
|
+
lines.push(`Total links: ${result.total}`);
|
|
1530
|
+
lines.push(`Broken links: ${result.broken}`);
|
|
1531
|
+
lines.push("");
|
|
1532
|
+
if (result.errors.length > 0) {
|
|
1533
|
+
lines.push("Broken Links:");
|
|
1534
|
+
lines.push("-".repeat(50));
|
|
1535
|
+
for (const { url, status, reason } of result.errors) {
|
|
1536
|
+
lines.push(`[${status}] ${url}${reason ? ` (${reason})` : ""}`);
|
|
1537
|
+
}
|
|
1538
|
+
lines.push("");
|
|
1539
|
+
}
|
|
1540
|
+
return lines.join("\n");
|
|
1541
|
+
}
|
|
1542
|
+
function hash4(str) {
|
|
1543
|
+
let h = 0;
|
|
1544
|
+
for (let i = 0; i < str.length; i++) {
|
|
1545
|
+
const char = str.charCodeAt(i);
|
|
1546
|
+
h = (h << 5) - h + char;
|
|
1547
|
+
h = h & h;
|
|
1548
|
+
}
|
|
1549
|
+
return Math.abs(h).toString(36);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/reports/json-report.ts
|
|
1553
|
+
function generateJsonReport(siteUrl, data, options = {}) {
|
|
1554
|
+
const { issues, urlInspections = [], crawlResults = [] } = data;
|
|
1555
|
+
const maxUrlsPerIssue = options.maxUrlsPerIssue ?? 10;
|
|
1556
|
+
const limitedIssues = limitIssuesByTitle(issues, maxUrlsPerIssue);
|
|
1557
|
+
const report = {
|
|
1558
|
+
id: generateReportId(),
|
|
1559
|
+
siteUrl,
|
|
1560
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1561
|
+
summary: generateSummary(issues, urlInspections, crawlResults),
|
|
1562
|
+
// Use original for accurate counts
|
|
1563
|
+
issues: sortIssues(limitedIssues),
|
|
1564
|
+
urlInspections: options.includeRawData ? urlInspections.slice(0, 100) : [],
|
|
1565
|
+
crawlResults: options.includeRawData ? crawlResults.slice(0, 100) : [],
|
|
1566
|
+
recommendations: generateRecommendations(issues, maxUrlsPerIssue)
|
|
1567
|
+
};
|
|
1568
|
+
return report;
|
|
1569
|
+
}
|
|
1570
|
+
function limitIssuesByTitle(issues, maxUrls) {
|
|
1571
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
1572
|
+
for (const issue of issues) {
|
|
1573
|
+
const existing = byTitle.get(issue.title) || [];
|
|
1574
|
+
existing.push(issue);
|
|
1575
|
+
byTitle.set(issue.title, existing);
|
|
1576
|
+
}
|
|
1577
|
+
const limited = [];
|
|
1578
|
+
for (const [, group] of byTitle) {
|
|
1579
|
+
const sorted = group.sort((a, b) => {
|
|
1580
|
+
const severityOrder2 = { critical: 0, error: 1, warning: 2, info: 3 };
|
|
1581
|
+
return severityOrder2[a.severity] - severityOrder2[b.severity];
|
|
1582
|
+
});
|
|
1583
|
+
limited.push(...sorted.slice(0, maxUrls));
|
|
1584
|
+
}
|
|
1585
|
+
return limited;
|
|
1586
|
+
}
|
|
1587
|
+
function generateSummary(issues, urlInspections, crawlResults) {
|
|
1588
|
+
const totalUrls = Math.max(
|
|
1589
|
+
urlInspections.length,
|
|
1590
|
+
crawlResults.length,
|
|
1591
|
+
new Set(issues.map((i) => i.url)).size
|
|
1592
|
+
);
|
|
1593
|
+
const indexedUrls = urlInspections.filter(
|
|
1594
|
+
(r) => r.indexStatusResult.coverageState === "SUBMITTED_AND_INDEXED"
|
|
1595
|
+
).length;
|
|
1596
|
+
const notIndexedUrls = urlInspections.filter(
|
|
1597
|
+
(r) => r.indexStatusResult.coverageState === "NOT_INDEXED" || r.indexStatusResult.coverageState === "CRAWLED_CURRENTLY_NOT_INDEXED" || r.indexStatusResult.coverageState === "DISCOVERED_CURRENTLY_NOT_INDEXED"
|
|
1598
|
+
).length;
|
|
1599
|
+
const issuesByCategory = issues.reduce(
|
|
1600
|
+
(acc, issue) => {
|
|
1601
|
+
acc[issue.category] = (acc[issue.category] || 0) + 1;
|
|
1602
|
+
return acc;
|
|
1603
|
+
},
|
|
1604
|
+
{}
|
|
1605
|
+
);
|
|
1606
|
+
const issuesBySeverity = issues.reduce(
|
|
1607
|
+
(acc, issue) => {
|
|
1608
|
+
acc[issue.severity] = (acc[issue.severity] || 0) + 1;
|
|
1609
|
+
return acc;
|
|
1610
|
+
},
|
|
1611
|
+
{}
|
|
1612
|
+
);
|
|
1613
|
+
const healthScore = calculateHealthScore(issues, totalUrls);
|
|
1614
|
+
return {
|
|
1615
|
+
totalUrls,
|
|
1616
|
+
indexedUrls,
|
|
1617
|
+
notIndexedUrls,
|
|
1618
|
+
issuesByCategory,
|
|
1619
|
+
issuesBySeverity,
|
|
1620
|
+
healthScore
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function calculateHealthScore(issues, totalUrls) {
|
|
1624
|
+
if (totalUrls === 0) return 100;
|
|
1625
|
+
const severityWeights = {
|
|
1626
|
+
critical: 10,
|
|
1627
|
+
error: 5,
|
|
1628
|
+
warning: 2,
|
|
1629
|
+
info: 0.5
|
|
1630
|
+
};
|
|
1631
|
+
const totalPenalty = issues.reduce(
|
|
1632
|
+
(sum, issue) => sum + severityWeights[issue.severity],
|
|
1633
|
+
0
|
|
1634
|
+
);
|
|
1635
|
+
const maxPenalty = totalUrls * 20;
|
|
1636
|
+
const penaltyRatio = Math.min(totalPenalty / maxPenalty, 1);
|
|
1637
|
+
return Math.round((1 - penaltyRatio) * 100);
|
|
1638
|
+
}
|
|
1639
|
+
function generateRecommendations(issues, maxUrls = 10) {
|
|
1640
|
+
const recommendations = [];
|
|
1641
|
+
const issueGroups = /* @__PURE__ */ new Map();
|
|
1642
|
+
for (const issue of issues) {
|
|
1643
|
+
const key = `${issue.category}:${issue.title}`;
|
|
1644
|
+
if (!issueGroups.has(key)) {
|
|
1645
|
+
issueGroups.set(key, []);
|
|
1646
|
+
}
|
|
1647
|
+
issueGroups.get(key).push(issue);
|
|
1648
|
+
}
|
|
1649
|
+
for (const [, groupedIssues] of issueGroups) {
|
|
1650
|
+
const firstIssue = groupedIssues[0];
|
|
1651
|
+
if (!firstIssue) continue;
|
|
1652
|
+
const severity = firstIssue.severity;
|
|
1653
|
+
const priority = severity === "critical" ? 1 : severity === "error" ? 2 : severity === "warning" ? 3 : 4;
|
|
1654
|
+
const impact = priority <= 2 ? "high" : priority === 3 ? "medium" : "low";
|
|
1655
|
+
const allUrls = groupedIssues.map((i) => i.url);
|
|
1656
|
+
const totalCount = allUrls.length;
|
|
1657
|
+
const limitedUrls = allUrls.slice(0, maxUrls);
|
|
1658
|
+
recommendations.push({
|
|
1659
|
+
priority,
|
|
1660
|
+
category: firstIssue.category,
|
|
1661
|
+
title: firstIssue.title,
|
|
1662
|
+
description: totalCount > maxUrls ? `${firstIssue.description} (showing ${maxUrls} of ${totalCount} URLs)` : firstIssue.description,
|
|
1663
|
+
affectedUrls: limitedUrls,
|
|
1664
|
+
estimatedImpact: impact,
|
|
1665
|
+
actionItems: [firstIssue.recommendation]
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
return recommendations.sort((a, b) => a.priority - b.priority);
|
|
1669
|
+
}
|
|
1670
|
+
function sortIssues(issues) {
|
|
1671
|
+
const severityOrder2 = {
|
|
1672
|
+
critical: 0,
|
|
1673
|
+
error: 1,
|
|
1674
|
+
warning: 2,
|
|
1675
|
+
info: 3
|
|
1676
|
+
};
|
|
1677
|
+
return [...issues].sort((a, b) => {
|
|
1678
|
+
const severityDiff = severityOrder2[a.severity] - severityOrder2[b.severity];
|
|
1679
|
+
if (severityDiff !== 0) return severityDiff;
|
|
1680
|
+
return a.category.localeCompare(b.category);
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
function generateReportId() {
|
|
1684
|
+
const timestamp = Date.now().toString(36);
|
|
1685
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
1686
|
+
return `seo-report-${timestamp}-${random}`;
|
|
1687
|
+
}
|
|
1688
|
+
function exportJsonReport(report, pretty = true) {
|
|
1689
|
+
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/reports/markdown-report.ts
|
|
1693
|
+
function generateMarkdownReport2(report, options = {}) {
|
|
1694
|
+
const { includeRawIssues = true, includeUrls = true, maxUrlsPerIssue = 10 } = options;
|
|
1695
|
+
const lines = [];
|
|
1696
|
+
lines.push(`# SEO Analysis Report`);
|
|
1697
|
+
lines.push("");
|
|
1698
|
+
lines.push(`**Site:** ${report.siteUrl}`);
|
|
1699
|
+
lines.push(`**Generated:** ${new Date(report.generatedAt).toLocaleString()}`);
|
|
1700
|
+
lines.push(`**Report ID:** ${report.id}`);
|
|
1701
|
+
lines.push("");
|
|
1702
|
+
lines.push("## Summary");
|
|
1703
|
+
lines.push("");
|
|
1704
|
+
lines.push(`| Metric | Value |`);
|
|
1705
|
+
lines.push(`|--------|-------|`);
|
|
1706
|
+
lines.push(`| Health Score | ${getHealthScoreEmoji(report.summary.healthScore)} **${report.summary.healthScore}/100** |`);
|
|
1707
|
+
lines.push(`| Total URLs | ${report.summary.totalUrls} |`);
|
|
1708
|
+
lines.push(`| Indexed URLs | ${report.summary.indexedUrls} |`);
|
|
1709
|
+
lines.push(`| Not Indexed | ${report.summary.notIndexedUrls} |`);
|
|
1710
|
+
lines.push("");
|
|
1711
|
+
lines.push("### Issues by Severity");
|
|
1712
|
+
lines.push("");
|
|
1713
|
+
const severities = ["critical", "error", "warning", "info"];
|
|
1714
|
+
for (const severity of severities) {
|
|
1715
|
+
const count = report.summary.issuesBySeverity[severity] || 0;
|
|
1716
|
+
lines.push(`- ${getSeverityEmoji(severity)} **${capitalize(severity)}:** ${count}`);
|
|
1717
|
+
}
|
|
1718
|
+
lines.push("");
|
|
1719
|
+
lines.push("### Issues by Category");
|
|
1720
|
+
lines.push("");
|
|
1721
|
+
const categories = Object.entries(report.summary.issuesByCategory).sort(
|
|
1722
|
+
([, a], [, b]) => b - a
|
|
1723
|
+
);
|
|
1724
|
+
for (const [category, count] of categories) {
|
|
1725
|
+
lines.push(`- ${getCategoryEmoji(category)} **${formatCategory(category)}:** ${count}`);
|
|
1726
|
+
}
|
|
1727
|
+
lines.push("");
|
|
1728
|
+
lines.push("## Prioritized Recommendations");
|
|
1729
|
+
lines.push("");
|
|
1730
|
+
for (const rec of report.recommendations) {
|
|
1731
|
+
lines.push(`### ${getPriorityEmoji(rec.priority)} Priority ${rec.priority}: ${rec.title}`);
|
|
1732
|
+
lines.push("");
|
|
1733
|
+
lines.push(`**Category:** ${formatCategory(rec.category)}`);
|
|
1734
|
+
lines.push(`**Impact:** ${capitalize(rec.estimatedImpact)}`);
|
|
1735
|
+
lines.push(`**Affected URLs:** ${rec.affectedUrls.length}`);
|
|
1736
|
+
lines.push("");
|
|
1737
|
+
lines.push(`${rec.description}`);
|
|
1738
|
+
lines.push("");
|
|
1739
|
+
lines.push("**Action Items:**");
|
|
1740
|
+
for (const action of rec.actionItems) {
|
|
1741
|
+
lines.push(`- ${action}`);
|
|
1742
|
+
}
|
|
1743
|
+
lines.push("");
|
|
1744
|
+
if (includeUrls && rec.affectedUrls.length > 0) {
|
|
1745
|
+
const urlsToShow = rec.affectedUrls.slice(0, maxUrlsPerIssue);
|
|
1746
|
+
lines.push("<details>");
|
|
1747
|
+
lines.push(`<summary>Affected URLs (${rec.affectedUrls.length})</summary>`);
|
|
1748
|
+
lines.push("");
|
|
1749
|
+
for (const url of urlsToShow) {
|
|
1750
|
+
lines.push(`- ${url}`);
|
|
1751
|
+
}
|
|
1752
|
+
if (rec.affectedUrls.length > maxUrlsPerIssue) {
|
|
1753
|
+
lines.push(`- ... and ${rec.affectedUrls.length - maxUrlsPerIssue} more`);
|
|
1754
|
+
}
|
|
1755
|
+
lines.push("</details>");
|
|
1756
|
+
lines.push("");
|
|
1757
|
+
}
|
|
1758
|
+
lines.push("---");
|
|
1759
|
+
lines.push("");
|
|
1760
|
+
}
|
|
1761
|
+
if (includeRawIssues) {
|
|
1762
|
+
lines.push("## All Issues");
|
|
1763
|
+
lines.push("");
|
|
1764
|
+
const issuesByCategory = groupBy(report.issues, "category");
|
|
1765
|
+
for (const [category, issues] of Object.entries(issuesByCategory)) {
|
|
1766
|
+
lines.push(`### ${getCategoryEmoji(category)} ${formatCategory(category)}`);
|
|
1767
|
+
lines.push("");
|
|
1768
|
+
for (const issue of issues) {
|
|
1769
|
+
lines.push(
|
|
1770
|
+
`#### ${getSeverityEmoji(issue.severity)} ${issue.title}`
|
|
1771
|
+
);
|
|
1772
|
+
lines.push("");
|
|
1773
|
+
lines.push(`**URL:** \`${issue.url}\``);
|
|
1774
|
+
lines.push(`**Severity:** ${capitalize(issue.severity)}`);
|
|
1775
|
+
lines.push("");
|
|
1776
|
+
lines.push(issue.description);
|
|
1777
|
+
lines.push("");
|
|
1778
|
+
lines.push(`**Recommendation:** ${issue.recommendation}`);
|
|
1779
|
+
lines.push("");
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
lines.push("---");
|
|
1784
|
+
lines.push("");
|
|
1785
|
+
lines.push("*Report generated by [@djangocfg/seo](https://djangocfg.com)*");
|
|
1786
|
+
lines.push("");
|
|
1787
|
+
lines.push("> This report is designed to be processed by AI assistants for automated SEO improvements.");
|
|
1788
|
+
return lines.join("\n");
|
|
1789
|
+
}
|
|
1790
|
+
function generateAiSummary(report) {
|
|
1791
|
+
const lines = [];
|
|
1792
|
+
lines.push("# SEO Report Summary for AI Processing");
|
|
1793
|
+
lines.push("");
|
|
1794
|
+
lines.push("## Context");
|
|
1795
|
+
lines.push(`Site: ${report.siteUrl}`);
|
|
1796
|
+
lines.push(`Health Score: ${report.summary.healthScore}/100`);
|
|
1797
|
+
lines.push(`Critical Issues: ${report.summary.issuesBySeverity.critical || 0}`);
|
|
1798
|
+
lines.push(`Errors: ${report.summary.issuesBySeverity.error || 0}`);
|
|
1799
|
+
lines.push(`Warnings: ${report.summary.issuesBySeverity.warning || 0}`);
|
|
1800
|
+
lines.push("");
|
|
1801
|
+
lines.push("## Top Priority Actions");
|
|
1802
|
+
lines.push("");
|
|
1803
|
+
const topRecommendations = report.recommendations.slice(0, 5);
|
|
1804
|
+
for (let i = 0; i < topRecommendations.length; i++) {
|
|
1805
|
+
const rec = topRecommendations[i];
|
|
1806
|
+
if (!rec) continue;
|
|
1807
|
+
lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length} URLs)`);
|
|
1808
|
+
lines.push(` - ${rec.actionItems[0]}`);
|
|
1809
|
+
}
|
|
1810
|
+
lines.push("");
|
|
1811
|
+
lines.push("## Issue Categories");
|
|
1812
|
+
lines.push("");
|
|
1813
|
+
const sortedCategories = Object.entries(report.summary.issuesByCategory).sort(([, a], [, b]) => b - a);
|
|
1814
|
+
for (const [category, count] of sortedCategories) {
|
|
1815
|
+
lines.push(`- ${formatCategory(category)}: ${count} issues`);
|
|
1816
|
+
}
|
|
1817
|
+
return lines.join("\n");
|
|
1818
|
+
}
|
|
1819
|
+
function getSeverityEmoji(severity) {
|
|
1820
|
+
const emojis = {
|
|
1821
|
+
critical: "\u{1F534}",
|
|
1822
|
+
error: "\u{1F7E0}",
|
|
1823
|
+
warning: "\u{1F7E1}",
|
|
1824
|
+
info: "\u{1F535}"
|
|
1825
|
+
};
|
|
1826
|
+
return emojis[severity];
|
|
1827
|
+
}
|
|
1828
|
+
function getCategoryEmoji(category) {
|
|
1829
|
+
const emojis = {
|
|
1830
|
+
indexing: "\u{1F4D1}",
|
|
1831
|
+
crawling: "\u{1F577}\uFE0F",
|
|
1832
|
+
content: "\u{1F4DD}",
|
|
1833
|
+
technical: "\u2699\uFE0F",
|
|
1834
|
+
mobile: "\u{1F4F1}",
|
|
1835
|
+
performance: "\u26A1",
|
|
1836
|
+
"structured-data": "\u{1F3F7}\uFE0F",
|
|
1837
|
+
security: "\u{1F512}"
|
|
1838
|
+
};
|
|
1839
|
+
return emojis[category] || "\u{1F4CB}";
|
|
1840
|
+
}
|
|
1841
|
+
function getPriorityEmoji(priority) {
|
|
1842
|
+
const emojis = {
|
|
1843
|
+
1: "\u{1F6A8}",
|
|
1844
|
+
2: "\u26A0\uFE0F",
|
|
1845
|
+
3: "\u{1F4CC}",
|
|
1846
|
+
4: "\u{1F4A1}",
|
|
1847
|
+
5: "\u2139\uFE0F"
|
|
1848
|
+
};
|
|
1849
|
+
return emojis[priority] || "\u{1F4CB}";
|
|
1850
|
+
}
|
|
1851
|
+
function getHealthScoreEmoji(score) {
|
|
1852
|
+
if (score >= 90) return "\u{1F7E2}";
|
|
1853
|
+
if (score >= 70) return "\u{1F7E1}";
|
|
1854
|
+
if (score >= 50) return "\u{1F7E0}";
|
|
1855
|
+
return "\u{1F534}";
|
|
1856
|
+
}
|
|
1857
|
+
function capitalize(str) {
|
|
1858
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1859
|
+
}
|
|
1860
|
+
function formatCategory(category) {
|
|
1861
|
+
return category.split("-").map(capitalize).join(" ");
|
|
1862
|
+
}
|
|
1863
|
+
function groupBy(array, key) {
|
|
1864
|
+
return array.reduce(
|
|
1865
|
+
(acc, item) => {
|
|
1866
|
+
const groupKey = String(item[key]);
|
|
1867
|
+
if (!acc[groupKey]) {
|
|
1868
|
+
acc[groupKey] = [];
|
|
1869
|
+
}
|
|
1870
|
+
acc[groupKey].push(item);
|
|
1871
|
+
return acc;
|
|
1872
|
+
},
|
|
1873
|
+
{}
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
var MAX_LINES = 1e3;
|
|
1877
|
+
function generateSplitReports(report, options) {
|
|
1878
|
+
const { outputDir, clearOutputDir = true } = options;
|
|
1879
|
+
if (clearOutputDir && existsSync(outputDir)) {
|
|
1880
|
+
const files = readdirSync(outputDir);
|
|
1881
|
+
for (const file of files) {
|
|
1882
|
+
if (file.startsWith("seo-") && file.endsWith(".md")) {
|
|
1883
|
+
rmSync(join(outputDir, file), { force: true });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (!existsSync(outputDir)) {
|
|
1888
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1889
|
+
}
|
|
1890
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1891
|
+
const siteName = new URL(report.siteUrl).hostname.replace(/\./g, "-");
|
|
1892
|
+
const prefix = `seo-${siteName}-${timestamp}`;
|
|
1893
|
+
const categoryFiles = [];
|
|
1894
|
+
const issuesByCategory = groupIssuesByCategory(report.issues);
|
|
1895
|
+
const categories = Object.keys(issuesByCategory);
|
|
1896
|
+
for (const category of categories) {
|
|
1897
|
+
const issues = issuesByCategory[category] || [];
|
|
1898
|
+
if (issues.length === 0) continue;
|
|
1899
|
+
const chunks = splitIntoChunks(issues);
|
|
1900
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
1901
|
+
const suffix = chunks.length > 1 ? `-${i + 1}` : "";
|
|
1902
|
+
const filename = `${prefix}-${category}${suffix}.md`;
|
|
1903
|
+
const filepath = join(outputDir, filename);
|
|
1904
|
+
const chunk = chunks[i];
|
|
1905
|
+
if (!chunk) continue;
|
|
1906
|
+
const content = generateCategoryFile(report.siteUrl, category, chunk, {
|
|
1907
|
+
part: chunks.length > 1 ? i + 1 : void 0,
|
|
1908
|
+
totalParts: chunks.length > 1 ? chunks.length : void 0
|
|
1909
|
+
});
|
|
1910
|
+
writeFileSync(filepath, content, "utf-8");
|
|
1911
|
+
categoryFiles.push(filename);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
const indexFilename = `${prefix}-index.md`;
|
|
1915
|
+
const indexFilepath = join(outputDir, indexFilename);
|
|
1916
|
+
const indexContent = generateIndexFile(report, categoryFiles);
|
|
1917
|
+
writeFileSync(indexFilepath, indexContent, "utf-8");
|
|
1918
|
+
return {
|
|
1919
|
+
indexFile: indexFilename,
|
|
1920
|
+
categoryFiles,
|
|
1921
|
+
totalFiles: categoryFiles.length + 1
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
function generateIndexFile(report, categoryFiles) {
|
|
1925
|
+
const lines = [];
|
|
1926
|
+
lines.push("# SEO Report Index");
|
|
1927
|
+
lines.push("");
|
|
1928
|
+
lines.push(`Site: ${report.siteUrl}`);
|
|
1929
|
+
lines.push(`Score: ${report.summary.healthScore}/100`);
|
|
1930
|
+
lines.push(`Date: ${report.generatedAt.slice(0, 10)}`);
|
|
1931
|
+
lines.push("");
|
|
1932
|
+
lines.push("## Issues");
|
|
1933
|
+
lines.push("");
|
|
1934
|
+
lines.push("| Severity | Count |");
|
|
1935
|
+
lines.push("|----------|-------|");
|
|
1936
|
+
const severities = ["critical", "error", "warning", "info"];
|
|
1937
|
+
for (const sev of severities) {
|
|
1938
|
+
const count = report.summary.issuesBySeverity[sev] || 0;
|
|
1939
|
+
if (count > 0) {
|
|
1940
|
+
lines.push(`| ${sev} | ${count} |`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
lines.push("");
|
|
1944
|
+
lines.push("## Actions");
|
|
1945
|
+
lines.push("");
|
|
1946
|
+
const topRecs = report.recommendations.slice(0, 10);
|
|
1947
|
+
for (let i = 0; i < topRecs.length; i++) {
|
|
1948
|
+
const rec = topRecs[i];
|
|
1949
|
+
if (!rec) continue;
|
|
1950
|
+
lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length})`);
|
|
1951
|
+
lines.push(` ${rec.actionItems[0]}`);
|
|
1952
|
+
}
|
|
1953
|
+
lines.push("");
|
|
1954
|
+
lines.push("## Files");
|
|
1955
|
+
lines.push("");
|
|
1956
|
+
for (const file of categoryFiles) {
|
|
1957
|
+
lines.push(`- [${file}](./${file})`);
|
|
1958
|
+
}
|
|
1959
|
+
return lines.join("\n");
|
|
1960
|
+
}
|
|
1961
|
+
function generateCategoryFile(siteUrl, category, issues, opts) {
|
|
1962
|
+
const lines = [];
|
|
1963
|
+
const partStr = opts.part ? ` (Part ${opts.part}/${opts.totalParts})` : "";
|
|
1964
|
+
lines.push(`# ${formatCategory2(category)}${partStr}`);
|
|
1965
|
+
lines.push("");
|
|
1966
|
+
lines.push(`Site: ${siteUrl}`);
|
|
1967
|
+
lines.push(`Issues: ${issues.length}`);
|
|
1968
|
+
lines.push("");
|
|
1969
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
1970
|
+
for (const issue of issues) {
|
|
1971
|
+
const group = byTitle.get(issue.title) || [];
|
|
1972
|
+
group.push(issue);
|
|
1973
|
+
byTitle.set(issue.title, group);
|
|
1974
|
+
}
|
|
1975
|
+
for (const [title, groupIssues] of byTitle) {
|
|
1976
|
+
const first = groupIssues[0];
|
|
1977
|
+
if (!first) continue;
|
|
1978
|
+
lines.push(`## ${title}`);
|
|
1979
|
+
lines.push("");
|
|
1980
|
+
lines.push(`Severity: ${first.severity}`);
|
|
1981
|
+
lines.push(`Count: ${groupIssues.length}`);
|
|
1982
|
+
lines.push("");
|
|
1983
|
+
lines.push(`> ${first.recommendation}`);
|
|
1984
|
+
lines.push("");
|
|
1985
|
+
lines.push("URLs:");
|
|
1986
|
+
for (const issue of groupIssues.slice(0, 20)) {
|
|
1987
|
+
lines.push(`- ${issue.url}`);
|
|
1988
|
+
}
|
|
1989
|
+
if (groupIssues.length > 20) {
|
|
1990
|
+
lines.push(`- ... +${groupIssues.length - 20} more`);
|
|
1991
|
+
}
|
|
1992
|
+
lines.push("");
|
|
1993
|
+
}
|
|
1994
|
+
return lines.join("\n");
|
|
1995
|
+
}
|
|
1996
|
+
function splitIntoChunks(issues, category) {
|
|
1997
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
1998
|
+
for (const issue of issues) {
|
|
1999
|
+
const group = byTitle.get(issue.title) || [];
|
|
2000
|
+
group.push(issue);
|
|
2001
|
+
byTitle.set(issue.title, group);
|
|
2002
|
+
}
|
|
2003
|
+
const chunks = [];
|
|
2004
|
+
let currentChunk = [];
|
|
2005
|
+
let currentLines = 10;
|
|
2006
|
+
for (const [, groupIssues] of byTitle) {
|
|
2007
|
+
const urlCount = Math.min(20, groupIssues.length);
|
|
2008
|
+
const groupLines = 8 + urlCount;
|
|
2009
|
+
if (currentLines + groupLines > MAX_LINES && currentChunk.length > 0) {
|
|
2010
|
+
chunks.push(currentChunk);
|
|
2011
|
+
currentChunk = [];
|
|
2012
|
+
currentLines = 10;
|
|
2013
|
+
}
|
|
2014
|
+
currentChunk.push(...groupIssues);
|
|
2015
|
+
currentLines += groupLines;
|
|
2016
|
+
}
|
|
2017
|
+
if (currentChunk.length > 0) {
|
|
2018
|
+
chunks.push(currentChunk);
|
|
2019
|
+
}
|
|
2020
|
+
return chunks.length > 0 ? chunks : [[]];
|
|
2021
|
+
}
|
|
2022
|
+
function groupIssuesByCategory(issues) {
|
|
2023
|
+
const result = {};
|
|
2024
|
+
for (const issue of issues) {
|
|
2025
|
+
if (!result[issue.category]) {
|
|
2026
|
+
result[issue.category] = [];
|
|
2027
|
+
}
|
|
2028
|
+
result[issue.category].push(issue);
|
|
2029
|
+
}
|
|
2030
|
+
return result;
|
|
2031
|
+
}
|
|
2032
|
+
function formatCategory2(category) {
|
|
2033
|
+
return category.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// src/reports/claude-context.ts
|
|
2037
|
+
function generateClaudeContext(report) {
|
|
2038
|
+
const lines = [];
|
|
2039
|
+
lines.push("# @djangocfg/seo");
|
|
2040
|
+
lines.push("");
|
|
2041
|
+
lines.push("SEO audit toolkit. Generates AI-optimized split reports (max 1000 lines each).");
|
|
2042
|
+
lines.push("");
|
|
2043
|
+
lines.push("## Commands");
|
|
2044
|
+
lines.push("");
|
|
2045
|
+
lines.push("```bash");
|
|
2046
|
+
lines.push("# Audit (HTTP-based, crawls live site)");
|
|
2047
|
+
lines.push("pnpm seo:audit # Full audit (split reports)");
|
|
2048
|
+
lines.push("pnpm seo:audit --env dev # Audit local dev");
|
|
2049
|
+
lines.push("pnpm seo:audit --format all # All formats");
|
|
2050
|
+
lines.push("");
|
|
2051
|
+
lines.push("# Content (file-based, scans MDX/content/)");
|
|
2052
|
+
lines.push("pnpm exec djangocfg-seo content check # Check MDX links");
|
|
2053
|
+
lines.push("pnpm exec djangocfg-seo content fix # Show fixable links");
|
|
2054
|
+
lines.push("pnpm exec djangocfg-seo content fix --fix # Apply fixes");
|
|
2055
|
+
lines.push("pnpm exec djangocfg-seo content sitemap # Generate sitemap.ts");
|
|
2056
|
+
lines.push("```");
|
|
2057
|
+
lines.push("");
|
|
2058
|
+
lines.push("## Options");
|
|
2059
|
+
lines.push("");
|
|
2060
|
+
lines.push("- `--env, -e` - prod (default) or dev");
|
|
2061
|
+
lines.push("- `--site, -s` - Site URL (overrides env)");
|
|
2062
|
+
lines.push("- `--output, -o` - Output directory");
|
|
2063
|
+
lines.push("- `--format, -f` - split (default), json, markdown, ai-summary, all");
|
|
2064
|
+
lines.push("- `--max-pages` - Max pages (default: 100)");
|
|
2065
|
+
lines.push("- `--service-account` - Google service account JSON path");
|
|
2066
|
+
lines.push("- `--content-dir` - Content directory (default: content/)");
|
|
2067
|
+
lines.push("- `--base-path` - Base URL path for docs (default: /docs)");
|
|
2068
|
+
lines.push("");
|
|
2069
|
+
lines.push("## Reports");
|
|
2070
|
+
lines.push("");
|
|
2071
|
+
lines.push("- `seo-*-index.md` - Summary + links to categories");
|
|
2072
|
+
lines.push("- `seo-*-technical.md` - Broken links, sitemap issues");
|
|
2073
|
+
lines.push("- `seo-*-content.md` - H1, meta, title issues");
|
|
2074
|
+
lines.push("- `seo-*-performance.md` - Load time, TTFB issues");
|
|
2075
|
+
lines.push("- `seo-ai-summary-*.md` - Quick overview");
|
|
2076
|
+
lines.push("");
|
|
2077
|
+
lines.push("## Issue Severity");
|
|
2078
|
+
lines.push("");
|
|
2079
|
+
lines.push("- **critical** - Blocks indexing (fix immediately)");
|
|
2080
|
+
lines.push("- **error** - SEO problems (high priority)");
|
|
2081
|
+
lines.push("- **warning** - Recommendations (medium priority)");
|
|
2082
|
+
lines.push("- **info** - Best practices (low priority)");
|
|
2083
|
+
lines.push("");
|
|
2084
|
+
lines.push("## Issue Categories");
|
|
2085
|
+
lines.push("");
|
|
2086
|
+
lines.push("- **technical** - Broken links, sitemap, robots.txt");
|
|
2087
|
+
lines.push("- **content** - Missing H1, meta description, title");
|
|
2088
|
+
lines.push("- **indexing** - Not indexed, crawl errors from GSC");
|
|
2089
|
+
lines.push("- **performance** - Slow load time (>3s), high TTFB (>800ms)");
|
|
2090
|
+
lines.push("");
|
|
2091
|
+
lines.push("## Routes Scanner");
|
|
2092
|
+
lines.push("");
|
|
2093
|
+
lines.push("Scans Next.js App Router `app/` directory. Handles:");
|
|
2094
|
+
lines.push("- Route groups `(group)` - ignored in URL");
|
|
2095
|
+
lines.push("- Dynamic `[slug]` - shown as `:slug`");
|
|
2096
|
+
lines.push("- Catch-all `[...slug]` - shown as `:...slug`");
|
|
2097
|
+
lines.push("- Parallel `@folder` - skipped");
|
|
2098
|
+
lines.push("- Private `_folder` - skipped");
|
|
2099
|
+
lines.push("");
|
|
2100
|
+
lines.push("## Link Guidelines");
|
|
2101
|
+
lines.push("");
|
|
2102
|
+
lines.push("### Nextra/MDX Projects (content/)");
|
|
2103
|
+
lines.push("");
|
|
2104
|
+
lines.push("For non-index files (e.g., `overview.mdx`):");
|
|
2105
|
+
lines.push("- **Sibling file**: `../sibling` (one level up)");
|
|
2106
|
+
lines.push("- **Other section**: `/docs/full/path` (absolute)");
|
|
2107
|
+
lines.push("- **AVOID**: `./sibling` (browser adds filename to path!)");
|
|
2108
|
+
lines.push("- **AVOID**: `../../deep/path` (hard to maintain)");
|
|
2109
|
+
lines.push("");
|
|
2110
|
+
lines.push("For index files (e.g., `index.mdx`):");
|
|
2111
|
+
lines.push("- **Child file**: `./child` works correctly");
|
|
2112
|
+
lines.push("- **Sibling folder**: `../sibling/` or absolute");
|
|
2113
|
+
lines.push("");
|
|
2114
|
+
lines.push("### Next.js App Router Projects");
|
|
2115
|
+
lines.push("");
|
|
2116
|
+
lines.push("Use declarative routes from `_routes/`:");
|
|
2117
|
+
lines.push("```typescript");
|
|
2118
|
+
lines.push('import { routes } from "@/app/_routes";');
|
|
2119
|
+
lines.push("<Link href={routes.dashboard.machines}>Machines</Link>");
|
|
2120
|
+
lines.push("```");
|
|
2121
|
+
lines.push("");
|
|
2122
|
+
lines.push("Benefits: type-safe, refactor-friendly, centralized.");
|
|
2123
|
+
lines.push("");
|
|
2124
|
+
lines.push("---");
|
|
2125
|
+
lines.push("");
|
|
2126
|
+
lines.push("## Current Audit");
|
|
2127
|
+
lines.push("");
|
|
2128
|
+
lines.push(`Site: ${report.siteUrl}`);
|
|
2129
|
+
lines.push(`Score: ${report.summary.healthScore}/100`);
|
|
2130
|
+
lines.push(`Date: ${report.generatedAt.slice(0, 10)}`);
|
|
2131
|
+
lines.push("");
|
|
2132
|
+
lines.push("### Issues");
|
|
2133
|
+
lines.push("");
|
|
2134
|
+
const { critical = 0, error = 0, warning = 0, info = 0 } = report.summary.issuesBySeverity;
|
|
2135
|
+
if (critical > 0) lines.push(`- Critical: ${critical}`);
|
|
2136
|
+
if (error > 0) lines.push(`- Error: ${error}`);
|
|
2137
|
+
if (warning > 0) lines.push(`- Warning: ${warning}`);
|
|
2138
|
+
if (info > 0) lines.push(`- Info: ${info}`);
|
|
2139
|
+
lines.push("");
|
|
2140
|
+
lines.push("### Top Actions");
|
|
2141
|
+
lines.push("");
|
|
2142
|
+
const topRecs = report.recommendations.slice(0, 5);
|
|
2143
|
+
for (let i = 0; i < topRecs.length; i++) {
|
|
2144
|
+
const rec = topRecs[i];
|
|
2145
|
+
if (!rec) continue;
|
|
2146
|
+
lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length} URLs)`);
|
|
2147
|
+
}
|
|
2148
|
+
lines.push("");
|
|
2149
|
+
lines.push("### Report Files");
|
|
2150
|
+
lines.push("");
|
|
2151
|
+
lines.push("See split reports in this directory:");
|
|
2152
|
+
lines.push("- `seo-*-index.md` - Start here");
|
|
2153
|
+
lines.push("- `seo-*-technical.md` - Technical issues");
|
|
2154
|
+
lines.push("- `seo-*-content.md` - Content issues");
|
|
2155
|
+
lines.push("- `seo-*-performance.md` - Performance issues");
|
|
2156
|
+
lines.push("");
|
|
2157
|
+
return lines.join("\n");
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// src/reports/generator.ts
|
|
2161
|
+
async function generateAndSaveReports(siteUrl, data, options) {
|
|
2162
|
+
const {
|
|
2163
|
+
outputDir,
|
|
2164
|
+
formats,
|
|
2165
|
+
includeRawData = false,
|
|
2166
|
+
timestamp = true,
|
|
2167
|
+
clearOutputDir = true,
|
|
2168
|
+
maxUrlsPerIssue = 10
|
|
2169
|
+
} = options;
|
|
2170
|
+
if (clearOutputDir && existsSync(outputDir)) {
|
|
2171
|
+
try {
|
|
2172
|
+
const files = readdirSync(outputDir);
|
|
2173
|
+
for (const file of files) {
|
|
2174
|
+
if (file.startsWith("seo-")) {
|
|
2175
|
+
rmSync(join(outputDir, file), { force: true });
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
} catch {
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
if (!existsSync(outputDir)) {
|
|
2182
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2183
|
+
}
|
|
2184
|
+
const report = generateJsonReport(siteUrl, data, { includeRawData, maxUrlsPerIssue });
|
|
2185
|
+
const result = {
|
|
2186
|
+
report,
|
|
2187
|
+
files: {}
|
|
2188
|
+
};
|
|
2189
|
+
const ts = timestamp ? `-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}` : "";
|
|
2190
|
+
const siteName = new URL(siteUrl).hostname.replace(/\./g, "-");
|
|
2191
|
+
if (formats.includes("json")) {
|
|
2192
|
+
const filename = `seo-report-${siteName}${ts}.json`;
|
|
2193
|
+
const filepath = join(outputDir, filename);
|
|
2194
|
+
const content = exportJsonReport(report, true);
|
|
2195
|
+
writeFileSync(filepath, content, "utf-8");
|
|
2196
|
+
result.files.json = filepath;
|
|
2197
|
+
consola3.success(`JSON report saved: ${filepath}`);
|
|
2198
|
+
}
|
|
2199
|
+
if (formats.includes("markdown")) {
|
|
2200
|
+
const filename = `seo-report-${siteName}${ts}.md`;
|
|
2201
|
+
const filepath = join(outputDir, filename);
|
|
2202
|
+
const content = generateMarkdownReport2(report, {
|
|
2203
|
+
includeRawIssues: true,
|
|
2204
|
+
includeUrls: true
|
|
2205
|
+
});
|
|
2206
|
+
writeFileSync(filepath, content, "utf-8");
|
|
2207
|
+
result.files.markdown = filepath;
|
|
2208
|
+
consola3.success(`Markdown report saved: ${filepath}`);
|
|
2209
|
+
}
|
|
2210
|
+
if (formats.includes("ai-summary")) {
|
|
2211
|
+
const filename = `seo-ai-summary-${siteName}${ts}.md`;
|
|
2212
|
+
const filepath = join(outputDir, filename);
|
|
2213
|
+
const content = generateAiSummary(report);
|
|
2214
|
+
writeFileSync(filepath, content, "utf-8");
|
|
2215
|
+
result.files.aiSummary = filepath;
|
|
2216
|
+
consola3.success(`AI summary saved: ${filepath}`);
|
|
2217
|
+
}
|
|
2218
|
+
if (formats.includes("split")) {
|
|
2219
|
+
const splitResult = generateSplitReports(report, {
|
|
2220
|
+
outputDir,
|
|
2221
|
+
clearOutputDir: false
|
|
2222
|
+
// Already cleared above
|
|
2223
|
+
});
|
|
2224
|
+
result.files.split = {
|
|
2225
|
+
index: join(outputDir, splitResult.indexFile),
|
|
2226
|
+
categories: splitResult.categoryFiles.map((f) => join(outputDir, f))
|
|
2227
|
+
};
|
|
2228
|
+
consola3.success(`Split reports saved: ${splitResult.totalFiles} files (index + ${splitResult.categoryFiles.length} categories)`);
|
|
2229
|
+
}
|
|
2230
|
+
const claudeContent = generateClaudeContext(report);
|
|
2231
|
+
const claudeFilepath = join(outputDir, "CLAUDE.md");
|
|
2232
|
+
writeFileSync(claudeFilepath, claudeContent, "utf-8");
|
|
2233
|
+
consola3.success(`AI context saved: ${claudeFilepath}`);
|
|
2234
|
+
return result;
|
|
2235
|
+
}
|
|
2236
|
+
function printReportSummary(report) {
|
|
2237
|
+
consola3.box(
|
|
2238
|
+
`SEO Report: ${report.siteUrl}
|
|
2239
|
+
Health Score: ${report.summary.healthScore}/100
|
|
2240
|
+
Total URLs: ${report.summary.totalUrls}
|
|
2241
|
+
Indexed: ${report.summary.indexedUrls} | Not Indexed: ${report.summary.notIndexedUrls}
|
|
2242
|
+
Issues: ${report.issues.length}`
|
|
2243
|
+
);
|
|
2244
|
+
if (report.summary.issuesBySeverity.critical) {
|
|
2245
|
+
consola3.error(`Critical issues: ${report.summary.issuesBySeverity.critical}`);
|
|
2246
|
+
}
|
|
2247
|
+
if (report.summary.issuesBySeverity.error) {
|
|
2248
|
+
consola3.warn(`Errors: ${report.summary.issuesBySeverity.error}`);
|
|
2249
|
+
}
|
|
2250
|
+
if (report.summary.issuesBySeverity.warning) {
|
|
2251
|
+
consola3.info(`Warnings: ${report.summary.issuesBySeverity.warning}`);
|
|
2252
|
+
}
|
|
2253
|
+
consola3.log("");
|
|
2254
|
+
consola3.info("Top recommendations:");
|
|
2255
|
+
for (const rec of report.recommendations.slice(0, 3)) {
|
|
2256
|
+
consola3.log(` ${rec.priority}. ${rec.title} (${rec.affectedUrls.length} URLs)`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
var PAGE_FILES = ["page.tsx", "page.ts", "page.jsx", "page.js"];
|
|
2260
|
+
var ROUTE_FILES = ["route.tsx", "route.ts", "route.jsx", "route.js"];
|
|
2261
|
+
var SPECIAL_FILES = ["layout", "loading", "error", "not-found", "template"];
|
|
2262
|
+
function findAppDir(startDir = process.cwd()) {
|
|
2263
|
+
const candidates = [
|
|
2264
|
+
join(startDir, "app"),
|
|
2265
|
+
join(startDir, "src", "app")
|
|
2266
|
+
];
|
|
2267
|
+
for (const dir of candidates) {
|
|
2268
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) {
|
|
2269
|
+
return dir;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
function scanRoutes(options = {}) {
|
|
2275
|
+
const {
|
|
2276
|
+
appDir = findAppDir() || "./app",
|
|
2277
|
+
includeApi = true,
|
|
2278
|
+
includeSpecial = false
|
|
2279
|
+
} = options;
|
|
2280
|
+
if (!existsSync(appDir)) {
|
|
2281
|
+
throw new Error(`App directory not found: ${appDir}`);
|
|
2282
|
+
}
|
|
2283
|
+
const routes = [];
|
|
2284
|
+
scanDirectory(appDir, "", routes, { includeApi, includeSpecial });
|
|
2285
|
+
return {
|
|
2286
|
+
routes,
|
|
2287
|
+
staticRoutes: routes.filter((r) => !r.isDynamic && r.type === "page"),
|
|
2288
|
+
dynamicRoutes: routes.filter((r) => r.isDynamic && r.type === "page"),
|
|
2289
|
+
apiRoutes: routes.filter((r) => r.type === "api"),
|
|
2290
|
+
appDir
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function scanDirectory(dir, routePath, routes, options) {
|
|
2294
|
+
let entries;
|
|
2295
|
+
try {
|
|
2296
|
+
entries = readdirSync(dir);
|
|
2297
|
+
} catch {
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
for (const entry of entries) {
|
|
2301
|
+
const fullPath = join(dir, entry);
|
|
2302
|
+
let stat;
|
|
2303
|
+
try {
|
|
2304
|
+
stat = statSync(fullPath);
|
|
2305
|
+
} catch {
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
if (stat.isDirectory()) {
|
|
2309
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
2310
|
+
if (entry.startsWith("(") && entry.endsWith(")")) {
|
|
2311
|
+
scanDirectory(fullPath, routePath, routes, options);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
if (entry.startsWith("@")) {
|
|
2315
|
+
scanDirectory(fullPath, routePath, routes, options);
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
if (entry.startsWith("(") && !entry.endsWith(")")) {
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
const segment = processSegment(entry);
|
|
2322
|
+
const newRoutePath = routePath + "/" + segment.urlSegment;
|
|
2323
|
+
scanDirectory(fullPath, newRoutePath, routes, options);
|
|
2324
|
+
} else if (stat.isFile()) {
|
|
2325
|
+
if (PAGE_FILES.includes(entry)) {
|
|
2326
|
+
const route = createRouteInfo(routePath || "/", dir, "page");
|
|
2327
|
+
routes.push(route);
|
|
2328
|
+
}
|
|
2329
|
+
if (options.includeApi && ROUTE_FILES.includes(entry)) {
|
|
2330
|
+
const route = createRouteInfo(routePath || "/", dir, "api");
|
|
2331
|
+
routes.push(route);
|
|
2332
|
+
}
|
|
2333
|
+
if (options.includeSpecial) {
|
|
2334
|
+
const baseName = entry.replace(/\.(tsx?|jsx?|js)$/, "");
|
|
2335
|
+
if (SPECIAL_FILES.includes(baseName)) {
|
|
2336
|
+
const route = createRouteInfo(routePath || "/", dir, baseName);
|
|
2337
|
+
routes.push(route);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
function processSegment(segment) {
|
|
2344
|
+
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
|
|
2345
|
+
const paramName = segment.slice(5, -2);
|
|
2346
|
+
return {
|
|
2347
|
+
urlSegment: segment,
|
|
2348
|
+
isDynamic: true,
|
|
2349
|
+
paramName,
|
|
2350
|
+
isCatchAll: true,
|
|
2351
|
+
isOptionalCatchAll: true
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
2355
|
+
const paramName = segment.slice(4, -1);
|
|
2356
|
+
return {
|
|
2357
|
+
urlSegment: segment,
|
|
2358
|
+
isDynamic: true,
|
|
2359
|
+
paramName,
|
|
2360
|
+
isCatchAll: true,
|
|
2361
|
+
isOptionalCatchAll: false
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
2365
|
+
const paramName = segment.slice(1, -1);
|
|
2366
|
+
return {
|
|
2367
|
+
urlSegment: segment,
|
|
2368
|
+
isDynamic: true,
|
|
2369
|
+
paramName,
|
|
2370
|
+
isCatchAll: false,
|
|
2371
|
+
isOptionalCatchAll: false
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
return {
|
|
2375
|
+
urlSegment: segment,
|
|
2376
|
+
isDynamic: false,
|
|
2377
|
+
isCatchAll: false,
|
|
2378
|
+
isOptionalCatchAll: false
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
function createRouteInfo(path6, filePath, type) {
|
|
2382
|
+
const segments = path6.split("/").filter(Boolean);
|
|
2383
|
+
const dynamicSegments = [];
|
|
2384
|
+
let isDynamic = false;
|
|
2385
|
+
let isCatchAll = false;
|
|
2386
|
+
let isOptionalCatchAll = false;
|
|
2387
|
+
for (const segment of segments) {
|
|
2388
|
+
const info = processSegment(segment);
|
|
2389
|
+
if (info.isDynamic) {
|
|
2390
|
+
isDynamic = true;
|
|
2391
|
+
if (info.paramName) {
|
|
2392
|
+
dynamicSegments.push(info.paramName);
|
|
2393
|
+
}
|
|
2394
|
+
if (info.isCatchAll) isCatchAll = true;
|
|
2395
|
+
if (info.isOptionalCatchAll) isOptionalCatchAll = true;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
let routeGroup;
|
|
2399
|
+
const groupMatch = filePath.match(/\(([^)]+)\)/);
|
|
2400
|
+
if (groupMatch) {
|
|
2401
|
+
routeGroup = groupMatch[1];
|
|
2402
|
+
}
|
|
2403
|
+
return {
|
|
2404
|
+
path: path6 || "/",
|
|
2405
|
+
filePath,
|
|
2406
|
+
type,
|
|
2407
|
+
isDynamic,
|
|
2408
|
+
dynamicSegments,
|
|
2409
|
+
isCatchAll,
|
|
2410
|
+
isOptionalCatchAll,
|
|
2411
|
+
routeGroup
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
function compareWithSitemap(scanResult, sitemapUrls, baseUrl) {
|
|
2415
|
+
const appRoutes = scanResult.routes.filter((r) => r.type === "page");
|
|
2416
|
+
const sitemapPaths = new Set(
|
|
2417
|
+
sitemapUrls.map((url) => {
|
|
2418
|
+
try {
|
|
2419
|
+
return new URL(url).pathname;
|
|
2420
|
+
} catch {
|
|
2421
|
+
return url;
|
|
2422
|
+
}
|
|
2423
|
+
})
|
|
2424
|
+
);
|
|
2425
|
+
const missingFromSitemap = [];
|
|
2426
|
+
const matching = [];
|
|
2427
|
+
for (const route of scanResult.staticRoutes) {
|
|
2428
|
+
const path6 = route.path === "/" ? "/" : route.path;
|
|
2429
|
+
if (sitemapPaths.has(path6) || sitemapPaths.has(path6 + "/") || sitemapPaths.has(path6.replace(/\/$/, ""))) {
|
|
2430
|
+
matching.push(route);
|
|
2431
|
+
} else {
|
|
2432
|
+
missingFromSitemap.push(route);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
const staticPaths = new Set(scanResult.staticRoutes.map((r) => r.path));
|
|
2436
|
+
const dynamicPatterns = scanResult.dynamicRoutes.map((r) => routeToRegex(r.path));
|
|
2437
|
+
const extraInSitemap = [];
|
|
2438
|
+
for (const path6 of sitemapPaths) {
|
|
2439
|
+
if (staticPaths.has(path6) || staticPaths.has(path6 + "/") || staticPaths.has(path6.replace(/\/$/, ""))) {
|
|
2440
|
+
continue;
|
|
2441
|
+
}
|
|
2442
|
+
const matchesDynamic = dynamicPatterns.some((regex) => regex.test(path6));
|
|
2443
|
+
if (matchesDynamic) continue;
|
|
2444
|
+
extraInSitemap.push(path6);
|
|
2445
|
+
}
|
|
2446
|
+
return {
|
|
2447
|
+
appRoutes,
|
|
2448
|
+
sitemapUrls,
|
|
2449
|
+
missingFromSitemap,
|
|
2450
|
+
extraInSitemap,
|
|
2451
|
+
matching
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
function routeToRegex(routePath) {
|
|
2455
|
+
let pattern = routePath.replace(/[.+?^${}()|\\]/g, "\\$&").replace(/\[\[\.\.\.([^\]]+)\]\]/g, "(?:/.*)?").replace(/\[\.\.\.([^\]]+)\]/g, "/.+").replace(/\[([^\]]+)\]/g, "/[^/]+");
|
|
2456
|
+
pattern = `^${pattern}/?$`;
|
|
2457
|
+
return new RegExp(pattern);
|
|
2458
|
+
}
|
|
2459
|
+
async function verifyRoutes(scanResult, options) {
|
|
2460
|
+
const {
|
|
2461
|
+
baseUrl,
|
|
2462
|
+
timeout = 1e4,
|
|
2463
|
+
concurrency = 5,
|
|
2464
|
+
staticOnly = true
|
|
2465
|
+
} = options;
|
|
2466
|
+
const routes = staticOnly ? scanResult.staticRoutes : scanResult.routes.filter((r) => r.type === "page");
|
|
2467
|
+
const limit = pLimit(concurrency);
|
|
2468
|
+
const results = await Promise.all(
|
|
2469
|
+
routes.map(
|
|
2470
|
+
(route) => limit(async () => {
|
|
2471
|
+
const url = new URL(route.path, baseUrl).href;
|
|
2472
|
+
const startTime = Date.now();
|
|
2473
|
+
try {
|
|
2474
|
+
const controller = new AbortController();
|
|
2475
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
2476
|
+
const response = await fetch(url, {
|
|
2477
|
+
method: "HEAD",
|
|
2478
|
+
signal: controller.signal,
|
|
2479
|
+
redirect: "follow"
|
|
2480
|
+
});
|
|
2481
|
+
clearTimeout(timeoutId);
|
|
2482
|
+
return {
|
|
2483
|
+
route,
|
|
2484
|
+
url,
|
|
2485
|
+
statusCode: response.status,
|
|
2486
|
+
isAccessible: response.status >= 200 && response.status < 400,
|
|
2487
|
+
responseTime: Date.now() - startTime
|
|
2488
|
+
};
|
|
2489
|
+
} catch (error) {
|
|
2490
|
+
return {
|
|
2491
|
+
route,
|
|
2492
|
+
url,
|
|
2493
|
+
statusCode: 0,
|
|
2494
|
+
isAccessible: false,
|
|
2495
|
+
error: error.message,
|
|
2496
|
+
responseTime: Date.now() - startTime
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
})
|
|
2500
|
+
)
|
|
2501
|
+
);
|
|
2502
|
+
return results;
|
|
2503
|
+
}
|
|
2504
|
+
function analyzeRoutes(scanResult, comparison, verification) {
|
|
2505
|
+
const issues = [];
|
|
2506
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2507
|
+
if (comparison && comparison.missingFromSitemap.length > 0) {
|
|
2508
|
+
for (const route of comparison.missingFromSitemap) {
|
|
2509
|
+
issues.push({
|
|
2510
|
+
id: `route-missing-sitemap-${route.path}`,
|
|
2511
|
+
category: "indexing",
|
|
2512
|
+
severity: "warning",
|
|
2513
|
+
title: "Route missing from sitemap",
|
|
2514
|
+
description: `Static route ${route.path} is not in sitemap.xml`,
|
|
2515
|
+
url: route.path,
|
|
2516
|
+
recommendation: "Add this route to your sitemap for better indexing",
|
|
2517
|
+
detectedAt: now
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
if (comparison && comparison.extraInSitemap.length > 0) {
|
|
2522
|
+
for (const path6 of comparison.extraInSitemap.slice(0, 20)) {
|
|
2523
|
+
issues.push({
|
|
2524
|
+
id: `sitemap-orphan-${path6}`,
|
|
2525
|
+
category: "indexing",
|
|
2526
|
+
severity: "info",
|
|
2527
|
+
title: "Sitemap URL without matching route",
|
|
2528
|
+
description: `URL ${path6} in sitemap doesn't match any app/ route`,
|
|
2529
|
+
url: path6,
|
|
2530
|
+
recommendation: "Verify this URL is still valid or remove from sitemap",
|
|
2531
|
+
detectedAt: now
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
if (scanResult.dynamicRoutes.length > scanResult.staticRoutes.length * 2) {
|
|
2536
|
+
issues.push({
|
|
2537
|
+
id: "too-many-dynamic-routes",
|
|
2538
|
+
category: "content",
|
|
2539
|
+
severity: "info",
|
|
2540
|
+
title: "High ratio of dynamic routes",
|
|
2541
|
+
description: `${scanResult.dynamicRoutes.length} dynamic vs ${scanResult.staticRoutes.length} static routes`,
|
|
2542
|
+
url: "/",
|
|
2543
|
+
recommendation: "Ensure dynamic routes have proper generateStaticParams for SSG",
|
|
2544
|
+
detectedAt: now
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
return issues;
|
|
2548
|
+
}
|
|
2549
|
+
function generateRoutesSummary(scanResult, comparison, verification) {
|
|
2550
|
+
const lines = [];
|
|
2551
|
+
lines.push("## Routes Summary");
|
|
2552
|
+
lines.push("");
|
|
2553
|
+
lines.push(`Total routes: ${scanResult.routes.length}`);
|
|
2554
|
+
lines.push(`\u251C\u2500\u2500 Static: ${scanResult.staticRoutes.length}`);
|
|
2555
|
+
lines.push(`\u251C\u2500\u2500 Dynamic: ${scanResult.dynamicRoutes.length}`);
|
|
2556
|
+
lines.push(`\u2514\u2500\u2500 API: ${scanResult.apiRoutes.length}`);
|
|
2557
|
+
lines.push("");
|
|
2558
|
+
return lines.join("\n");
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// src/cli/types.ts
|
|
2562
|
+
function parseFormats(format) {
|
|
2563
|
+
if (format === "all") {
|
|
2564
|
+
return ["json", "markdown", "ai-summary", "split"];
|
|
2565
|
+
}
|
|
2566
|
+
if (format === "split") {
|
|
2567
|
+
return ["ai-summary", "split"];
|
|
2568
|
+
}
|
|
2569
|
+
return format.split(",").map((f) => f.trim());
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// src/cli/commands/audit.ts
|
|
2573
|
+
async function runAudit(options) {
|
|
2574
|
+
const siteUrl = getSiteUrl(options);
|
|
2575
|
+
const startTime = Date.now();
|
|
2576
|
+
console.log("");
|
|
2577
|
+
consola3.box(`${chalk2.bold("SEO Audit")}
|
|
2578
|
+
${siteUrl}`);
|
|
2579
|
+
const serviceAccountPath = findGoogleServiceAccount(options["service-account"]);
|
|
2580
|
+
const hasGsc = !!serviceAccountPath;
|
|
2581
|
+
const appDir = options["app-dir"] || findAppDir();
|
|
2582
|
+
const hasRoutes = !!appDir;
|
|
2583
|
+
if (!serviceAccountPath) {
|
|
2584
|
+
const keyFile = getGscKeyFilename();
|
|
2585
|
+
console.log("");
|
|
2586
|
+
consola3.info(chalk2.dim("GSC not configured. Save service account as " + chalk2.cyan(keyFile) + " for indexing data."));
|
|
2587
|
+
}
|
|
2588
|
+
const allIssues = [];
|
|
2589
|
+
const allInspections = [];
|
|
2590
|
+
const allCrawlResults = [];
|
|
2591
|
+
const results = [];
|
|
2592
|
+
let collectedSitemapUrls = [];
|
|
2593
|
+
let totalSteps = 4;
|
|
2594
|
+
if (hasRoutes) totalSteps++;
|
|
2595
|
+
if (hasGsc) totalSteps++;
|
|
2596
|
+
let completedSteps = 0;
|
|
2597
|
+
const errors = [];
|
|
2598
|
+
const updateProgress = (step, status) => {
|
|
2599
|
+
const bar = "\u2588".repeat(completedSteps) + "\u2591".repeat(totalSteps - completedSteps);
|
|
2600
|
+
const pct = Math.round(completedSteps / totalSteps * 100);
|
|
2601
|
+
if (status === "running") {
|
|
2602
|
+
process.stdout.write(`\r${chalk2.cyan("\u25B8")} ${bar} ${pct}% ${chalk2.dim(step)}`);
|
|
2603
|
+
} else if (status === "done") {
|
|
2604
|
+
completedSteps++;
|
|
2605
|
+
const newBar = "\u2588".repeat(completedSteps) + "\u2591".repeat(totalSteps - completedSteps);
|
|
2606
|
+
const newPct = Math.round(completedSteps / totalSteps * 100);
|
|
2607
|
+
process.stdout.write(`\r${chalk2.green("\u2713")} ${newBar} ${newPct}% ${chalk2.dim(step)}${" ".repeat(20)}
|
|
2608
|
+
`);
|
|
2609
|
+
} else {
|
|
2610
|
+
process.stdout.write(`\r${chalk2.red("\u2717")} ${bar} ${pct}% ${chalk2.dim(step)}${" ".repeat(20)}
|
|
2611
|
+
`);
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
console.log("");
|
|
2615
|
+
let sitemapUrls = [];
|
|
2616
|
+
updateProgress("robots.txt", "running");
|
|
2617
|
+
try {
|
|
2618
|
+
const analysis = await analyzeRobotsTxt(siteUrl);
|
|
2619
|
+
sitemapUrls = analysis.sitemaps;
|
|
2620
|
+
results.push({ name: "robots.txt", issues: analysis.issues, meta: { exists: analysis.exists } });
|
|
2621
|
+
allIssues.push(...analysis.issues);
|
|
2622
|
+
updateProgress("robots.txt", "done");
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
errors.push(`robots.txt: ${e.message}`);
|
|
2625
|
+
updateProgress("robots.txt", "error");
|
|
2626
|
+
}
|
|
2627
|
+
const parallelTasks = await Promise.allSettled([
|
|
2628
|
+
// Sitemap
|
|
2629
|
+
(async () => {
|
|
2630
|
+
updateProgress("Sitemap", "running");
|
|
2631
|
+
const issues = [];
|
|
2632
|
+
const sitemapsToCheck = sitemapUrls.length > 0 ? sitemapUrls : [new URL("/sitemap.xml", siteUrl).href];
|
|
2633
|
+
let totalUrls = 0;
|
|
2634
|
+
for (const smUrl of sitemapsToCheck) {
|
|
2635
|
+
const analyses = await analyzeAllSitemaps(smUrl);
|
|
2636
|
+
for (const a of analyses) {
|
|
2637
|
+
issues.push(...a.issues);
|
|
2638
|
+
totalUrls += a.urls.length;
|
|
2639
|
+
collectedSitemapUrls.push(...a.urls.map((u) => u.loc));
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
updateProgress("Sitemap", "done");
|
|
2643
|
+
return { name: "Sitemap", issues, meta: { urls: totalUrls } };
|
|
2644
|
+
})(),
|
|
2645
|
+
// Crawl
|
|
2646
|
+
(async () => {
|
|
2647
|
+
updateProgress("Crawl", "running");
|
|
2648
|
+
const crawler = new SiteCrawler(siteUrl, {
|
|
2649
|
+
maxPages: parseInt(options["max-pages"], 10),
|
|
2650
|
+
maxDepth: parseInt(options["max-depth"], 10)
|
|
2651
|
+
});
|
|
2652
|
+
const crawlResults = await crawler.crawl();
|
|
2653
|
+
allCrawlResults.push(...crawlResults);
|
|
2654
|
+
const issues = analyzeCrawlResults(crawlResults);
|
|
2655
|
+
updateProgress("Crawl", "done");
|
|
2656
|
+
return { name: "Crawl", issues, meta: { pages: crawlResults.length } };
|
|
2657
|
+
})(),
|
|
2658
|
+
// Links
|
|
2659
|
+
(async () => {
|
|
2660
|
+
updateProgress("Links", "running");
|
|
2661
|
+
const result = await checkLinks({
|
|
2662
|
+
url: siteUrl,
|
|
2663
|
+
timeout: parseInt(options.timeout, 10),
|
|
2664
|
+
concurrency: parseInt(options.concurrency, 10),
|
|
2665
|
+
verbose: false
|
|
2666
|
+
});
|
|
2667
|
+
const issues = linkResultsToSeoIssues(result);
|
|
2668
|
+
updateProgress("Links", "done");
|
|
2669
|
+
return { name: "Links", issues, meta: { total: result.total, broken: result.broken } };
|
|
2670
|
+
})()
|
|
2671
|
+
]);
|
|
2672
|
+
for (const task of parallelTasks) {
|
|
2673
|
+
if (task.status === "fulfilled") {
|
|
2674
|
+
results.push(task.value);
|
|
2675
|
+
allIssues.push(...task.value.issues);
|
|
2676
|
+
} else {
|
|
2677
|
+
errors.push(task.reason?.message || "Unknown error");
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
if (hasRoutes && appDir) {
|
|
2681
|
+
updateProgress("Routes", "running");
|
|
2682
|
+
try {
|
|
2683
|
+
const scanResult = scanRoutes({ appDir });
|
|
2684
|
+
const comparison = compareWithSitemap(scanResult, collectedSitemapUrls, siteUrl);
|
|
2685
|
+
const issues = analyzeRoutes(scanResult, comparison);
|
|
2686
|
+
results.push({
|
|
2687
|
+
name: "Routes",
|
|
2688
|
+
issues,
|
|
2689
|
+
meta: {
|
|
2690
|
+
static: scanResult.staticRoutes.length,
|
|
2691
|
+
dynamic: scanResult.dynamicRoutes.length,
|
|
2692
|
+
missing: comparison.missingFromSitemap.length
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
allIssues.push(...issues);
|
|
2696
|
+
updateProgress("Routes", "done");
|
|
2697
|
+
} catch (e) {
|
|
2698
|
+
errors.push(`Routes: ${e.message}`);
|
|
2699
|
+
updateProgress("Routes", "error");
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
if (hasGsc) {
|
|
2703
|
+
updateProgress("GSC", "running");
|
|
2704
|
+
try {
|
|
2705
|
+
const client = new GoogleConsoleClient({
|
|
2706
|
+
siteUrl,
|
|
2707
|
+
serviceAccountPath
|
|
2708
|
+
});
|
|
2709
|
+
const isAuth = await client.verify();
|
|
2710
|
+
if (isAuth) {
|
|
2711
|
+
const urlsToInspect = allCrawlResults.filter((r) => r.statusCode === 200).slice(0, 50).map((r) => r.url);
|
|
2712
|
+
const inspections = await client.inspectUrls(urlsToInspect);
|
|
2713
|
+
allInspections.push(...inspections);
|
|
2714
|
+
const issues = analyzeInspectionResults(inspections);
|
|
2715
|
+
results.push({ name: "GSC", issues, meta: { inspected: inspections.length } });
|
|
2716
|
+
allIssues.push(...issues);
|
|
2717
|
+
} else {
|
|
2718
|
+
results.push({ name: "GSC", issues: [], meta: { skipped: true } });
|
|
2719
|
+
}
|
|
2720
|
+
updateProgress("GSC", "done");
|
|
2721
|
+
} catch (e) {
|
|
2722
|
+
errors.push(`GSC: ${e.message}`);
|
|
2723
|
+
updateProgress("GSC", "error");
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
if (errors.length > 0) {
|
|
2727
|
+
console.log("");
|
|
2728
|
+
for (const err of errors) {
|
|
2729
|
+
consola3.error(err);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
console.log("");
|
|
2733
|
+
consola3.log(chalk2.bold("Results:"));
|
|
2734
|
+
for (const r of results) {
|
|
2735
|
+
const issueStr = r.issues.length > 0 ? chalk2.yellow(`${r.issues.length} issues`) : chalk2.green("OK");
|
|
2736
|
+
const metaStr = r.meta ? chalk2.dim(` (${Object.entries(r.meta).map(([k, v]) => `${k}: ${v}`).join(", ")})`) : "";
|
|
2737
|
+
consola3.log(` ${r.name}: ${issueStr}${metaStr}`);
|
|
2738
|
+
}
|
|
2739
|
+
console.log("");
|
|
2740
|
+
consola3.start("Generating reports...");
|
|
2741
|
+
const formats = parseFormats(options.format);
|
|
2742
|
+
const { report, files } = await generateAndSaveReports(
|
|
2743
|
+
siteUrl,
|
|
2744
|
+
{
|
|
2745
|
+
issues: allIssues,
|
|
2746
|
+
urlInspections: allInspections,
|
|
2747
|
+
crawlResults: allCrawlResults
|
|
2748
|
+
},
|
|
2749
|
+
{
|
|
2750
|
+
outputDir: options.output,
|
|
2751
|
+
formats,
|
|
2752
|
+
includeRawData: true
|
|
2753
|
+
}
|
|
2754
|
+
);
|
|
2755
|
+
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2756
|
+
console.log("");
|
|
2757
|
+
printReportSummary(report);
|
|
2758
|
+
console.log("");
|
|
2759
|
+
consola3.info(`Reports saved to: ${chalk2.cyan(options.output)}`);
|
|
2760
|
+
if (files.json) consola3.log(` ${chalk2.dim("\u2192")} ${files.json}`);
|
|
2761
|
+
if (files.markdown) consola3.log(` ${chalk2.dim("\u2192")} ${files.markdown}`);
|
|
2762
|
+
if (files.aiSummary) consola3.log(` ${chalk2.dim("\u2192")} ${files.aiSummary}`);
|
|
2763
|
+
if (files.split) {
|
|
2764
|
+
consola3.log(` ${chalk2.dim("\u2192")} ${files.split.index} ${chalk2.dim("(index)")}`);
|
|
2765
|
+
consola3.log(` ${chalk2.dim("\u2192")} ${files.split.categories.length} category files`);
|
|
2766
|
+
}
|
|
2767
|
+
console.log("");
|
|
2768
|
+
consola3.success(`Audit completed in ${duration}s`);
|
|
2769
|
+
}
|
|
2770
|
+
async function runRoutes(options) {
|
|
2771
|
+
const siteUrl = getSiteUrl(options);
|
|
2772
|
+
const appDir = options["app-dir"] || findAppDir();
|
|
2773
|
+
if (!appDir) {
|
|
2774
|
+
consola3.error("Could not find app/ directory. Use --app-dir to specify path.");
|
|
2775
|
+
process.exit(1);
|
|
2776
|
+
}
|
|
2777
|
+
console.log("");
|
|
2778
|
+
consola3.box(`${chalk2.bold("Routes Scanner")}
|
|
2779
|
+
${appDir}`);
|
|
2780
|
+
consola3.start("Scanning app/ directory...");
|
|
2781
|
+
const scanResult = scanRoutes({ appDir });
|
|
2782
|
+
consola3.success(`Found ${scanResult.routes.length} routes`);
|
|
2783
|
+
console.log(` \u251C\u2500\u2500 Static: ${scanResult.staticRoutes.length}`);
|
|
2784
|
+
console.log(` \u251C\u2500\u2500 Dynamic: ${scanResult.dynamicRoutes.length}`);
|
|
2785
|
+
console.log(` \u2514\u2500\u2500 API: ${scanResult.apiRoutes.length}`);
|
|
2786
|
+
if (scanResult.staticRoutes.length > 0) {
|
|
2787
|
+
console.log("");
|
|
2788
|
+
consola3.info("Static routes:");
|
|
2789
|
+
for (const route of scanResult.staticRoutes.slice(0, 20)) {
|
|
2790
|
+
console.log(` ${chalk2.green("\u2192")} ${route.path}`);
|
|
2791
|
+
}
|
|
2792
|
+
if (scanResult.staticRoutes.length > 20) {
|
|
2793
|
+
console.log(` ${chalk2.dim(`... +${scanResult.staticRoutes.length - 20} more`)}`);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (scanResult.dynamicRoutes.length > 0) {
|
|
2797
|
+
console.log("");
|
|
2798
|
+
consola3.info("Dynamic routes:");
|
|
2799
|
+
for (const route of scanResult.dynamicRoutes.slice(0, 10)) {
|
|
2800
|
+
const params = route.dynamicSegments.join(", ");
|
|
2801
|
+
console.log(` ${chalk2.yellow("\u2192")} ${route.path} ${chalk2.dim(`[${params}]`)}`);
|
|
2802
|
+
}
|
|
2803
|
+
if (scanResult.dynamicRoutes.length > 10) {
|
|
2804
|
+
console.log(` ${chalk2.dim(`... +${scanResult.dynamicRoutes.length - 10} more`)}`);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
if (options.check) {
|
|
2808
|
+
console.log("");
|
|
2809
|
+
consola3.start("Loading sitemap...");
|
|
2810
|
+
try {
|
|
2811
|
+
const sitemapUrl = new URL("/sitemap.xml", siteUrl).href;
|
|
2812
|
+
const sitemap = await analyzeSitemap(sitemapUrl);
|
|
2813
|
+
const sitemapUrls = sitemap.urls.map((u) => u.loc);
|
|
2814
|
+
consola3.success(`Loaded ${sitemapUrls.length} URLs from sitemap`);
|
|
2815
|
+
const comparison = compareWithSitemap(scanResult, sitemapUrls, siteUrl);
|
|
2816
|
+
console.log("");
|
|
2817
|
+
consola3.info("Sitemap comparison:");
|
|
2818
|
+
console.log(` \u251C\u2500\u2500 Matching: ${comparison.matching.length}`);
|
|
2819
|
+
console.log(` \u251C\u2500\u2500 Missing from sitemap: ${chalk2.yellow(String(comparison.missingFromSitemap.length))}`);
|
|
2820
|
+
console.log(` \u2514\u2500\u2500 Extra in sitemap: ${comparison.extraInSitemap.length}`);
|
|
2821
|
+
if (comparison.missingFromSitemap.length > 0) {
|
|
2822
|
+
console.log("");
|
|
2823
|
+
consola3.warn("Routes missing from sitemap:");
|
|
2824
|
+
for (const route of comparison.missingFromSitemap.slice(0, 10)) {
|
|
2825
|
+
console.log(` ${chalk2.red("\u2717")} ${route.path}`);
|
|
2826
|
+
}
|
|
2827
|
+
if (comparison.missingFromSitemap.length > 10) {
|
|
2828
|
+
console.log(` ${chalk2.dim(`... +${comparison.missingFromSitemap.length - 10} more`)}`);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
consola3.error(`Failed to load sitemap: ${error.message}`);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
if (options.verify) {
|
|
2836
|
+
console.log("");
|
|
2837
|
+
consola3.start("Verifying routes...");
|
|
2838
|
+
const verification = await verifyRoutes(scanResult, {
|
|
2839
|
+
baseUrl: siteUrl,
|
|
2840
|
+
timeout: parseInt(options.timeout, 10),
|
|
2841
|
+
concurrency: 5,
|
|
2842
|
+
staticOnly: true
|
|
2843
|
+
});
|
|
2844
|
+
const accessible = verification.filter((r) => r.isAccessible);
|
|
2845
|
+
const broken = verification.filter((r) => !r.isAccessible);
|
|
2846
|
+
consola3.success(`Verified ${verification.length} routes`);
|
|
2847
|
+
console.log(` \u251C\u2500\u2500 Accessible: ${chalk2.green(String(accessible.length))}`);
|
|
2848
|
+
console.log(` \u2514\u2500\u2500 Broken: ${chalk2.red(String(broken.length))}`);
|
|
2849
|
+
if (broken.length > 0) {
|
|
2850
|
+
console.log("");
|
|
2851
|
+
consola3.error("Broken routes:");
|
|
2852
|
+
for (const r of broken.slice(0, 10)) {
|
|
2853
|
+
console.log(` ${chalk2.red("\u2717")} ${r.route.path} \u2192 ${r.statusCode || r.error}`);
|
|
2854
|
+
}
|
|
2855
|
+
if (broken.length > 10) {
|
|
2856
|
+
console.log(` ${chalk2.dim(`... +${broken.length - 10} more`)}`);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
console.log("");
|
|
2861
|
+
console.log(generateRoutesSummary(scanResult));
|
|
2862
|
+
}
|
|
2863
|
+
function loadUrlsFromFile(filePath) {
|
|
2864
|
+
if (!existsSync(filePath)) {
|
|
2865
|
+
throw new Error(`File not found: ${filePath}`);
|
|
2866
|
+
}
|
|
2867
|
+
const content = readFileSync(filePath, "utf-8");
|
|
2868
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/cli/commands/inspect.ts
|
|
2872
|
+
async function runInspect(options) {
|
|
2873
|
+
const siteUrl = getSiteUrl(options);
|
|
2874
|
+
consola3.start("Starting URL inspection via Google Search Console");
|
|
2875
|
+
const client = new GoogleConsoleClient({
|
|
2876
|
+
siteUrl,
|
|
2877
|
+
serviceAccountPath: options["service-account"]
|
|
2878
|
+
});
|
|
2879
|
+
const isAuth = await client.verify();
|
|
2880
|
+
if (!isAuth) {
|
|
2881
|
+
consola3.error("Failed to authenticate with Google Search Console");
|
|
2882
|
+
process.exit(1);
|
|
2883
|
+
}
|
|
2884
|
+
let urls;
|
|
2885
|
+
if (options.urls) {
|
|
2886
|
+
urls = loadUrlsFromFile(options.urls);
|
|
2887
|
+
consola3.info(`Loaded ${urls.length} URLs from ${options.urls}`);
|
|
2888
|
+
} else {
|
|
2889
|
+
consola3.info("Fetching URLs from search analytics...");
|
|
2890
|
+
const today = /* @__PURE__ */ new Date();
|
|
2891
|
+
const startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
2892
|
+
const rows = await client.getSearchAnalytics({
|
|
2893
|
+
startDate: startDate.toISOString().split("T")[0],
|
|
2894
|
+
endDate: today.toISOString().split("T")[0],
|
|
2895
|
+
dimensions: ["page"],
|
|
2896
|
+
rowLimit: 100
|
|
2897
|
+
});
|
|
2898
|
+
urls = rows.map((row) => row.keys?.[0] || "").filter(Boolean);
|
|
2899
|
+
consola3.info(`Found ${urls.length} URLs from search analytics`);
|
|
2900
|
+
}
|
|
2901
|
+
const results = await client.inspectUrls(urls);
|
|
2902
|
+
const issues = analyzeInspectionResults(results);
|
|
2903
|
+
consola3.info(`Found ${issues.length} issues`);
|
|
2904
|
+
const formats = parseFormats(options.format);
|
|
2905
|
+
await generateAndSaveReports(siteUrl, { issues, urlInspections: results }, {
|
|
2906
|
+
outputDir: options.output,
|
|
2907
|
+
formats,
|
|
2908
|
+
includeRawData: true
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
async function runCrawl(options) {
|
|
2912
|
+
const siteUrl = getSiteUrl(options);
|
|
2913
|
+
consola3.start(`Starting crawl of ${siteUrl}`);
|
|
2914
|
+
const crawler = new SiteCrawler(siteUrl, {
|
|
2915
|
+
maxPages: parseInt(options["max-pages"], 10),
|
|
2916
|
+
maxDepth: parseInt(options["max-depth"], 10)
|
|
2917
|
+
});
|
|
2918
|
+
const crawlResults = await crawler.crawl();
|
|
2919
|
+
const issues = analyzeCrawlResults(crawlResults);
|
|
2920
|
+
consola3.info(`Found ${issues.length} issues from ${crawlResults.length} pages`);
|
|
2921
|
+
const formats = parseFormats(options.format);
|
|
2922
|
+
await generateAndSaveReports(siteUrl, { issues, crawlResults }, {
|
|
2923
|
+
outputDir: options.output,
|
|
2924
|
+
formats,
|
|
2925
|
+
includeRawData: true
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
async function runLinks(options) {
|
|
2929
|
+
const siteUrl = getSiteUrl(options);
|
|
2930
|
+
consola3.start(`Checking links on ${siteUrl}`);
|
|
2931
|
+
const result = await checkLinks({
|
|
2932
|
+
url: siteUrl,
|
|
2933
|
+
timeout: parseInt(options.timeout, 10),
|
|
2934
|
+
concurrency: parseInt(options.concurrency, 10),
|
|
2935
|
+
verbose: true
|
|
2936
|
+
});
|
|
2937
|
+
if (result.success) {
|
|
2938
|
+
consola3.success(`All ${result.total} links are valid!`);
|
|
2939
|
+
} else {
|
|
2940
|
+
consola3.error(`Found ${result.broken} broken links out of ${result.total}`);
|
|
2941
|
+
if (options.output !== "./seo-reports" || result.broken > 0) {
|
|
2942
|
+
const issues = linkResultsToSeoIssues(result);
|
|
2943
|
+
const formats = parseFormats(options.format);
|
|
2944
|
+
await generateAndSaveReports(siteUrl, { issues }, {
|
|
2945
|
+
outputDir: options.output,
|
|
2946
|
+
formats,
|
|
2947
|
+
includeRawData: false
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
process.exit(result.success ? 0 : 1);
|
|
2952
|
+
}
|
|
2953
|
+
async function runRobots(options) {
|
|
2954
|
+
const siteUrl = getSiteUrl(options);
|
|
2955
|
+
consola3.start(`Analyzing robots.txt for ${siteUrl}`);
|
|
2956
|
+
const analysis = await analyzeRobotsTxt(siteUrl);
|
|
2957
|
+
if (analysis.exists) {
|
|
2958
|
+
consola3.success("robots.txt found");
|
|
2959
|
+
consola3.info(`Sitemaps: ${analysis.sitemaps.length}`);
|
|
2960
|
+
consola3.info(`Disallow rules: ${analysis.disallowedPaths.length}`);
|
|
2961
|
+
consola3.info(`Allow rules: ${analysis.allowedPaths.length}`);
|
|
2962
|
+
if (analysis.crawlDelay) {
|
|
2963
|
+
consola3.info(`Crawl-delay: ${analysis.crawlDelay}`);
|
|
2964
|
+
}
|
|
2965
|
+
if (analysis.issues.length > 0) {
|
|
2966
|
+
consola3.warn(`Issues found: ${analysis.issues.length}`);
|
|
2967
|
+
for (const issue of analysis.issues) {
|
|
2968
|
+
consola3.log(` - [${issue.severity}] ${issue.title}`);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
} else {
|
|
2972
|
+
consola3.warn("robots.txt not found");
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
async function runSitemap(options) {
|
|
2976
|
+
let sitemapUrl = getSiteUrl(options);
|
|
2977
|
+
if (!sitemapUrl.endsWith(".xml")) {
|
|
2978
|
+
sitemapUrl = new URL("/sitemap.xml", sitemapUrl).href;
|
|
2979
|
+
}
|
|
2980
|
+
consola3.start(`Validating sitemap: ${sitemapUrl}`);
|
|
2981
|
+
const analyses = await analyzeAllSitemaps(sitemapUrl);
|
|
2982
|
+
let totalUrls = 0;
|
|
2983
|
+
let totalIssues = 0;
|
|
2984
|
+
for (const analysis of analyses) {
|
|
2985
|
+
if (analysis.exists) {
|
|
2986
|
+
consola3.success(`${analysis.url}`);
|
|
2987
|
+
consola3.info(` Type: ${analysis.type}`);
|
|
2988
|
+
if (analysis.type === "sitemap") {
|
|
2989
|
+
consola3.info(` URLs: ${analysis.urls.length}`);
|
|
2990
|
+
totalUrls += analysis.urls.length;
|
|
2991
|
+
} else {
|
|
2992
|
+
consola3.info(` Child sitemaps: ${analysis.childSitemaps.length}`);
|
|
2993
|
+
}
|
|
2994
|
+
if (analysis.issues.length > 0) {
|
|
2995
|
+
totalIssues += analysis.issues.length;
|
|
2996
|
+
for (const issue of analysis.issues) {
|
|
2997
|
+
consola3.warn(` [${issue.severity}] ${issue.title}`);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
} else {
|
|
3001
|
+
consola3.error(`${analysis.url} - Not found`);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
consola3.box(`Total URLs: ${totalUrls}
|
|
3005
|
+
Total Issues: ${totalIssues}`);
|
|
3006
|
+
}
|
|
3007
|
+
function detectProjectType(cwd) {
|
|
3008
|
+
const contentDir = path.join(cwd, "content");
|
|
3009
|
+
const appDir = path.join(cwd, "app");
|
|
3010
|
+
if (fs4.existsSync(contentDir)) {
|
|
3011
|
+
if (hasMetaFiles(contentDir)) {
|
|
3012
|
+
return "nextra";
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
if (fs4.existsSync(appDir)) {
|
|
3016
|
+
return "nextjs";
|
|
3017
|
+
}
|
|
3018
|
+
return "unknown";
|
|
3019
|
+
}
|
|
3020
|
+
function hasMetaFiles(dir) {
|
|
3021
|
+
if (!fs4.existsSync(dir)) return false;
|
|
3022
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
3023
|
+
for (const entry of entries) {
|
|
3024
|
+
if (entry.name === "_meta.ts" || entry.name === "_meta.tsx") {
|
|
3025
|
+
return true;
|
|
3026
|
+
}
|
|
3027
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
3028
|
+
const subDir = path.join(dir, entry.name);
|
|
3029
|
+
if (hasMetaFiles(subDir)) {
|
|
3030
|
+
return true;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
return false;
|
|
3035
|
+
}
|
|
3036
|
+
function getAllFiles(dir, extensions, files = []) {
|
|
3037
|
+
if (!fs4.existsSync(dir)) return files;
|
|
3038
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
3039
|
+
for (const entry of entries) {
|
|
3040
|
+
const fullPath = path.join(dir, entry.name);
|
|
3041
|
+
if (entry.isDirectory()) {
|
|
3042
|
+
if (!entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
3043
|
+
getAllFiles(fullPath, extensions, files);
|
|
3044
|
+
}
|
|
3045
|
+
} else {
|
|
3046
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
3047
|
+
if (extensions.includes(ext)) {
|
|
3048
|
+
files.push(fullPath);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
return files;
|
|
3053
|
+
}
|
|
3054
|
+
function getAllMdxFiles(contentDir) {
|
|
3055
|
+
return getAllFiles(contentDir, [".mdx", ".md"]);
|
|
3056
|
+
}
|
|
3057
|
+
function getFileInfo(filePath, contentDir) {
|
|
3058
|
+
const relativePath = path.relative(contentDir, filePath);
|
|
3059
|
+
const parsed = path.parse(relativePath);
|
|
3060
|
+
const isIndex = parsed.name === "index";
|
|
3061
|
+
const folder = parsed.dir || "";
|
|
3062
|
+
return {
|
|
3063
|
+
fullPath: filePath,
|
|
3064
|
+
relativePath,
|
|
3065
|
+
isIndex,
|
|
3066
|
+
folder,
|
|
3067
|
+
name: parsed.name
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
function pathExists(docsPath, contentDir) {
|
|
3071
|
+
const cleanPath = docsPath.replace(/\/$/, "").replace(/^\//, "");
|
|
3072
|
+
const candidates = [
|
|
3073
|
+
path.join(contentDir, cleanPath + ".mdx"),
|
|
3074
|
+
path.join(contentDir, cleanPath + ".md"),
|
|
3075
|
+
path.join(contentDir, cleanPath, "index.mdx"),
|
|
3076
|
+
path.join(contentDir, cleanPath, "index.md")
|
|
3077
|
+
];
|
|
3078
|
+
return candidates.some((p) => fs4.existsSync(p));
|
|
3079
|
+
}
|
|
3080
|
+
function findContentDir(cwd) {
|
|
3081
|
+
const candidates = [
|
|
3082
|
+
path.join(cwd, "content"),
|
|
3083
|
+
path.join(cwd, "docs"),
|
|
3084
|
+
path.join(cwd, "pages", "docs")
|
|
3085
|
+
];
|
|
3086
|
+
for (const candidate of candidates) {
|
|
3087
|
+
if (fs4.existsSync(candidate)) {
|
|
3088
|
+
return candidate;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
return null;
|
|
3092
|
+
}
|
|
3093
|
+
var LINK_PATTERNS = [
|
|
3094
|
+
// Absolute links: [text](/docs/path)
|
|
3095
|
+
{ regex: /\]\(\/docs\/([^)#\s"]+)/g, type: "absolute" },
|
|
3096
|
+
{ regex: /href="\/docs\/([^"#]+)"/g, type: "absolute" },
|
|
3097
|
+
{ regex: /to="\/docs\/([^"#]+)"/g, type: "absolute" },
|
|
3098
|
+
// Dot-slash relative: [text](./path)
|
|
3099
|
+
{ regex: /\]\(\.\/([^)#\s"]+)(?:#[^)]*)?\)/g, type: "dotslash" },
|
|
3100
|
+
{ regex: /href="\.\/([^"#]+)"/g, type: "dotslash" },
|
|
3101
|
+
// Parent relative: [text](../path)
|
|
3102
|
+
{ regex: /\]\(\.\.\/([^)#\s"]+)(?:#[^)]*)?\)/g, type: "parent" },
|
|
3103
|
+
{ regex: /href="\.\.\/([^"#]+)"/g, type: "parent" },
|
|
3104
|
+
// Simple relative (no prefix): [text](path)
|
|
3105
|
+
{ regex: /\]\((?!\/|http|#|\.|\[)([a-zA-Z][^)#\s"]*)(?:#[^)]*)?\)/g, type: "simple" },
|
|
3106
|
+
{ regex: /href="(?!\/|http|#|\.)([a-zA-Z][^"#]*)"/g, type: "simple" }
|
|
3107
|
+
];
|
|
3108
|
+
function isAssetLink(linkPath, assetExtensions) {
|
|
3109
|
+
return assetExtensions.some((ext) => linkPath.toLowerCase().endsWith(ext));
|
|
3110
|
+
}
|
|
3111
|
+
function resolveLink(fromFilePath, linkPath, linkType, contentDir) {
|
|
3112
|
+
if (linkType === "absolute") {
|
|
3113
|
+
return linkPath;
|
|
3114
|
+
}
|
|
3115
|
+
const { isIndex, folder: sourceFolder, name: fileName } = getFileInfo(fromFilePath, contentDir);
|
|
3116
|
+
const sourceParts = sourceFolder ? sourceFolder.split("/") : [];
|
|
3117
|
+
if (linkType === "dotslash" || linkType === "simple") {
|
|
3118
|
+
if (isIndex) {
|
|
3119
|
+
return sourceFolder ? `${sourceFolder}/${linkPath}` : linkPath;
|
|
3120
|
+
} else {
|
|
3121
|
+
return sourceFolder ? `${sourceFolder}/${fileName}/${linkPath}` : `${fileName}/${linkPath}`;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
if (linkType === "parent") {
|
|
3125
|
+
if (isIndex) {
|
|
3126
|
+
const newParts = [...sourceParts];
|
|
3127
|
+
newParts.pop();
|
|
3128
|
+
return newParts.length ? `${newParts.join("/")}/${linkPath}` : linkPath;
|
|
3129
|
+
} else {
|
|
3130
|
+
return sourceParts.length ? `${sourceParts.join("/")}/${linkPath}` : linkPath;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
return linkPath;
|
|
3134
|
+
}
|
|
3135
|
+
function extractLinks(filePath, contentDir, assetExtensions) {
|
|
3136
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
3137
|
+
const links = [];
|
|
3138
|
+
for (const { regex, type } of LINK_PATTERNS) {
|
|
3139
|
+
regex.lastIndex = 0;
|
|
3140
|
+
let match;
|
|
3141
|
+
while ((match = regex.exec(content)) !== null) {
|
|
3142
|
+
const rawLink = match[1];
|
|
3143
|
+
if (!rawLink) continue;
|
|
3144
|
+
if (isAssetLink(rawLink, assetExtensions)) continue;
|
|
3145
|
+
const resolved = resolveLink(filePath, rawLink, type, contentDir);
|
|
3146
|
+
links.push({
|
|
3147
|
+
raw: rawLink,
|
|
3148
|
+
resolved,
|
|
3149
|
+
type,
|
|
3150
|
+
line: content.substring(0, match.index).split("\n").length
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return links;
|
|
3155
|
+
}
|
|
3156
|
+
function checkContentLinks(contentDir, config2) {
|
|
3157
|
+
const assetExtensions = config2?.assetExtensions || [
|
|
3158
|
+
".png",
|
|
3159
|
+
".jpg",
|
|
3160
|
+
".jpeg",
|
|
3161
|
+
".gif",
|
|
3162
|
+
".svg",
|
|
3163
|
+
".webp",
|
|
3164
|
+
".ico",
|
|
3165
|
+
".pdf",
|
|
3166
|
+
".zip",
|
|
3167
|
+
".tar",
|
|
3168
|
+
".gz"
|
|
3169
|
+
];
|
|
3170
|
+
const basePath = config2?.basePath;
|
|
3171
|
+
const files = getAllMdxFiles(contentDir);
|
|
3172
|
+
const brokenLinks = [];
|
|
3173
|
+
const checkedLinks = /* @__PURE__ */ new Map();
|
|
3174
|
+
for (const file of files) {
|
|
3175
|
+
const links = extractLinks(file, contentDir, assetExtensions);
|
|
3176
|
+
const relativePath = path.relative(contentDir, file);
|
|
3177
|
+
for (const link of links) {
|
|
3178
|
+
if (!checkedLinks.has(link.resolved)) {
|
|
3179
|
+
checkedLinks.set(link.resolved, pathExists(link.resolved, contentDir));
|
|
3180
|
+
}
|
|
3181
|
+
if (!checkedLinks.get(link.resolved)) {
|
|
3182
|
+
brokenLinks.push({
|
|
3183
|
+
file: relativePath,
|
|
3184
|
+
link: `${basePath}/${link.resolved}`,
|
|
3185
|
+
type: link.type,
|
|
3186
|
+
raw: link.raw,
|
|
3187
|
+
line: link.line
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
return {
|
|
3193
|
+
filesChecked: files.length,
|
|
3194
|
+
uniqueLinks: checkedLinks.size,
|
|
3195
|
+
brokenLinks,
|
|
3196
|
+
success: brokenLinks.length === 0
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
function groupBrokenLinksByFile(brokenLinks) {
|
|
3200
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
3201
|
+
for (const link of brokenLinks) {
|
|
3202
|
+
const existing = byFile.get(link.file) || [];
|
|
3203
|
+
existing.push(link);
|
|
3204
|
+
byFile.set(link.file, existing);
|
|
3205
|
+
}
|
|
3206
|
+
return byFile;
|
|
3207
|
+
}
|
|
3208
|
+
function isAssetLink2(linkPath, assetExtensions) {
|
|
3209
|
+
return assetExtensions.some((ext) => linkPath.toLowerCase().endsWith(ext));
|
|
3210
|
+
}
|
|
3211
|
+
function calculateRelativePath(sourceFile, targetDocsPath, contentDir) {
|
|
3212
|
+
const { isIndex, folder: sourceFolder } = getFileInfo(sourceFile, contentDir);
|
|
3213
|
+
const targetPath = targetDocsPath.replace(/^\//, "");
|
|
3214
|
+
const sourceParts = sourceFolder ? sourceFolder.split("/") : [];
|
|
3215
|
+
const targetParts = targetPath.split("/");
|
|
3216
|
+
let commonLength = 0;
|
|
3217
|
+
for (let i = 0; i < Math.min(sourceParts.length, targetParts.length); i++) {
|
|
3218
|
+
if (sourceParts[i] === targetParts[i]) {
|
|
3219
|
+
commonLength++;
|
|
3220
|
+
} else {
|
|
3221
|
+
break;
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
if (isIndex) {
|
|
3225
|
+
if (targetPath.startsWith(sourceFolder + "/") || sourceFolder === "") {
|
|
3226
|
+
const relative2 = sourceFolder ? targetPath.slice(sourceFolder.length + 1) : targetPath;
|
|
3227
|
+
return "./" + relative2;
|
|
3228
|
+
}
|
|
3229
|
+
const upsNeeded = sourceParts.length - commonLength;
|
|
3230
|
+
const downs = targetParts.slice(commonLength);
|
|
3231
|
+
if (upsNeeded <= 1) {
|
|
3232
|
+
return "../".repeat(upsNeeded) + downs.join("/");
|
|
3233
|
+
}
|
|
3234
|
+
return null;
|
|
3235
|
+
} else {
|
|
3236
|
+
if (targetPath.startsWith(sourceFolder + "/") || sourceFolder === "") {
|
|
3237
|
+
const relative2 = sourceFolder ? targetPath.slice(sourceFolder.length + 1) : targetPath;
|
|
3238
|
+
if (!relative2.includes("/")) {
|
|
3239
|
+
return "../" + relative2;
|
|
3240
|
+
}
|
|
3241
|
+
return "../" + relative2;
|
|
3242
|
+
}
|
|
3243
|
+
const upsNeeded = sourceParts.length - commonLength + 1;
|
|
3244
|
+
const downs = targetParts.slice(commonLength);
|
|
3245
|
+
if (upsNeeded <= 2) {
|
|
3246
|
+
return "../".repeat(upsNeeded) + downs.join("/");
|
|
3247
|
+
}
|
|
3248
|
+
return null;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
function processFile(filePath, contentDir, assetExtensions) {
|
|
3252
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
3253
|
+
const fixes = [];
|
|
3254
|
+
const patterns = [
|
|
3255
|
+
{ regex: /(\]\()\/docs\/([^)#\s"]+)(\))/g },
|
|
3256
|
+
{ regex: /(href=")\/docs\/([^"#]+)(")/g }
|
|
3257
|
+
];
|
|
3258
|
+
for (const { regex } of patterns) {
|
|
3259
|
+
regex.lastIndex = 0;
|
|
3260
|
+
let match;
|
|
3261
|
+
while ((match = regex.exec(content)) !== null) {
|
|
3262
|
+
const targetPath = match[2];
|
|
3263
|
+
if (!targetPath) continue;
|
|
3264
|
+
if (isAssetLink2(targetPath, assetExtensions)) continue;
|
|
3265
|
+
if (!pathExists(targetPath, contentDir)) continue;
|
|
3266
|
+
const relativePath = calculateRelativePath(filePath, targetPath, contentDir);
|
|
3267
|
+
if (relativePath) {
|
|
3268
|
+
fixes.push({
|
|
3269
|
+
from: `/docs/${targetPath}`,
|
|
3270
|
+
to: relativePath,
|
|
3271
|
+
line: content.substring(0, match.index).split("\n").length
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return fixes;
|
|
3277
|
+
}
|
|
3278
|
+
function applyFixes(filePath, fixes) {
|
|
3279
|
+
let content = fs4.readFileSync(filePath, "utf-8");
|
|
3280
|
+
for (const { from, to } of fixes) {
|
|
3281
|
+
content = content.split(from).join(to);
|
|
3282
|
+
}
|
|
3283
|
+
fs4.writeFileSync(filePath, content, "utf-8");
|
|
3284
|
+
}
|
|
3285
|
+
function fixContentLinks(contentDir, options = {}) {
|
|
3286
|
+
const { apply = false, config: config2 } = options;
|
|
3287
|
+
const assetExtensions = config2?.assetExtensions || [
|
|
3288
|
+
".png",
|
|
3289
|
+
".jpg",
|
|
3290
|
+
".jpeg",
|
|
3291
|
+
".gif",
|
|
3292
|
+
".svg",
|
|
3293
|
+
".webp",
|
|
3294
|
+
".ico",
|
|
3295
|
+
".pdf"
|
|
3296
|
+
];
|
|
3297
|
+
const files = getAllMdxFiles(contentDir);
|
|
3298
|
+
let totalChanges = 0;
|
|
3299
|
+
const fileChanges = [];
|
|
3300
|
+
for (const file of files) {
|
|
3301
|
+
const fixes = processFile(file, contentDir, assetExtensions);
|
|
3302
|
+
if (fixes.length > 0) {
|
|
3303
|
+
const relativePath = path.relative(contentDir, file);
|
|
3304
|
+
fileChanges.push({
|
|
3305
|
+
file: relativePath,
|
|
3306
|
+
fullPath: file,
|
|
3307
|
+
fixes
|
|
3308
|
+
});
|
|
3309
|
+
totalChanges += fixes.length;
|
|
3310
|
+
if (apply) {
|
|
3311
|
+
applyFixes(file, fixes);
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
return {
|
|
3316
|
+
totalChanges,
|
|
3317
|
+
fileChanges,
|
|
3318
|
+
applied: apply
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
async function getMeta(dir) {
|
|
3322
|
+
const metaPath = path.join(dir, "_meta.ts");
|
|
3323
|
+
if (!fs4.existsSync(metaPath)) return {};
|
|
3324
|
+
try {
|
|
3325
|
+
const meta = await import(metaPath);
|
|
3326
|
+
return meta.default || {};
|
|
3327
|
+
} catch {
|
|
3328
|
+
try {
|
|
3329
|
+
const content = fs4.readFileSync(metaPath, "utf-8");
|
|
3330
|
+
const matches = content.matchAll(/'([^']+)':\s*['"]([^'"]+)['"]/g);
|
|
3331
|
+
const result = {};
|
|
3332
|
+
for (const match of matches) {
|
|
3333
|
+
const key = match[1];
|
|
3334
|
+
const value = match[2];
|
|
3335
|
+
if (key && value) {
|
|
3336
|
+
result[key] = value;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
return result;
|
|
3340
|
+
} catch {
|
|
3341
|
+
return {};
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
function getTitleFromMeta(key, meta) {
|
|
3346
|
+
const value = meta[key];
|
|
3347
|
+
if (!value) return key;
|
|
3348
|
+
if (typeof value === "string") return value;
|
|
3349
|
+
if (typeof value === "object" && value !== null && "title" in value) {
|
|
3350
|
+
return String(value.title);
|
|
3351
|
+
}
|
|
3352
|
+
return key;
|
|
3353
|
+
}
|
|
3354
|
+
async function scanContent(dir, baseUrl = "/docs") {
|
|
3355
|
+
const items = [];
|
|
3356
|
+
if (!fs4.existsSync(dir)) return items;
|
|
3357
|
+
const meta = await getMeta(dir);
|
|
3358
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
3359
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
3360
|
+
for (const entry of entries) {
|
|
3361
|
+
const key = entry.name.replace(/\.mdx?$/, "");
|
|
3362
|
+
fileMap.set(key, entry);
|
|
3363
|
+
}
|
|
3364
|
+
const metaKeys = Object.keys(meta);
|
|
3365
|
+
for (const key of metaKeys) {
|
|
3366
|
+
const entry = fileMap.get(key);
|
|
3367
|
+
if (!entry) continue;
|
|
3368
|
+
fileMap.delete(key);
|
|
3369
|
+
const fullPath = path.join(dir, entry.name);
|
|
3370
|
+
const itemPath = path.join(baseUrl, key).replace(/\\/g, "/");
|
|
3371
|
+
if (entry.isDirectory()) {
|
|
3372
|
+
const children = await scanContent(fullPath, itemPath);
|
|
3373
|
+
items.push({
|
|
3374
|
+
title: getTitleFromMeta(key, meta),
|
|
3375
|
+
path: itemPath,
|
|
3376
|
+
children: children.length > 0 ? children : void 0
|
|
3377
|
+
});
|
|
3378
|
+
} else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))) {
|
|
3379
|
+
if (entry.name !== "index.mdx" && entry.name !== "index.md") {
|
|
3380
|
+
items.push({
|
|
3381
|
+
title: getTitleFromMeta(key, meta),
|
|
3382
|
+
path: itemPath
|
|
3383
|
+
});
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
for (const [key, entry] of fileMap.entries()) {
|
|
3388
|
+
if (key.startsWith("_") || key.startsWith(".")) continue;
|
|
3389
|
+
const fullPath = path.join(dir, entry.name);
|
|
3390
|
+
const itemPath = path.join(baseUrl, key).replace(/\\/g, "/");
|
|
3391
|
+
if (entry.isDirectory()) {
|
|
3392
|
+
const children = await scanContent(fullPath, itemPath);
|
|
3393
|
+
items.push({
|
|
3394
|
+
title: key,
|
|
3395
|
+
path: itemPath,
|
|
3396
|
+
children: children.length > 0 ? children : void 0
|
|
3397
|
+
});
|
|
3398
|
+
} else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))) {
|
|
3399
|
+
if (entry.name !== "index.mdx" && entry.name !== "index.md") {
|
|
3400
|
+
items.push({
|
|
3401
|
+
title: key,
|
|
3402
|
+
path: itemPath
|
|
3403
|
+
});
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
return items;
|
|
3408
|
+
}
|
|
3409
|
+
function routeToTitle(routePath) {
|
|
3410
|
+
const segment = routePath.split("/").filter(Boolean).pop() || "Home";
|
|
3411
|
+
if (segment.startsWith("[")) return segment;
|
|
3412
|
+
return segment.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
3413
|
+
}
|
|
3414
|
+
function routesToSitemapItems(routes) {
|
|
3415
|
+
const root = /* @__PURE__ */ new Map();
|
|
3416
|
+
const items = [];
|
|
3417
|
+
const sortedRoutes = [...routes].filter((r) => r.type === "page" && !r.isDynamic).sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
|
3418
|
+
for (const route of sortedRoutes) {
|
|
3419
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
3420
|
+
if (segments.length === 0) {
|
|
3421
|
+
items.push({
|
|
3422
|
+
title: "Home",
|
|
3423
|
+
path: "/"
|
|
3424
|
+
});
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
let currentPath = "";
|
|
3428
|
+
let parentChildren = items;
|
|
3429
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
3430
|
+
currentPath += "/" + segments[i];
|
|
3431
|
+
let parent = root.get(currentPath);
|
|
3432
|
+
if (!parent) {
|
|
3433
|
+
parent = {
|
|
3434
|
+
title: routeToTitle(currentPath),
|
|
3435
|
+
path: currentPath,
|
|
3436
|
+
children: []
|
|
3437
|
+
};
|
|
3438
|
+
root.set(currentPath, parent);
|
|
3439
|
+
parentChildren.push(parent);
|
|
3440
|
+
}
|
|
3441
|
+
parentChildren = parent.children || (parent.children = []);
|
|
3442
|
+
}
|
|
3443
|
+
const item = {
|
|
3444
|
+
title: routeToTitle(route.path),
|
|
3445
|
+
path: route.path
|
|
3446
|
+
};
|
|
3447
|
+
parentChildren.push(item);
|
|
3448
|
+
}
|
|
3449
|
+
return items;
|
|
3450
|
+
}
|
|
3451
|
+
async function generateSitemapData(cwd, config2) {
|
|
3452
|
+
const projectType = detectProjectType(cwd);
|
|
3453
|
+
const contentDir = path.join(cwd, config2?.contentDir || "content");
|
|
3454
|
+
const appDir = path.join(cwd, config2?.appDir || "app");
|
|
3455
|
+
const basePath = config2?.basePath || "/docs";
|
|
3456
|
+
let docsItems = [];
|
|
3457
|
+
let appItems = [];
|
|
3458
|
+
if (projectType === "nextra" && fs4.existsSync(contentDir)) {
|
|
3459
|
+
docsItems = await scanContent(contentDir, basePath);
|
|
3460
|
+
}
|
|
3461
|
+
if (fs4.existsSync(appDir)) {
|
|
3462
|
+
try {
|
|
3463
|
+
const scanResult = scanRoutes({ appDir, includeApi: false });
|
|
3464
|
+
appItems = routesToSitemapItems(scanResult.routes);
|
|
3465
|
+
} catch {
|
|
3466
|
+
appItems = [];
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
return {
|
|
3470
|
+
app: appItems,
|
|
3471
|
+
docs: docsItems
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
function generateTsContent(data) {
|
|
3475
|
+
return `
|
|
3476
|
+
// This file is auto-generated by @djangocfg/seo
|
|
3477
|
+
// Do not edit manually
|
|
3478
|
+
|
|
3479
|
+
export interface SitemapItem {
|
|
3480
|
+
title: string;
|
|
3481
|
+
path: string;
|
|
3482
|
+
children?: SitemapItem[];
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
export const sitemap: { app: SitemapItem[], docs: SitemapItem[] } = ${JSON.stringify(data, null, 2)};
|
|
3486
|
+
`;
|
|
3487
|
+
}
|
|
3488
|
+
async function generateSitemap(cwd, options = {}) {
|
|
3489
|
+
const output = options.output || "app/_core/sitemap.ts";
|
|
3490
|
+
const outputPath = path.join(cwd, output);
|
|
3491
|
+
const data = await generateSitemapData(cwd, options.config);
|
|
3492
|
+
const outputDir = path.dirname(outputPath);
|
|
3493
|
+
if (!fs4.existsSync(outputDir)) {
|
|
3494
|
+
fs4.mkdirSync(outputDir, { recursive: true });
|
|
3495
|
+
}
|
|
3496
|
+
const content = generateTsContent(data);
|
|
3497
|
+
fs4.writeFileSync(outputPath, content);
|
|
3498
|
+
return { outputPath, data };
|
|
3499
|
+
}
|
|
3500
|
+
function flattenSitemap(items) {
|
|
3501
|
+
const paths = [];
|
|
3502
|
+
function traverse(item) {
|
|
3503
|
+
paths.push(item.path);
|
|
3504
|
+
if (item.children) {
|
|
3505
|
+
for (const child of item.children) {
|
|
3506
|
+
traverse(child);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
for (const item of items) {
|
|
3511
|
+
traverse(item);
|
|
3512
|
+
}
|
|
3513
|
+
return paths;
|
|
3514
|
+
}
|
|
3515
|
+
function countSitemapItems(data) {
|
|
3516
|
+
const appPaths = flattenSitemap(data.app);
|
|
3517
|
+
const docsPaths = flattenSitemap(data.docs);
|
|
3518
|
+
return {
|
|
3519
|
+
app: appPaths.length,
|
|
3520
|
+
docs: docsPaths.length,
|
|
3521
|
+
total: appPaths.length + docsPaths.length
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
// src/cli/commands/content.ts
|
|
3526
|
+
var CONTENT_HELP = `
|
|
3527
|
+
${chalk2.bold("Content Commands")} - MDX/Nextra content tools
|
|
3528
|
+
|
|
3529
|
+
${chalk2.bold("Usage:")}
|
|
3530
|
+
djangocfg-seo content <subcommand> [options]
|
|
3531
|
+
|
|
3532
|
+
${chalk2.bold("Subcommands:")}
|
|
3533
|
+
check Check links in content/ directory
|
|
3534
|
+
fix Fix absolute links to relative
|
|
3535
|
+
sitemap Generate sitemap.ts from content/
|
|
3536
|
+
|
|
3537
|
+
${chalk2.bold("Options:")}
|
|
3538
|
+
--content-dir <path> Content directory (default: content/)
|
|
3539
|
+
--output <path> Output file for sitemap (default: app/_core/sitemap.ts)
|
|
3540
|
+
--fix Apply fixes (for 'fix' subcommand)
|
|
3541
|
+
--base-path <path> Base URL path (default: /docs)
|
|
3542
|
+
|
|
3543
|
+
${chalk2.bold("Examples:")}
|
|
3544
|
+
djangocfg-seo content check
|
|
3545
|
+
djangocfg-seo content fix --fix
|
|
3546
|
+
djangocfg-seo content sitemap --output app/_core/sitemap.ts
|
|
3547
|
+
`;
|
|
3548
|
+
async function runContent(options) {
|
|
3549
|
+
const subcommand = options._[1];
|
|
3550
|
+
if (!subcommand || subcommand === "help") {
|
|
3551
|
+
console.log(CONTENT_HELP);
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
const cwd = process.cwd();
|
|
3555
|
+
const contentDir = options["content-dir"] ? path.resolve(cwd, options["content-dir"]) : findContentDir(cwd);
|
|
3556
|
+
if (!contentDir && subcommand !== "sitemap") {
|
|
3557
|
+
consola3.error("Could not find content/ directory. Use --content-dir to specify path.");
|
|
3558
|
+
process.exit(1);
|
|
3559
|
+
}
|
|
3560
|
+
const projectType = detectProjectType(cwd);
|
|
3561
|
+
console.log("");
|
|
3562
|
+
consola3.box(`${chalk2.bold("Content Tools")}
|
|
3563
|
+
Project: ${projectType}
|
|
3564
|
+
Path: ${contentDir || cwd}`);
|
|
3565
|
+
switch (subcommand) {
|
|
3566
|
+
case "check":
|
|
3567
|
+
await runCheck(contentDir, options);
|
|
3568
|
+
break;
|
|
3569
|
+
case "fix":
|
|
3570
|
+
await runFix(contentDir, options);
|
|
3571
|
+
break;
|
|
3572
|
+
case "sitemap":
|
|
3573
|
+
await runSitemapGenerate(cwd, options);
|
|
3574
|
+
break;
|
|
3575
|
+
default:
|
|
3576
|
+
consola3.error(`Unknown subcommand: ${subcommand}`);
|
|
3577
|
+
console.log(CONTENT_HELP);
|
|
3578
|
+
process.exit(1);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
async function runCheck(contentDir, options) {
|
|
3582
|
+
consola3.start("Checking links in content/ folder...");
|
|
3583
|
+
const basePath = options["base-path"] || "/docs";
|
|
3584
|
+
const result = checkContentLinks(contentDir, { basePath });
|
|
3585
|
+
if (result.success) {
|
|
3586
|
+
console.log("");
|
|
3587
|
+
consola3.success("All links are valid!");
|
|
3588
|
+
console.log(` Checked ${result.filesChecked} files, ${result.uniqueLinks} unique links.`);
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
3591
|
+
console.log("");
|
|
3592
|
+
consola3.error(`Found ${result.brokenLinks.length} broken links:`);
|
|
3593
|
+
console.log("");
|
|
3594
|
+
const byFile = groupBrokenLinksByFile(result.brokenLinks);
|
|
3595
|
+
for (const [file, links] of byFile) {
|
|
3596
|
+
console.log(`${chalk2.cyan("\u{1F4C4}")} ${file}`);
|
|
3597
|
+
for (const link of links) {
|
|
3598
|
+
console.log(` L${link.line}: ${chalk2.red("\u2717")} ${link.link} ${chalk2.dim(`(${link.type}: "${link.raw}")`)}`);
|
|
3599
|
+
}
|
|
3600
|
+
console.log("");
|
|
3601
|
+
}
|
|
3602
|
+
console.log(`${chalk2.bold("Summary:")} ${result.brokenLinks.length} broken links in ${byFile.size} files`);
|
|
3603
|
+
console.log(` Checked ${result.filesChecked} files, ${result.uniqueLinks} unique links.`);
|
|
3604
|
+
process.exit(1);
|
|
3605
|
+
}
|
|
3606
|
+
async function runFix(contentDir, options) {
|
|
3607
|
+
const applyFixes2 = options.fix === true;
|
|
3608
|
+
consola3.start(applyFixes2 ? "Fixing links..." : "Checking for absolute links that can be relative...");
|
|
3609
|
+
const result = fixContentLinks(contentDir, { apply: applyFixes2 });
|
|
3610
|
+
if (result.totalChanges === 0) {
|
|
3611
|
+
console.log("");
|
|
3612
|
+
consola3.success("No absolute links that can be converted to relative.");
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
console.log("");
|
|
3616
|
+
console.log(`Found ${result.totalChanges} links that can be relative:`);
|
|
3617
|
+
console.log("");
|
|
3618
|
+
for (const { file, fixes } of result.fileChanges) {
|
|
3619
|
+
console.log(`${chalk2.cyan("\u{1F4C4}")} ${file}`);
|
|
3620
|
+
for (const { from, to, line } of fixes) {
|
|
3621
|
+
console.log(` L${line}: ${from} ${chalk2.yellow("\u2192")} ${to}`);
|
|
3622
|
+
}
|
|
3623
|
+
console.log("");
|
|
3624
|
+
}
|
|
3625
|
+
if (applyFixes2) {
|
|
3626
|
+
consola3.success(`Fixed ${result.totalChanges} links in ${result.fileChanges.length} files.`);
|
|
3627
|
+
} else {
|
|
3628
|
+
console.log(`${chalk2.yellow("\u{1F4A1}")} Run with --fix to apply changes:`);
|
|
3629
|
+
console.log(` djangocfg-seo content fix --fix`);
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
async function runSitemapGenerate(cwd, options) {
|
|
3633
|
+
consola3.start("Generating sitemap...");
|
|
3634
|
+
const rawOutput = options.output;
|
|
3635
|
+
const output = rawOutput?.endsWith(".ts") ? rawOutput : "app/_core/sitemap.ts";
|
|
3636
|
+
const contentDir = options["content-dir"] || "content";
|
|
3637
|
+
const basePath = options["base-path"] || "/docs";
|
|
3638
|
+
const { outputPath, data } = await generateSitemap(cwd, {
|
|
3639
|
+
output,
|
|
3640
|
+
config: { contentDir, basePath }
|
|
3641
|
+
});
|
|
3642
|
+
const counts = countSitemapItems(data);
|
|
3643
|
+
console.log("");
|
|
3644
|
+
consola3.success(`Sitemap generated at ${outputPath}`);
|
|
3645
|
+
console.log(` \u251C\u2500\u2500 App pages: ${counts.app}`);
|
|
3646
|
+
console.log(` \u251C\u2500\u2500 Doc pages: ${counts.docs}`);
|
|
3647
|
+
console.log(` \u2514\u2500\u2500 Total: ${counts.total}`);
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// src/cli/index.ts
|
|
3651
|
+
loadEnvFiles();
|
|
3652
|
+
var VERSION = "1.0.0";
|
|
3653
|
+
var HELP = `
|
|
3654
|
+
${chalk2.bold("@djangocfg/seo")} - SEO Analysis Tool v${VERSION}
|
|
3655
|
+
|
|
3656
|
+
${chalk2.bold("Usage:")}
|
|
3657
|
+
djangocfg-seo <command> [options]
|
|
3658
|
+
|
|
3659
|
+
${chalk2.bold("Commands:")}
|
|
3660
|
+
audit Full SEO audit (robots + sitemap + crawl + links)
|
|
3661
|
+
routes Scan app/ directory and compare with sitemap
|
|
3662
|
+
content MDX content tools (check, fix, sitemap)
|
|
3663
|
+
crawl Crawl site and analyze SEO issues
|
|
3664
|
+
links Check all links for broken links
|
|
3665
|
+
robots Analyze robots.txt
|
|
3666
|
+
sitemap Validate sitemap
|
|
3667
|
+
inspect Inspect URLs via Google Search Console API
|
|
3668
|
+
|
|
3669
|
+
${chalk2.bold("Options:")}
|
|
3670
|
+
--env, -e Environment: prod (default) or dev
|
|
3671
|
+
--site, -s Site URL (overrides env detection)
|
|
3672
|
+
--output, -o Output directory for reports
|
|
3673
|
+
--format, -f Report format: split (default), json, markdown, ai-summary, all
|
|
3674
|
+
--max-pages Maximum pages to crawl (default: 100)
|
|
3675
|
+
--max-depth Maximum crawl depth (default: 3)
|
|
3676
|
+
--timeout Request timeout in ms (default: 60000)
|
|
3677
|
+
--concurrency Max concurrent requests (default: 50)
|
|
3678
|
+
--service-account Path to Google service account JSON
|
|
3679
|
+
--app-dir Path to app/ directory (for routes command)
|
|
3680
|
+
--content-dir Path to content/ directory (for content command)
|
|
3681
|
+
--base-path Base URL path for docs (default: /docs)
|
|
3682
|
+
--check Compare routes with sitemap
|
|
3683
|
+
--verify Verify routes are accessible
|
|
3684
|
+
--fix Apply fixes (for content fix command)
|
|
3685
|
+
--help, -h Show this help
|
|
3686
|
+
--version, -v Show version
|
|
3687
|
+
|
|
3688
|
+
${chalk2.bold("Examples:")}
|
|
3689
|
+
${chalk2.dim("# Full SEO audit")}
|
|
3690
|
+
djangocfg-seo audit
|
|
3691
|
+
|
|
3692
|
+
${chalk2.dim("# Scan app routes and compare with sitemap")}
|
|
3693
|
+
djangocfg-seo routes --check
|
|
3694
|
+
|
|
3695
|
+
${chalk2.dim("# Check links on production")}
|
|
3696
|
+
djangocfg-seo links
|
|
3697
|
+
|
|
3698
|
+
${chalk2.dim("# Check MDX content links")}
|
|
3699
|
+
djangocfg-seo content check
|
|
3700
|
+
|
|
3701
|
+
${chalk2.dim("# Fix absolute links to relative")}
|
|
3702
|
+
djangocfg-seo content fix --fix
|
|
3703
|
+
|
|
3704
|
+
${chalk2.dim("# Generate sitemap.ts from content")}
|
|
3705
|
+
djangocfg-seo content sitemap
|
|
3706
|
+
`;
|
|
3707
|
+
async function main() {
|
|
3708
|
+
const { values, positionals } = parseArgs({
|
|
3709
|
+
allowPositionals: true,
|
|
3710
|
+
options: {
|
|
3711
|
+
env: { type: "string", short: "e", default: "prod" },
|
|
3712
|
+
site: { type: "string", short: "s" },
|
|
3713
|
+
output: { type: "string", short: "o", default: "./seo-reports" },
|
|
3714
|
+
urls: { type: "string", short: "u" },
|
|
3715
|
+
"max-pages": { type: "string", default: "100" },
|
|
3716
|
+
"max-depth": { type: "string", default: "3" },
|
|
3717
|
+
timeout: { type: "string", default: "60000" },
|
|
3718
|
+
concurrency: { type: "string", default: "50" },
|
|
3719
|
+
format: { type: "string", short: "f", default: "split" },
|
|
3720
|
+
"service-account": { type: "string" },
|
|
3721
|
+
"app-dir": { type: "string" },
|
|
3722
|
+
"content-dir": { type: "string" },
|
|
3723
|
+
"base-path": { type: "string" },
|
|
3724
|
+
check: { type: "boolean" },
|
|
3725
|
+
verify: { type: "boolean" },
|
|
3726
|
+
fix: { type: "boolean" },
|
|
3727
|
+
help: { type: "boolean", short: "h" },
|
|
3728
|
+
version: { type: "boolean", short: "v" }
|
|
3729
|
+
}
|
|
3730
|
+
});
|
|
3731
|
+
if (values.version) {
|
|
3732
|
+
console.log(VERSION);
|
|
3733
|
+
process.exit(0);
|
|
3734
|
+
}
|
|
3735
|
+
if (values.help || positionals.length === 0) {
|
|
3736
|
+
console.log(HELP);
|
|
3737
|
+
process.exit(0);
|
|
3738
|
+
}
|
|
3739
|
+
const command = positionals[0];
|
|
3740
|
+
const options = { ...values, _: positionals };
|
|
3741
|
+
try {
|
|
3742
|
+
switch (command) {
|
|
3743
|
+
case "audit":
|
|
3744
|
+
case "report":
|
|
3745
|
+
await runAudit(options);
|
|
3746
|
+
break;
|
|
3747
|
+
case "routes":
|
|
3748
|
+
await runRoutes(options);
|
|
3749
|
+
break;
|
|
3750
|
+
case "inspect":
|
|
3751
|
+
await runInspect(options);
|
|
3752
|
+
break;
|
|
3753
|
+
case "crawl":
|
|
3754
|
+
await runCrawl(options);
|
|
3755
|
+
break;
|
|
3756
|
+
case "links":
|
|
3757
|
+
await runLinks(options);
|
|
3758
|
+
break;
|
|
3759
|
+
case "robots":
|
|
3760
|
+
await runRobots(options);
|
|
3761
|
+
break;
|
|
3762
|
+
case "sitemap":
|
|
3763
|
+
await runSitemap(options);
|
|
3764
|
+
break;
|
|
3765
|
+
case "content":
|
|
3766
|
+
await runContent(options);
|
|
3767
|
+
break;
|
|
3768
|
+
default:
|
|
3769
|
+
consola3.error(`Unknown command: ${command}`);
|
|
3770
|
+
console.log(HELP);
|
|
3771
|
+
process.exit(1);
|
|
3772
|
+
}
|
|
3773
|
+
} catch (error) {
|
|
3774
|
+
consola3.error(error);
|
|
3775
|
+
process.exit(1);
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
main().catch(consola3.error);
|
|
3779
|
+
//# sourceMappingURL=cli.mjs.map
|
|
3780
|
+
//# sourceMappingURL=cli.mjs.map
|