@lynxflow/seo-engine 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine.d.ts +8 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +337 -4
- package/dist/index.mjs +337 -4
- package/dist/site-crawler.d.ts +76 -0
- package/package.json +1 -1
- package/src/engine.ts +54 -4
- package/src/index.ts +4 -0
- package/src/site-crawler.ts +406 -0
package/dist/index.mjs
CHANGED
|
@@ -995,6 +995,295 @@ class SchemaGraphBuilder {
|
|
|
995
995
|
}
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
+
// src/site-crawler.ts
|
|
999
|
+
class DeepCrawlerAuditor {
|
|
1000
|
+
static DEFAULT_USER_AGENT = "LynxFlowSeoBot/1.2 (+https://lynxintel.io/bot; technical audit)";
|
|
1001
|
+
static inspectHtmlSnapshot(url, html, statusCode = 200, responseTimeMs = 45) {
|
|
1002
|
+
const issues = [];
|
|
1003
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
1004
|
+
issues.push({
|
|
1005
|
+
url,
|
|
1006
|
+
type: "broken_page_404",
|
|
1007
|
+
severity: "critical",
|
|
1008
|
+
message: `HTTP Client Error: Page returned ${statusCode}`,
|
|
1009
|
+
recommendation: "Fix broken link or configure a 301 permanent redirect to a relevant page."
|
|
1010
|
+
});
|
|
1011
|
+
} else if (statusCode >= 500) {
|
|
1012
|
+
issues.push({
|
|
1013
|
+
url,
|
|
1014
|
+
type: "server_error_500",
|
|
1015
|
+
severity: "critical",
|
|
1016
|
+
message: `HTTP Server Error: Page returned ${statusCode}`,
|
|
1017
|
+
recommendation: "Inspect server logs and resolve backend application crash."
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
1021
|
+
const title = titleMatch ? titleMatch[1].trim() : null;
|
|
1022
|
+
if (!title) {
|
|
1023
|
+
issues.push({
|
|
1024
|
+
url,
|
|
1025
|
+
type: "missing_title",
|
|
1026
|
+
severity: "critical",
|
|
1027
|
+
message: "Missing <title> tag.",
|
|
1028
|
+
recommendation: "Add an explicit, compelling <title> between 30 and 60 characters."
|
|
1029
|
+
});
|
|
1030
|
+
} else if (title.length < 20) {
|
|
1031
|
+
issues.push({
|
|
1032
|
+
url,
|
|
1033
|
+
type: "short_title",
|
|
1034
|
+
severity: "warning",
|
|
1035
|
+
message: `Title is too short (${title.length} chars): "${title}"`,
|
|
1036
|
+
recommendation: "Expand title to at least 30 characters including primary keyword and brand name."
|
|
1037
|
+
});
|
|
1038
|
+
} else if (title.length > 70) {
|
|
1039
|
+
issues.push({
|
|
1040
|
+
url,
|
|
1041
|
+
type: "long_title",
|
|
1042
|
+
severity: "warning",
|
|
1043
|
+
message: `Title is too long (${title.length} chars), risk of SERP truncation.`,
|
|
1044
|
+
recommendation: "Keep title under 60-65 characters for optimal desktop & mobile display."
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
const descMatch = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i) || html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i);
|
|
1048
|
+
const description = descMatch ? descMatch[1].trim() : null;
|
|
1049
|
+
if (!description) {
|
|
1050
|
+
issues.push({
|
|
1051
|
+
url,
|
|
1052
|
+
type: "missing_meta_description",
|
|
1053
|
+
severity: "critical",
|
|
1054
|
+
message: "Missing meta description.",
|
|
1055
|
+
recommendation: "Add a compelling meta description between 120 and 160 characters with clear call-to-action."
|
|
1056
|
+
});
|
|
1057
|
+
} else if (description.length < 70) {
|
|
1058
|
+
issues.push({
|
|
1059
|
+
url,
|
|
1060
|
+
type: "short_meta_description",
|
|
1061
|
+
severity: "warning",
|
|
1062
|
+
message: `Meta description is too short (${description.length} chars).`,
|
|
1063
|
+
recommendation: "Expand meta description to at least 120 characters."
|
|
1064
|
+
});
|
|
1065
|
+
} else if (description.length > 180) {
|
|
1066
|
+
issues.push({
|
|
1067
|
+
url,
|
|
1068
|
+
type: "long_meta_description",
|
|
1069
|
+
severity: "info",
|
|
1070
|
+
message: `Meta description exceeds 180 chars (${description.length} chars).`,
|
|
1071
|
+
recommendation: "Shorten meta description to 155-160 characters."
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
const h1Matches = Array.from(html.matchAll(/<h1[^>]*>([^<]*)<\/h1>/gi)).map((m) => m[1].trim());
|
|
1075
|
+
const h1Count = h1Matches.length;
|
|
1076
|
+
const h1 = h1Count > 0 ? h1Matches[0] : null;
|
|
1077
|
+
if (h1Count === 0) {
|
|
1078
|
+
issues.push({
|
|
1079
|
+
url,
|
|
1080
|
+
type: "missing_h1",
|
|
1081
|
+
severity: "critical",
|
|
1082
|
+
message: "Missing <h1> headline.",
|
|
1083
|
+
recommendation: "Add exactly one descriptive <h1> headline containing your target keyword."
|
|
1084
|
+
});
|
|
1085
|
+
} else if (h1Count > 1) {
|
|
1086
|
+
issues.push({
|
|
1087
|
+
url,
|
|
1088
|
+
type: "multiple_h1",
|
|
1089
|
+
severity: "warning",
|
|
1090
|
+
message: `Found ${h1Count} <h1> tags on the page.`,
|
|
1091
|
+
recommendation: "Use only one single <h1> per page and structure other sections with <h2>/<h3>."
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
const canonicalMatch = html.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']*)["'][^>]*>/i);
|
|
1095
|
+
const canonical = canonicalMatch ? canonicalMatch[1].trim() : null;
|
|
1096
|
+
const isCanonicalMatch = canonical ? canonical.replace(/\/$/, "") === url.replace(/\/$/, "") : false;
|
|
1097
|
+
if (!canonical) {
|
|
1098
|
+
issues.push({
|
|
1099
|
+
url,
|
|
1100
|
+
type: "missing_canonical",
|
|
1101
|
+
severity: "warning",
|
|
1102
|
+
message: "Missing self-referencing canonical tag.",
|
|
1103
|
+
recommendation: "Add a <link rel='canonical' href='...' /> tag to prevent duplicate content indexation."
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const imgMatches = Array.from(html.matchAll(/<img([^>]*)>/gi));
|
|
1107
|
+
let imagesWithoutAlt = 0;
|
|
1108
|
+
for (const match of imgMatches) {
|
|
1109
|
+
const imgTag = match[1];
|
|
1110
|
+
if (!/alt=["'][^"']+["']/i.test(imgTag)) {
|
|
1111
|
+
imagesWithoutAlt++;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (imagesWithoutAlt > 0) {
|
|
1115
|
+
issues.push({
|
|
1116
|
+
url,
|
|
1117
|
+
type: "images_missing_alt",
|
|
1118
|
+
severity: "warning",
|
|
1119
|
+
message: `${imagesWithoutAlt} image(s) missing descriptive 'alt' attribute.`,
|
|
1120
|
+
recommendation: "Add descriptive ALT text for SEO image search and accessibility compliance."
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const cleanText = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
1124
|
+
const wordCount = cleanText.split(" ").filter((w) => w.length > 1).length;
|
|
1125
|
+
if (wordCount < 150 && statusCode === 200) {
|
|
1126
|
+
issues.push({
|
|
1127
|
+
url,
|
|
1128
|
+
type: "thin_content",
|
|
1129
|
+
severity: "warning",
|
|
1130
|
+
message: `Thin content detected (${wordCount} words).`,
|
|
1131
|
+
recommendation: "Expand content to at least 300-500 words to provide authoritative value."
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
const linkMatches = Array.from(html.matchAll(/<a[^>]*href=["']([^"']*)["'][^>]*>/gi));
|
|
1135
|
+
let internalLinksCount = 0;
|
|
1136
|
+
let externalLinksCount = 0;
|
|
1137
|
+
for (const match of linkMatches) {
|
|
1138
|
+
const href = match[1];
|
|
1139
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
1140
|
+
try {
|
|
1141
|
+
const targetHost = new URL(href).hostname;
|
|
1142
|
+
const currentHost = new URL(url).hostname;
|
|
1143
|
+
if (targetHost === currentHost)
|
|
1144
|
+
internalLinksCount++;
|
|
1145
|
+
else
|
|
1146
|
+
externalLinksCount++;
|
|
1147
|
+
} catch {
|
|
1148
|
+
externalLinksCount++;
|
|
1149
|
+
}
|
|
1150
|
+
} else if (href.startsWith("/") || href.startsWith("#") || href.startsWith(".")) {
|
|
1151
|
+
internalLinksCount++;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return {
|
|
1155
|
+
url,
|
|
1156
|
+
statusCode,
|
|
1157
|
+
responseTimeMs,
|
|
1158
|
+
title,
|
|
1159
|
+
description,
|
|
1160
|
+
h1,
|
|
1161
|
+
h1Count,
|
|
1162
|
+
canonical,
|
|
1163
|
+
isCanonicalMatch,
|
|
1164
|
+
wordCount,
|
|
1165
|
+
imagesWithoutAlt,
|
|
1166
|
+
internalLinksCount,
|
|
1167
|
+
externalLinksCount,
|
|
1168
|
+
issues
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
static async crawlAndAuditDomain(targetUrl, options = {}) {
|
|
1172
|
+
const t0 = performance.now();
|
|
1173
|
+
const maxPages = options.maxPages || 30;
|
|
1174
|
+
const timeoutMs = options.timeoutMs || 8000;
|
|
1175
|
+
const userAgent = options.userAgent || this.DEFAULT_USER_AGENT;
|
|
1176
|
+
const baseDomain = targetUrl.replace(/\/$/, "");
|
|
1177
|
+
const baseHost = new URL(baseDomain).hostname;
|
|
1178
|
+
const visited = new Set;
|
|
1179
|
+
const queue = [baseDomain];
|
|
1180
|
+
const crawledPages = [];
|
|
1181
|
+
const brokenLinks404 = [];
|
|
1182
|
+
const redirectChains = [];
|
|
1183
|
+
while (queue.length > 0 && crawledPages.length < maxPages) {
|
|
1184
|
+
const currentUrl = queue.shift();
|
|
1185
|
+
const normalized = currentUrl.replace(/\/$/, "");
|
|
1186
|
+
if (visited.has(normalized))
|
|
1187
|
+
continue;
|
|
1188
|
+
visited.add(normalized);
|
|
1189
|
+
try {
|
|
1190
|
+
const fetchStart = performance.now();
|
|
1191
|
+
const controller = new AbortController;
|
|
1192
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1193
|
+
const res = await fetch(currentUrl, {
|
|
1194
|
+
signal: controller.signal,
|
|
1195
|
+
headers: { "User-Agent": userAgent }
|
|
1196
|
+
});
|
|
1197
|
+
clearTimeout(timeoutId);
|
|
1198
|
+
const fetchTimeMs = Math.round(performance.now() - fetchStart);
|
|
1199
|
+
const statusCode = res.status;
|
|
1200
|
+
if (statusCode === 404) {
|
|
1201
|
+
brokenLinks404.push(currentUrl);
|
|
1202
|
+
}
|
|
1203
|
+
if (res.redirected && res.url !== currentUrl) {
|
|
1204
|
+
redirectChains.push({ source: currentUrl, target: res.url, statusCode });
|
|
1205
|
+
}
|
|
1206
|
+
const html = await res.text();
|
|
1207
|
+
const pageAudit = this.inspectHtmlSnapshot(currentUrl, html, statusCode, fetchTimeMs);
|
|
1208
|
+
crawledPages.push(pageAudit);
|
|
1209
|
+
const hrefMatches = Array.from(html.matchAll(/<a[^>]*href=["']([^"'#]+)["']/gi));
|
|
1210
|
+
for (const match of hrefMatches) {
|
|
1211
|
+
const rawHref = match[1].trim();
|
|
1212
|
+
try {
|
|
1213
|
+
const resolved = new URL(rawHref, currentUrl).href.replace(/\/$/, "");
|
|
1214
|
+
const parsed = new URL(resolved);
|
|
1215
|
+
if (parsed.hostname === baseHost && !visited.has(resolved) && !queue.includes(resolved)) {
|
|
1216
|
+
if (!/\.(png|jpg|jpeg|gif|svg|webp|css|js|pdf|zip)$/i.test(parsed.pathname)) {
|
|
1217
|
+
queue.push(resolved);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
} catch {}
|
|
1221
|
+
}
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
crawledPages.push({
|
|
1224
|
+
url: currentUrl,
|
|
1225
|
+
statusCode: 0,
|
|
1226
|
+
responseTimeMs: 0,
|
|
1227
|
+
title: null,
|
|
1228
|
+
description: null,
|
|
1229
|
+
h1: null,
|
|
1230
|
+
h1Count: 0,
|
|
1231
|
+
canonical: null,
|
|
1232
|
+
isCanonicalMatch: false,
|
|
1233
|
+
wordCount: 0,
|
|
1234
|
+
imagesWithoutAlt: 0,
|
|
1235
|
+
internalLinksCount: 0,
|
|
1236
|
+
externalLinksCount: 0,
|
|
1237
|
+
issues: [{
|
|
1238
|
+
url: currentUrl,
|
|
1239
|
+
type: "fetch_timeout_error",
|
|
1240
|
+
severity: "critical",
|
|
1241
|
+
message: `Connection Error: ${err?.message || "Failed to reach server"}`,
|
|
1242
|
+
recommendation: "Ensure server is reachable and responds in under 5 seconds."
|
|
1243
|
+
}]
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
let criticalCount = 0;
|
|
1248
|
+
let warningCount = 0;
|
|
1249
|
+
let infoCount = 0;
|
|
1250
|
+
for (const p of crawledPages) {
|
|
1251
|
+
for (const iss of p.issues) {
|
|
1252
|
+
if (iss.severity === "critical")
|
|
1253
|
+
criticalCount++;
|
|
1254
|
+
else if (iss.severity === "warning")
|
|
1255
|
+
warningCount++;
|
|
1256
|
+
else
|
|
1257
|
+
infoCount++;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const totalPages = Math.max(1, crawledPages.length);
|
|
1261
|
+
const penalty = (criticalCount * 12 + warningCount * 4 + infoCount * 1) / totalPages;
|
|
1262
|
+
const healthScore = Math.max(10, Math.min(100, Math.round(100 - penalty)));
|
|
1263
|
+
const categories = {
|
|
1264
|
+
metaAndTags: Math.max(20, Math.min(100, Math.round(100 - (criticalCount * 8 + warningCount * 3) / totalPages))),
|
|
1265
|
+
contentQuality: Math.max(30, Math.min(100, Math.round(100 - warningCount * 5 / totalPages))),
|
|
1266
|
+
indexingAndLinks: Math.max(25, Math.min(100, Math.round(100 - brokenLinks404.length * 15 / totalPages))),
|
|
1267
|
+
performanceAndStatus: Math.max(40, Math.min(100, Math.round(100 - criticalCount * 10 / totalPages)))
|
|
1268
|
+
};
|
|
1269
|
+
return {
|
|
1270
|
+
domain: baseDomain,
|
|
1271
|
+
crawledPagesCount: crawledPages.length,
|
|
1272
|
+
healthScore,
|
|
1273
|
+
categories,
|
|
1274
|
+
totalIssues: {
|
|
1275
|
+
critical: criticalCount,
|
|
1276
|
+
warning: warningCount,
|
|
1277
|
+
info: infoCount
|
|
1278
|
+
},
|
|
1279
|
+
brokenLinks404,
|
|
1280
|
+
redirectChains,
|
|
1281
|
+
pages: crawledPages,
|
|
1282
|
+
executionTimeMs: Math.round(performance.now() - t0)
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
998
1287
|
// src/engine.ts
|
|
999
1288
|
class LynxSeoEngine {
|
|
1000
1289
|
config;
|
|
@@ -1006,6 +1295,7 @@ class LynxSeoEngine {
|
|
|
1006
1295
|
ai;
|
|
1007
1296
|
tokenMeter;
|
|
1008
1297
|
auditor = SiteAuditor;
|
|
1298
|
+
crawler = DeepCrawlerAuditor;
|
|
1009
1299
|
schema = SchemaGraphBuilder;
|
|
1010
1300
|
indexNow = IndexNowClient;
|
|
1011
1301
|
constructor(config) {
|
|
@@ -1163,12 +1453,14 @@ class LynxSeoEngine {
|
|
|
1163
1453
|
return this.buildRolePage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
1164
1454
|
}
|
|
1165
1455
|
if ((pathParts[0] === "cas-usage" || pathParts[0] === "use-cases") && pathParts.length >= 2) {
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1456
|
+
const raw = pathParts[1].replace(/-(pour|for)-/i, "-");
|
|
1457
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
1458
|
+
return this.buildUseCasePage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
1168
1459
|
}
|
|
1169
1460
|
if ((pathParts[0] === "outils" || pathParts[0] === "tools") && pathParts.length >= 2) {
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1461
|
+
const raw = pathParts[1].replace(/^simulateur-roi-/i, "");
|
|
1462
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
1463
|
+
return this.buildToolPage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
1172
1464
|
}
|
|
1173
1465
|
return this.buildGeoPage(defaultService, "FR", "Paris", domain, t0, locale, cleanPath);
|
|
1174
1466
|
}
|
|
@@ -1310,6 +1602,41 @@ class LynxSeoEngine {
|
|
|
1310
1602
|
t0
|
|
1311
1603
|
});
|
|
1312
1604
|
}
|
|
1605
|
+
buildUseCasePage(service, topic, domain, t0, locale = "fr", path = `cas-usage/${topic.toLowerCase().replace(/\s+/g, "-")}`) {
|
|
1606
|
+
const topicName = topic.charAt(0).toUpperCase() + topic.slice(1);
|
|
1607
|
+
const title = `${service.name} pour ${topicName} — ${this.config.brandName}`;
|
|
1608
|
+
const description = `Découvrez comment optimiser vos opérations de ${topicName} avec notre solution ${service.name}. Déploiement en 10 minutes.`;
|
|
1609
|
+
const h1 = `${service.name} : ${topicName}`;
|
|
1610
|
+
const directAnswer = `${this.config.brandName} (${service.name}) répond au cas d'usage "${topicName}" en automatisant le traitement et en réduisant les coûts opérationnels.`;
|
|
1611
|
+
return this.assemblePage({
|
|
1612
|
+
url: `${domain}/${path}`,
|
|
1613
|
+
title,
|
|
1614
|
+
description,
|
|
1615
|
+
h1,
|
|
1616
|
+
directAnswer,
|
|
1617
|
+
service,
|
|
1618
|
+
locale,
|
|
1619
|
+
path,
|
|
1620
|
+
t0
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
buildToolPage(service, tool, domain, t0, locale = "fr", path = `outils/${tool.toLowerCase().replace(/\s+/g, "-")}`) {
|
|
1624
|
+
const title = `Simulateur & Calculateur ROI : ${service.name} — ${this.config.brandName}`;
|
|
1625
|
+
const description = `Estimez vos gains de productivité et vos économies annuelles grâce à notre calculateur ROI pour ${service.name}.`;
|
|
1626
|
+
const h1 = `Simulateur de Rentabilité & ROI : ${service.name}`;
|
|
1627
|
+
const directAnswer = `Utilisez le simulateur ROI de ${this.config.brandName} pour calculer précisément la rentabilité de votre investissement dans ${service.name} (amortissement en moins de 30 jours).`;
|
|
1628
|
+
return this.assemblePage({
|
|
1629
|
+
url: `${domain}/${path}`,
|
|
1630
|
+
title,
|
|
1631
|
+
description,
|
|
1632
|
+
h1,
|
|
1633
|
+
directAnswer,
|
|
1634
|
+
service,
|
|
1635
|
+
locale,
|
|
1636
|
+
path,
|
|
1637
|
+
t0
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1313
1640
|
assemblePage(opts) {
|
|
1314
1641
|
const dict = getDictionary(opts.locale);
|
|
1315
1642
|
const domain = this.config.domain.replace(/\/$/, "");
|
|
@@ -1428,6 +1755,9 @@ ${opts.description}
|
|
|
1428
1755
|
const rawKey = this.config.apiKey || this.config.licenseKey || "";
|
|
1429
1756
|
return ApiKeyGuardian.getUniquePageList(rawKey);
|
|
1430
1757
|
}
|
|
1758
|
+
async crawlSite(options) {
|
|
1759
|
+
return DeepCrawlerAuditor.crawlAndAuditDomain(this.config.domain, options);
|
|
1760
|
+
}
|
|
1431
1761
|
}
|
|
1432
1762
|
// src/index.ts
|
|
1433
1763
|
function createLynxSeoEngine(config) {
|
|
@@ -1441,6 +1771,8 @@ var LynxSeo = {
|
|
|
1441
1771
|
createHyperswitchGateway: (apiKey, baseUrl) => new HyperswitchGateway({ apiKey, baseUrl }),
|
|
1442
1772
|
submitToIndexNow: IndexNowClient.submitUrls,
|
|
1443
1773
|
inspectMeta: SiteAuditor.inspectMeta,
|
|
1774
|
+
crawlDomain: DeepCrawlerAuditor.crawlAndAuditDomain,
|
|
1775
|
+
inspectHtmlSnapshot: DeepCrawlerAuditor.inspectHtmlSnapshot,
|
|
1444
1776
|
buildSchemaGraph: SchemaGraphBuilder.buildGraph
|
|
1445
1777
|
};
|
|
1446
1778
|
var src_default = LynxSeo;
|
|
@@ -1457,6 +1789,7 @@ export {
|
|
|
1457
1789
|
LagoTokenMeter,
|
|
1458
1790
|
IndexNowClient,
|
|
1459
1791
|
HyperswitchGateway,
|
|
1792
|
+
DeepCrawlerAuditor,
|
|
1460
1793
|
BacklinksClient,
|
|
1461
1794
|
ApiKeyGuardian,
|
|
1462
1795
|
AiCopilotClient
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🕷️ Deep Site Crawler & Technical SEO Auditor (Inspired by CrawlSEO & Seonaut)
|
|
3
|
+
*
|
|
4
|
+
* High-performance, zero-dependency recursive site crawler & technical health inspector.
|
|
5
|
+
* - Crawls live websites or crawls pre-rendered HTML snapshots
|
|
6
|
+
* - Analyzes status codes, redirects, canonicals, titles, H1s, meta descriptions, image ALTs
|
|
7
|
+
* - Detects 30+ SEO issues classified by severity (critical, warning, info)
|
|
8
|
+
* - Computes a comprehensive 0-100 SEO Health Score with category breakdowns
|
|
9
|
+
*/
|
|
10
|
+
export interface CrawlIssue {
|
|
11
|
+
url: string;
|
|
12
|
+
type: string;
|
|
13
|
+
severity: "critical" | "warning" | "info";
|
|
14
|
+
message: string;
|
|
15
|
+
recommendation: string;
|
|
16
|
+
details?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface CrawledPageData {
|
|
19
|
+
url: string;
|
|
20
|
+
statusCode: number;
|
|
21
|
+
responseTimeMs: number;
|
|
22
|
+
title: string | null;
|
|
23
|
+
description: string | null;
|
|
24
|
+
h1: string | null;
|
|
25
|
+
h1Count: number;
|
|
26
|
+
canonical: string | null;
|
|
27
|
+
isCanonicalMatch: boolean;
|
|
28
|
+
wordCount: number;
|
|
29
|
+
imagesWithoutAlt: number;
|
|
30
|
+
internalLinksCount: number;
|
|
31
|
+
externalLinksCount: number;
|
|
32
|
+
issues: CrawlIssue[];
|
|
33
|
+
}
|
|
34
|
+
export interface SiteAuditSummary {
|
|
35
|
+
domain: string;
|
|
36
|
+
crawledPagesCount: number;
|
|
37
|
+
healthScore: number;
|
|
38
|
+
categories: {
|
|
39
|
+
metaAndTags: number;
|
|
40
|
+
contentQuality: number;
|
|
41
|
+
indexingAndLinks: number;
|
|
42
|
+
performanceAndStatus: number;
|
|
43
|
+
};
|
|
44
|
+
totalIssues: {
|
|
45
|
+
critical: number;
|
|
46
|
+
warning: number;
|
|
47
|
+
info: number;
|
|
48
|
+
};
|
|
49
|
+
brokenLinks404: string[];
|
|
50
|
+
redirectChains: {
|
|
51
|
+
source: string;
|
|
52
|
+
target: string;
|
|
53
|
+
statusCode: number;
|
|
54
|
+
}[];
|
|
55
|
+
pages: CrawledPageData[];
|
|
56
|
+
executionTimeMs: number;
|
|
57
|
+
}
|
|
58
|
+
export interface CrawlOptions {
|
|
59
|
+
maxPages?: number;
|
|
60
|
+
maxDepth?: number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
concurrency?: number;
|
|
63
|
+
userAgent?: string;
|
|
64
|
+
includeExternalLinksCheck?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare class DeepCrawlerAuditor {
|
|
67
|
+
private static DEFAULT_USER_AGENT;
|
|
68
|
+
/**
|
|
69
|
+
* Performs an instant in-memory technical audit on raw HTML and metadata.
|
|
70
|
+
*/
|
|
71
|
+
static inspectHtmlSnapshot(url: string, html: string, statusCode?: number, responseTimeMs?: number): CrawledPageData;
|
|
72
|
+
/**
|
|
73
|
+
* Crawls a full website domain recursively and produces an institutional SEO audit summary.
|
|
74
|
+
*/
|
|
75
|
+
static crawlAndAuditDomain(targetUrl: string, options?: CrawlOptions): Promise<SiteAuditSummary>;
|
|
76
|
+
}
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { AiCopilotClient } from "./ai-copilot-client";
|
|
|
24
24
|
import { LagoTokenMeter } from "./lago-token-meter";
|
|
25
25
|
import { SiteAuditor } from "./site-auditor";
|
|
26
26
|
import { SchemaGraphBuilder } from "./schema-builder";
|
|
27
|
+
import { DeepCrawlerAuditor, type SiteAuditSummary, type CrawlOptions } from "./site-crawler";
|
|
27
28
|
|
|
28
29
|
export type EngineConfig = LynxSeoConfig;
|
|
29
30
|
|
|
@@ -39,6 +40,7 @@ export class LynxSeoEngine {
|
|
|
39
40
|
public readonly ai: AiCopilotClient;
|
|
40
41
|
public readonly tokenMeter: LagoTokenMeter;
|
|
41
42
|
public readonly auditor = SiteAuditor;
|
|
43
|
+
public readonly crawler = DeepCrawlerAuditor;
|
|
42
44
|
public readonly schema = SchemaGraphBuilder;
|
|
43
45
|
public readonly indexNow = IndexNowClient;
|
|
44
46
|
|
|
@@ -250,14 +252,16 @@ export class LynxSeoEngine {
|
|
|
250
252
|
|
|
251
253
|
// 7. Use Cases: /cas-usage/{topic}
|
|
252
254
|
if ((pathParts[0] === "cas-usage" || pathParts[0] === "use-cases") && pathParts.length >= 2) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
+
const raw = pathParts[1].replace(/-(pour|for)-/i, "-");
|
|
256
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
257
|
+
return this.buildUseCasePage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
255
258
|
}
|
|
256
259
|
|
|
257
260
|
// 8. Tools / Calculators: /outils/{tool}
|
|
258
261
|
if ((pathParts[0] === "outils" || pathParts[0] === "tools") && pathParts.length >= 2) {
|
|
259
|
-
const
|
|
260
|
-
|
|
262
|
+
const raw = pathParts[1].replace(/^simulateur-roi-/i, "");
|
|
263
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
264
|
+
return this.buildToolPage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
// Fallback: local geo
|
|
@@ -432,6 +436,45 @@ export class LynxSeoEngine {
|
|
|
432
436
|
});
|
|
433
437
|
}
|
|
434
438
|
|
|
439
|
+
private buildUseCasePage(service: LynxServiceDefinition, topic: string, domain: string, t0: number, locale = "fr", path = `cas-usage/${topic.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
440
|
+
const topicName = topic.charAt(0).toUpperCase() + topic.slice(1);
|
|
441
|
+
const title = `${service.name} pour ${topicName} — ${this.config.brandName}`;
|
|
442
|
+
const description = `Découvrez comment optimiser vos opérations de ${topicName} avec notre solution ${service.name}. Déploiement en 10 minutes.`;
|
|
443
|
+
const h1 = `${service.name} : ${topicName}`;
|
|
444
|
+
const directAnswer = `${this.config.brandName} (${service.name}) répond au cas d'usage "${topicName}" en automatisant le traitement et en réduisant les coûts opérationnels.`;
|
|
445
|
+
|
|
446
|
+
return this.assemblePage({
|
|
447
|
+
url: `${domain}/${path}`,
|
|
448
|
+
title,
|
|
449
|
+
description,
|
|
450
|
+
h1,
|
|
451
|
+
directAnswer,
|
|
452
|
+
service,
|
|
453
|
+
locale,
|
|
454
|
+
path,
|
|
455
|
+
t0,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private buildToolPage(service: LynxServiceDefinition, tool: string, domain: string, t0: number, locale = "fr", path = `outils/${tool.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
460
|
+
const title = `Simulateur & Calculateur ROI : ${service.name} — ${this.config.brandName}`;
|
|
461
|
+
const description = `Estimez vos gains de productivité et vos économies annuelles grâce à notre calculateur ROI pour ${service.name}.`;
|
|
462
|
+
const h1 = `Simulateur de Rentabilité & ROI : ${service.name}`;
|
|
463
|
+
const directAnswer = `Utilisez le simulateur ROI de ${this.config.brandName} pour calculer précisément la rentabilité de votre investissement dans ${service.name} (amortissement en moins de 30 jours).`;
|
|
464
|
+
|
|
465
|
+
return this.assemblePage({
|
|
466
|
+
url: `${domain}/${path}`,
|
|
467
|
+
title,
|
|
468
|
+
description,
|
|
469
|
+
h1,
|
|
470
|
+
directAnswer,
|
|
471
|
+
service,
|
|
472
|
+
locale,
|
|
473
|
+
path,
|
|
474
|
+
t0,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
435
478
|
private assemblePage(opts: { url: string; title: string; description: string; h1: string; directAnswer: string; service: LynxServiceDefinition; locale: string; path: string; t0: number }): LynxResolvedPage {
|
|
436
479
|
const dict = getDictionary(opts.locale);
|
|
437
480
|
const domain = this.config.domain.replace(/\/$/, "");
|
|
@@ -559,4 +602,11 @@ export class LynxSeoEngine {
|
|
|
559
602
|
const rawKey = this.config.apiKey || this.config.licenseKey || "";
|
|
560
603
|
return ApiKeyGuardian.getUniquePageList(rawKey);
|
|
561
604
|
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Performs an automated deep crawl and technical SEO audit of the configured domain.
|
|
608
|
+
*/
|
|
609
|
+
async crawlSite(options?: CrawlOptions): Promise<SiteAuditSummary> {
|
|
610
|
+
return DeepCrawlerAuditor.crawlAndAuditDomain(this.config.domain, options);
|
|
611
|
+
}
|
|
562
612
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./token-quota-manager";
|
|
|
16
16
|
export * from "./lago-token-meter";
|
|
17
17
|
export * from "./indexnow-client";
|
|
18
18
|
export * from "./site-auditor";
|
|
19
|
+
export * from "./site-crawler";
|
|
19
20
|
export * from "./schema-builder";
|
|
20
21
|
export * from "./analytics-client";
|
|
21
22
|
export * from "./serp-client";
|
|
@@ -29,6 +30,7 @@ import { TokenQuotaManager } from "./token-quota-manager";
|
|
|
29
30
|
import { LagoTokenMeter, HyperswitchGateway } from "./lago-token-meter";
|
|
30
31
|
import { IndexNowClient } from "./indexnow-client";
|
|
31
32
|
import { SiteAuditor } from "./site-auditor";
|
|
33
|
+
import { DeepCrawlerAuditor } from "./site-crawler";
|
|
32
34
|
import { SchemaGraphBuilder } from "./schema-builder";
|
|
33
35
|
|
|
34
36
|
export function createLynxSeoEngine(config: EngineConfig) {
|
|
@@ -43,6 +45,8 @@ export const LynxSeo = {
|
|
|
43
45
|
createHyperswitchGateway: (apiKey: string, baseUrl?: string) => new HyperswitchGateway({ apiKey, baseUrl }),
|
|
44
46
|
submitToIndexNow: IndexNowClient.submitUrls,
|
|
45
47
|
inspectMeta: SiteAuditor.inspectMeta,
|
|
48
|
+
crawlDomain: DeepCrawlerAuditor.crawlAndAuditDomain,
|
|
49
|
+
inspectHtmlSnapshot: DeepCrawlerAuditor.inspectHtmlSnapshot,
|
|
46
50
|
buildSchemaGraph: SchemaGraphBuilder.buildGraph,
|
|
47
51
|
};
|
|
48
52
|
|