aeorank 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -28
- package/dist/browser.d.ts +524 -0
- package/dist/browser.js +4796 -0
- package/dist/browser.js.map +1 -0
- package/dist/{chunk-3IJISYWT.js → chunk-PKJIKMLV.js} +2 -2
- package/dist/chunk-PKJIKMLV.js.map +1 -0
- package/dist/cli.js +331 -54
- package/dist/cli.js.map +1 -1
- package/dist/{full-site-crawler-F7J2HRL4.js → full-site-crawler-FQYO46YV.js} +2 -2
- package/dist/full-site-crawler-FQYO46YV.js.map +1 -0
- package/dist/{full-site-crawler-VFARFR2C.js → full-site-crawler-UIOMKOZA.js} +2 -2
- package/dist/index.cjs +1657 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +143 -2
- package/dist/index.d.ts +143 -2
- package/dist/index.js +1649 -56
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
- package/dist/chunk-3IJISYWT.js.map +0 -1
- package/dist/full-site-crawler-F7J2HRL4.js.map +0 -1
- /package/dist/{full-site-crawler-VFARFR2C.js.map → full-site-crawler-UIOMKOZA.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -303,7 +303,7 @@ var init_full_site_crawler = __esm({
|
|
|
303
303
|
RESOURCE_EXTENSIONS = /\.(js|css|png|jpg|jpeg|gif|svg|ico|pdf|xml|txt|woff|woff2|ttf|eot|mp4|mp3|webp|avif|zip|gz|tar|json)$/i;
|
|
304
304
|
SKIP_PATH_PATTERNS = /^\/(api|wp-admin|wp-json|static|assets|_next|auth|login|signup|cart|checkout|admin|feed|xmlrpc)\b/i;
|
|
305
305
|
CATEGORY_PATTERNS = [
|
|
306
|
-
[/\/(blog|articles?|posts?|news|insights|guides)\b/i, "blog"],
|
|
306
|
+
[/\/([^/]*-?)?(blog|articles?|posts?|news|insights|guides)\b/i, "blog"],
|
|
307
307
|
[/\/(about|about-us|company|who-we-are)\b/i, "about"],
|
|
308
308
|
[/\/(pricing|plans|packages)\b/i, "pricing"],
|
|
309
309
|
[/\/(services?|features?|solutions?|products?|what-we-do|offerings?)\b/i, "services"],
|
|
@@ -326,21 +326,28 @@ __export(index_exports, {
|
|
|
326
326
|
audit: () => audit,
|
|
327
327
|
auditSiteFromData: () => auditSiteFromData,
|
|
328
328
|
buildDetailedFindings: () => buildDetailedFindings,
|
|
329
|
+
buildLinkGraph: () => buildLinkGraph,
|
|
329
330
|
buildScorecard: () => buildScorecard,
|
|
331
|
+
calculateDepths: () => calculateDepths,
|
|
330
332
|
calculateOverallScore: () => calculateOverallScore,
|
|
331
333
|
classifyRendering: () => classifyRendering,
|
|
332
334
|
compare: () => compare,
|
|
333
335
|
crawlFullSite: () => crawlFullSite,
|
|
336
|
+
detectClusters: () => detectClusters,
|
|
337
|
+
detectHubs: () => detectHubs,
|
|
334
338
|
detectParkedDomain: () => detectParkedDomain,
|
|
339
|
+
detectPillars: () => detectPillars,
|
|
335
340
|
extractAllUrlsFromSitemap: () => extractAllUrlsFromSitemap,
|
|
336
341
|
extractContentPagesFromSitemap: () => extractContentPagesFromSitemap,
|
|
337
342
|
extractInternalLinks: () => extractInternalLinks,
|
|
343
|
+
extractLinksWithAnchors: () => extractLinksWithAnchors,
|
|
338
344
|
extractNavLinks: () => extractNavLinks,
|
|
339
345
|
extractRawDataSummary: () => extractRawDataSummary,
|
|
340
346
|
fetchMultiPageData: () => fetchMultiPageData,
|
|
341
347
|
fetchWithHeadless: () => fetchWithHeadless,
|
|
342
348
|
generateBottomLine: () => generateBottomLine,
|
|
343
349
|
generateComparisonHtmlReport: () => generateComparisonHtmlReport,
|
|
350
|
+
generateFixPlan: () => generateFixPlan,
|
|
344
351
|
generateHtmlReport: () => generateHtmlReport,
|
|
345
352
|
generateOpportunities: () => generateOpportunities,
|
|
346
353
|
generatePitchNumbers: () => generatePitchNumbers,
|
|
@@ -350,7 +357,8 @@ __export(index_exports, {
|
|
|
350
357
|
prefetchSiteData: () => prefetchSiteData,
|
|
351
358
|
scoreAllPages: () => scoreAllPages,
|
|
352
359
|
scorePage: () => scorePage,
|
|
353
|
-
scoreToStatus: () => scoreToStatus
|
|
360
|
+
scoreToStatus: () => scoreToStatus,
|
|
361
|
+
serializeLinkGraph: () => serializeLinkGraph
|
|
354
362
|
});
|
|
355
363
|
module.exports = __toCommonJS(index_exports);
|
|
356
364
|
|
|
@@ -544,7 +552,7 @@ async function prefetchSiteData(domain) {
|
|
|
544
552
|
sitemapForBlog = subSitemap.text;
|
|
545
553
|
}
|
|
546
554
|
}
|
|
547
|
-
const blogUrls = extractBlogUrlsFromSitemap(sitemapForBlog, domain,
|
|
555
|
+
const blogUrls = extractBlogUrlsFromSitemap(sitemapForBlog, domain, 50);
|
|
548
556
|
if (blogUrls.length > 0) {
|
|
549
557
|
const fetched = await Promise.all(blogUrls.map((url) => fetchText(url)));
|
|
550
558
|
blogSample = fetched.filter(
|
|
@@ -901,15 +909,17 @@ function checkOriginalData(data) {
|
|
|
901
909
|
findings.push({ severity: "critical", detail: "Could not fetch homepage" });
|
|
902
910
|
return { criterion: "original_data", criterion_label: "Original Data & Expert Content", score: 0, status: "not_found", findings, fix_priority: "P2" };
|
|
903
911
|
}
|
|
912
|
+
const allPages = [data.homepage, ...data.blogSample || []].filter(Boolean);
|
|
904
913
|
const html = data.homepage.text;
|
|
905
|
-
const
|
|
914
|
+
const allText = allPages.map((p) => p.text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ")).join(" ");
|
|
915
|
+
const text = data.homepage.text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ");
|
|
906
916
|
let score = 0;
|
|
907
917
|
const statPatterns = /\d+%|\d+\s*(patients|clients|customers|cases|years|professionals|specialists|companies|users|businesses|domains|audits)/i;
|
|
908
|
-
if (statPatterns.test(
|
|
918
|
+
if (statPatterns.test(allText)) {
|
|
909
919
|
const researchContext = /\b(our\s+(?:study|analysis|research|data|survey|findings|report)|we\s+(?:surveyed|analyzed|studied|measured|tracked)|proprietary|methodology|original\s+research)\b/i;
|
|
910
|
-
if (researchContext.test(
|
|
920
|
+
if (researchContext.test(allText)) {
|
|
911
921
|
score += 3;
|
|
912
|
-
findings.push({ severity: "info", detail: "Proprietary statistics with research context found
|
|
922
|
+
findings.push({ severity: "info", detail: "Proprietary statistics with research context found" });
|
|
913
923
|
} else {
|
|
914
924
|
score += 1;
|
|
915
925
|
findings.push({ severity: "low", detail: 'Statistics found but without research context (e.g., "500+ clients")', fix: 'Add context about your methodology: "Our analysis of X found..." or "We surveyed Y..."' });
|
|
@@ -1424,20 +1434,24 @@ function checkFactDensity(data) {
|
|
|
1424
1434
|
findings.push({ severity: "critical", detail: "Could not fetch homepage" });
|
|
1425
1435
|
return { criterion: "fact_density", criterion_label: "Fact & Data Density", score: 0, status: "not_found", findings, fix_priority: "P2" };
|
|
1426
1436
|
}
|
|
1427
|
-
const
|
|
1437
|
+
const allPages = [data.homepage, ...data.blogSample || []].filter(Boolean);
|
|
1438
|
+
const allText = allPages.map((p) => p.text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ")).join(" ");
|
|
1439
|
+
const text = allText;
|
|
1440
|
+
const pageCount = allPages.length;
|
|
1428
1441
|
let score = 0;
|
|
1429
1442
|
const dataPoints = text.match(/\d+(?:\.\d+)?(?:\s*%|\s*\$|\s*USD|\s*EUR)/g) || [];
|
|
1430
1443
|
const countPhrases = text.match(/\d+(?:,\d{3})*\+?\s+(?:users?|clients?|customers?|companies|businesses|patients?|members?|employees?|projects?|downloads?)/gi) || [];
|
|
1431
1444
|
const totalDataPoints = dataPoints.length + countPhrases.length;
|
|
1432
|
-
|
|
1445
|
+
const avgPerPage = pageCount > 0 ? totalDataPoints / pageCount : 0;
|
|
1446
|
+
if (avgPerPage >= 4) {
|
|
1433
1447
|
score += 5;
|
|
1434
|
-
findings.push({ severity: "info", detail: `${totalDataPoints} quantitative data points found
|
|
1435
|
-
} else if (
|
|
1448
|
+
findings.push({ severity: "info", detail: `${totalDataPoints} quantitative data points found across ${pageCount} pages (avg ${avgPerPage.toFixed(1)}/page)` });
|
|
1449
|
+
} else if (avgPerPage >= 2) {
|
|
1436
1450
|
score += 3;
|
|
1437
|
-
findings.push({ severity: "info", detail: `${totalDataPoints} quantitative data points found` });
|
|
1451
|
+
findings.push({ severity: "info", detail: `${totalDataPoints} quantitative data points found across ${pageCount} pages` });
|
|
1438
1452
|
} else if (totalDataPoints >= 1) {
|
|
1439
1453
|
score += 1;
|
|
1440
|
-
findings.push({ severity: "low", detail: `Only ${totalDataPoints} quantitative data point(s) found`, fix: "Add more specific numbers, percentages, and metrics to strengthen credibility" });
|
|
1454
|
+
findings.push({ severity: "low", detail: `Only ${totalDataPoints} quantitative data point(s) found across ${pageCount} pages`, fix: "Add more specific numbers, percentages, and metrics to strengthen credibility" });
|
|
1441
1455
|
} else {
|
|
1442
1456
|
findings.push({ severity: "high", detail: "No quantitative data points found", fix: "Add specific statistics (percentages, counts, comparisons) that AI engines can cite" });
|
|
1443
1457
|
}
|
|
@@ -1543,9 +1557,9 @@ function countRecentSitemapDates(sitemapText) {
|
|
|
1543
1557
|
distinctRecentDays: recentDays.size
|
|
1544
1558
|
};
|
|
1545
1559
|
}
|
|
1546
|
-
var BLOG_PATH_PATTERNS = /\/(?:blog|articles?|insights?|guides?|resources?|news|posts?|learn|help|how-?to|tutorials?|case-stud|whitepapers?)\b/i;
|
|
1560
|
+
var BLOG_PATH_PATTERNS = /\/(?:[^/]*-?)?(?:blog|articles?|insights?|guides?|resources?|news|posts?|learn|help|how-?to|tutorials?|case-stud|whitepapers?)\b/i;
|
|
1547
1561
|
var EXCLUDE_PATH_PATTERNS = /\/(?:tag|category|author|page|feed|wp-content|wp-admin|wp-json|cart|checkout|login|search|api|static|assets|_next)\b/i;
|
|
1548
|
-
function extractBlogUrlsFromSitemap(sitemapText, domain, limit =
|
|
1562
|
+
function extractBlogUrlsFromSitemap(sitemapText, domain, limit = 50) {
|
|
1549
1563
|
const urlBlocks = sitemapText.match(/<url>([\s\S]*?)<\/url>/gi) || [];
|
|
1550
1564
|
const candidates = [];
|
|
1551
1565
|
const cleanDomain = domain.replace(/^www\./, "").toLowerCase();
|
|
@@ -1841,7 +1855,7 @@ function jaccardSimilarity(a, b) {
|
|
|
1841
1855
|
const union = a.size + b.size - intersection;
|
|
1842
1856
|
return union === 0 ? 0 : intersection / union;
|
|
1843
1857
|
}
|
|
1844
|
-
function checkContentCannibalization(data) {
|
|
1858
|
+
function checkContentCannibalization(data, topicCoherenceScore) {
|
|
1845
1859
|
const findings = [];
|
|
1846
1860
|
if (!data.homepage) {
|
|
1847
1861
|
findings.push({ severity: "critical", detail: "No homepage available for cannibalization analysis" });
|
|
@@ -1851,7 +1865,7 @@ function checkContentCannibalization(data) {
|
|
|
1851
1865
|
{ html: data.homepage.text, url: data.homepage.finalUrl || `https://${data.domain}/` }
|
|
1852
1866
|
];
|
|
1853
1867
|
if (data.blogSample) {
|
|
1854
|
-
for (const page of data.blogSample
|
|
1868
|
+
for (const page of data.blogSample) {
|
|
1855
1869
|
pages.push({ html: page.text, url: page.finalUrl || "" });
|
|
1856
1870
|
}
|
|
1857
1871
|
}
|
|
@@ -1861,10 +1875,29 @@ function checkContentCannibalization(data) {
|
|
|
1861
1875
|
}
|
|
1862
1876
|
const pageTitles = pages.map((p) => ({ title: extractPageTitle(p.html), url: p.url }));
|
|
1863
1877
|
const wordSets = pageTitles.map((p) => titleToWordSet(p.title));
|
|
1878
|
+
const termPageCount = /* @__PURE__ */ new Map();
|
|
1879
|
+
for (const ws of wordSets) {
|
|
1880
|
+
for (const w of ws) {
|
|
1881
|
+
termPageCount.set(w, (termPageCount.get(w) || 0) + 1);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const commonTermThreshold = Math.max(3, pages.length * 0.4);
|
|
1885
|
+
const siteThemeTerms = /* @__PURE__ */ new Set();
|
|
1886
|
+
for (const [term, count] of termPageCount) {
|
|
1887
|
+
if (count >= commonTermThreshold) siteThemeTerms.add(term);
|
|
1888
|
+
}
|
|
1889
|
+
const filteredSets = wordSets.map((ws) => {
|
|
1890
|
+
const filtered = /* @__PURE__ */ new Set();
|
|
1891
|
+
for (const w of ws) {
|
|
1892
|
+
if (!siteThemeTerms.has(w)) filtered.add(w);
|
|
1893
|
+
}
|
|
1894
|
+
return filtered;
|
|
1895
|
+
});
|
|
1864
1896
|
const cannibalPairs = [];
|
|
1865
1897
|
for (let i = 0; i < pages.length; i++) {
|
|
1866
1898
|
for (let j = i + 1; j < pages.length; j++) {
|
|
1867
|
-
|
|
1899
|
+
if (filteredSets[i].size === 0 && filteredSets[j].size === 0) continue;
|
|
1900
|
+
const sim = jaccardSimilarity(filteredSets[i], filteredSets[j]);
|
|
1868
1901
|
if (sim > 0.6) {
|
|
1869
1902
|
cannibalPairs.push({
|
|
1870
1903
|
urlA: pageTitles[i].url.slice(0, 60),
|
|
@@ -1874,23 +1907,39 @@ function checkContentCannibalization(data) {
|
|
|
1874
1907
|
}
|
|
1875
1908
|
}
|
|
1876
1909
|
}
|
|
1910
|
+
const cannibalUrls = /* @__PURE__ */ new Set();
|
|
1911
|
+
for (const pair of cannibalPairs) {
|
|
1912
|
+
cannibalUrls.add(pair.urlA);
|
|
1913
|
+
cannibalUrls.add(pair.urlB);
|
|
1914
|
+
}
|
|
1915
|
+
const cannibalRatio = pages.length > 0 ? cannibalUrls.size / pages.length : 0;
|
|
1877
1916
|
let score;
|
|
1878
1917
|
if (cannibalPairs.length === 0) {
|
|
1879
1918
|
score = 10;
|
|
1880
1919
|
findings.push({ severity: "info", detail: `${pages.length} pages analyzed - no content cannibalization detected` });
|
|
1881
|
-
} else if (
|
|
1882
|
-
score =
|
|
1883
|
-
findings.push({ severity: "
|
|
1884
|
-
} else if (
|
|
1920
|
+
} else if (cannibalRatio <= 0.05) {
|
|
1921
|
+
score = 9;
|
|
1922
|
+
findings.push({ severity: "info", detail: `${cannibalPairs.length} pair(s) of pages with minor topic overlap (${cannibalUrls.size}/${pages.length} pages affected)` });
|
|
1923
|
+
} else if (cannibalRatio <= 0.1) {
|
|
1924
|
+
score = 7;
|
|
1925
|
+
findings.push({ severity: "low", detail: `${cannibalUrls.size} pages (${Math.round(cannibalRatio * 100)}%) have overlapping topics`, fix: "Differentiate titles and H1 headings to reduce topic overlap" });
|
|
1926
|
+
} else if (cannibalRatio <= 0.2) {
|
|
1885
1927
|
score = 5;
|
|
1886
|
-
findings.push({ severity: "medium", detail: `${
|
|
1928
|
+
findings.push({ severity: "medium", detail: `${cannibalUrls.size} pages (${Math.round(cannibalRatio * 100)}%) competing for overlapping topics`, fix: "Consolidate overlapping pages or differentiate their titles and content focus" });
|
|
1929
|
+
} else if (cannibalRatio <= 0.4) {
|
|
1930
|
+
score = 3;
|
|
1931
|
+
findings.push({ severity: "medium", detail: `${cannibalUrls.size} pages (${Math.round(cannibalRatio * 100)}%) have significant content overlap`, fix: "Many pages compete for the same topics - consolidate or clearly differentiate them" });
|
|
1887
1932
|
} else {
|
|
1888
1933
|
score = 0;
|
|
1889
|
-
findings.push({ severity: "high", detail: `${
|
|
1934
|
+
findings.push({ severity: "high", detail: `${cannibalUrls.size} pages (${Math.round(cannibalRatio * 100)}%) competing for the same topics`, fix: "Severe content cannibalization - consolidate overlapping pages or create clear topic differentiation" });
|
|
1890
1935
|
}
|
|
1891
1936
|
for (const pair of cannibalPairs.slice(0, 3)) {
|
|
1892
1937
|
findings.push({ severity: "low", detail: `Overlap (${pair.similarity}%): ${pair.urlA} vs ${pair.urlB}` });
|
|
1893
1938
|
}
|
|
1939
|
+
if (topicCoherenceScore !== void 0 && topicCoherenceScore <= 4 && score >= 8) {
|
|
1940
|
+
score = 6;
|
|
1941
|
+
findings.push({ severity: "low", detail: "Low topic overlap but content lacks coherent focus - not a strong signal for AI authority", fix: "Focus content on fewer core topics to build topical authority that AI engines can identify" });
|
|
1942
|
+
}
|
|
1894
1943
|
return { criterion: "content_cannibalization", criterion_label: "Content Cannibalization", score, status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P1" };
|
|
1895
1944
|
}
|
|
1896
1945
|
function checkVisibleDateSignal(data) {
|
|
@@ -2116,7 +2165,233 @@ function extractRawDataSummary(data) {
|
|
|
2116
2165
|
crawl_skipped: data.crawlStats?.skipped ?? 0
|
|
2117
2166
|
};
|
|
2118
2167
|
}
|
|
2168
|
+
function getPageTopicText(html) {
|
|
2169
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
2170
|
+
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
2171
|
+
return [
|
|
2172
|
+
titleMatch?.[1] || "",
|
|
2173
|
+
h1Match?.[1]?.replace(/<[^>]*>/g, "") || ""
|
|
2174
|
+
].join(" ").toLowerCase().trim();
|
|
2175
|
+
}
|
|
2176
|
+
function extractBigrams(text) {
|
|
2177
|
+
const words = text.split(/[\s,.!?;:()\[\]{}"'\/&]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !/^\d+$/.test(w));
|
|
2178
|
+
const bigrams = [];
|
|
2179
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
2180
|
+
bigrams.push(words[i] + " " + words[i + 1]);
|
|
2181
|
+
}
|
|
2182
|
+
return bigrams;
|
|
2183
|
+
}
|
|
2184
|
+
function checkTopicCoherence(data) {
|
|
2185
|
+
const findings = [];
|
|
2186
|
+
if (!data.homepage) {
|
|
2187
|
+
findings.push({ severity: "critical", detail: "Could not fetch homepage" });
|
|
2188
|
+
return { criterion: "topic_coherence", criterion_label: "Topic Coherence", score: 0, status: "not_found", findings, fix_priority: "P0" };
|
|
2189
|
+
}
|
|
2190
|
+
if (!data.blogSample || data.blogSample.length < 3) {
|
|
2191
|
+
findings.push({ severity: "info", detail: `Only ${data.blogSample?.length || 0} blog pages found - insufficient for topic coherence analysis` });
|
|
2192
|
+
return { criterion: "topic_coherence", criterion_label: "Topic Coherence", score: 5, status: "partial", findings, fix_priority: "P2" };
|
|
2193
|
+
}
|
|
2194
|
+
const blogPages = data.blogSample;
|
|
2195
|
+
const domainBase = data.domain.replace(/^www\./, "").replace(/\.(com|org|net|io|co|ai)$/i, "").toLowerCase();
|
|
2196
|
+
const brandWords = /* @__PURE__ */ new Set();
|
|
2197
|
+
brandWords.add(domainBase);
|
|
2198
|
+
for (const part of domainBase.split(/[-_]/)) {
|
|
2199
|
+
if (part.length > 2) brandWords.add(part);
|
|
2200
|
+
}
|
|
2201
|
+
const rawTermFreq = /* @__PURE__ */ new Map();
|
|
2202
|
+
const pageTitleTexts = [];
|
|
2203
|
+
for (const page of blogPages) {
|
|
2204
|
+
const topicText = getPageTopicText(page.text);
|
|
2205
|
+
pageTitleTexts.push(topicText);
|
|
2206
|
+
const words = topicText.split(/[\s,.!?;:()\[\]{}"'\/&]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !/^\d+$/.test(w));
|
|
2207
|
+
const uniqueWords = new Set(words);
|
|
2208
|
+
for (const w of uniqueWords) {
|
|
2209
|
+
rawTermFreq.set(w, (rawTermFreq.get(w) || 0) + 1);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
for (const [term, count] of rawTermFreq) {
|
|
2213
|
+
if (count / blogPages.length >= 0.8 && domainBase.includes(term)) {
|
|
2214
|
+
brandWords.add(term);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
const termFreq = /* @__PURE__ */ new Map();
|
|
2218
|
+
for (const page of blogPages) {
|
|
2219
|
+
const topicText = getPageTopicText(page.text);
|
|
2220
|
+
const words = topicText.split(/[\s,.!?;:()\[\]{}"'\/&]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !/^\d+$/.test(w) && !brandWords.has(w));
|
|
2221
|
+
const uniqueWords = new Set(words);
|
|
2222
|
+
for (const w of uniqueWords) {
|
|
2223
|
+
termFreq.set(w, (termFreq.get(w) || 0) + 1);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
const sortedTerms = [...termFreq.entries()].sort((a, b) => b[1] - a[1]);
|
|
2227
|
+
const topTerm = sortedTerms[0];
|
|
2228
|
+
const bigramFreq = /* @__PURE__ */ new Map();
|
|
2229
|
+
const pageBigrams = [];
|
|
2230
|
+
for (const topicText of pageTitleTexts) {
|
|
2231
|
+
const bigrams = extractBigrams(topicText).filter((bg) => !bg.split(" ").some((w) => brandWords.has(w)));
|
|
2232
|
+
pageBigrams.push(bigrams);
|
|
2233
|
+
const uniqueBigrams = new Set(bigrams);
|
|
2234
|
+
for (const bg of uniqueBigrams) {
|
|
2235
|
+
bigramFreq.set(bg, (bigramFreq.get(bg) || 0) + 1);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
const sortedBigrams = [...bigramFreq.entries()].sort((a, b) => b[1] - a[1]);
|
|
2239
|
+
const topBigram = sortedBigrams[0];
|
|
2240
|
+
const significantBigrams = sortedBigrams.filter(([, count]) => count >= 2);
|
|
2241
|
+
const clusterRoots = [];
|
|
2242
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
2243
|
+
for (const [bg] of significantBigrams) {
|
|
2244
|
+
if (assigned.has(bg)) continue;
|
|
2245
|
+
clusterRoots.push(bg);
|
|
2246
|
+
assigned.add(bg);
|
|
2247
|
+
const [w1, w2] = bg.split(" ");
|
|
2248
|
+
for (const [otherBg] of significantBigrams) {
|
|
2249
|
+
if (assigned.has(otherBg)) continue;
|
|
2250
|
+
if (otherBg.includes(w1) || otherBg.includes(w2)) {
|
|
2251
|
+
assigned.add(otherBg);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
const topicClusterCount = clusterRoots.length;
|
|
2256
|
+
const dominantTerm = topTerm?.[0] || "";
|
|
2257
|
+
const dominantTermCount = topTerm?.[1] || 0;
|
|
2258
|
+
const focusRatio = blogPages.length > 0 ? dominantTermCount / blogPages.length : 0;
|
|
2259
|
+
const dominantBigram = topBigram?.[0] || "";
|
|
2260
|
+
const dominantBigramCount = topBigram?.[1] || 0;
|
|
2261
|
+
const bigramFocusRatio = blogPages.length > 0 ? dominantBigramCount / blogPages.length : 0;
|
|
2262
|
+
let score = 0;
|
|
2263
|
+
const bestFocusRatio = Math.max(focusRatio, bigramFocusRatio);
|
|
2264
|
+
if (bestFocusRatio >= 0.8) {
|
|
2265
|
+
score += 7;
|
|
2266
|
+
} else if (bestFocusRatio >= 0.6) {
|
|
2267
|
+
score += 6;
|
|
2268
|
+
} else if (bestFocusRatio >= 0.45) {
|
|
2269
|
+
score += 5;
|
|
2270
|
+
} else if (bestFocusRatio >= 0.3) {
|
|
2271
|
+
score += 3;
|
|
2272
|
+
} else if (bestFocusRatio >= 0.15) {
|
|
2273
|
+
score += 2;
|
|
2274
|
+
} else {
|
|
2275
|
+
score += 1;
|
|
2276
|
+
}
|
|
2277
|
+
const clusterPenaltyReduced = focusRatio >= 0.7;
|
|
2278
|
+
if (topicClusterCount <= 3) {
|
|
2279
|
+
score += 3;
|
|
2280
|
+
findings.push({ severity: "info", detail: `${topicClusterCount} topic cluster(s) - tightly focused content` });
|
|
2281
|
+
} else if (topicClusterCount <= 6) {
|
|
2282
|
+
score += clusterPenaltyReduced ? 2 : 1;
|
|
2283
|
+
findings.push({ severity: "info", detail: `${topicClusterCount} topic clusters${clusterPenaltyReduced ? " within a focused niche" : " - moderately focused"}` });
|
|
2284
|
+
} else if (topicClusterCount <= 10) {
|
|
2285
|
+
score += clusterPenaltyReduced ? 1 : 0;
|
|
2286
|
+
if (!clusterPenaltyReduced) {
|
|
2287
|
+
findings.push({ severity: "low", detail: `${topicClusterCount} topic clusters - scattered content`, fix: "Reduce the number of distinct topics. Focus blog content on 2-3 core expertise areas." });
|
|
2288
|
+
} else {
|
|
2289
|
+
findings.push({ severity: "info", detail: `${topicClusterCount} topic clusters but strong core topic focus (${Math.round(focusRatio * 100)}%)` });
|
|
2290
|
+
}
|
|
2291
|
+
} else {
|
|
2292
|
+
score += clusterPenaltyReduced ? 0 : -2;
|
|
2293
|
+
if (!clusterPenaltyReduced) {
|
|
2294
|
+
findings.push({ severity: "medium", detail: `${topicClusterCount} topic clusters - highly scattered content`, fix: "Content covers too many unrelated topics. AI engines cannot identify your expertise. Focus on your core niche." });
|
|
2295
|
+
} else {
|
|
2296
|
+
findings.push({ severity: "low", detail: `${topicClusterCount} topic clusters despite strong core topic focus`, fix: "Consider narrowing subtopics within your niche for even stronger AI visibility." });
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
score = Math.max(0, Math.min(10, score));
|
|
2300
|
+
if (dominantTerm) {
|
|
2301
|
+
const focusPct = Math.round(focusRatio * 100);
|
|
2302
|
+
findings.push({ severity: "info", detail: `Dominant topic term: "${dominantTerm}" (${focusPct}% of ${blogPages.length} pages)` });
|
|
2303
|
+
}
|
|
2304
|
+
if (dominantBigram && dominantBigramCount >= 2) {
|
|
2305
|
+
findings.push({ severity: "info", detail: `Dominant topic phrase: "${dominantBigram}" (${dominantBigramCount}/${blogPages.length} pages)` });
|
|
2306
|
+
}
|
|
2307
|
+
const offTopicExamples = [];
|
|
2308
|
+
for (let i = 0; i < pageTitleTexts.length && offTopicExamples.length < 3; i++) {
|
|
2309
|
+
if (dominantTerm && !pageTitleTexts[i].includes(dominantTerm)) {
|
|
2310
|
+
const title = blogPages[i].text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim();
|
|
2311
|
+
if (title && title.length > 3) offTopicExamples.push(title.slice(0, 60));
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
if (offTopicExamples.length > 0 && score < 8) {
|
|
2315
|
+
findings.push({ severity: "low", detail: `Off-topic examples: ${offTopicExamples.join("; ")}` });
|
|
2316
|
+
}
|
|
2317
|
+
return { criterion: "topic_coherence", criterion_label: "Topic Coherence", score, status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P0" };
|
|
2318
|
+
}
|
|
2319
|
+
function countWords(html) {
|
|
2320
|
+
const text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
2321
|
+
return text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
2322
|
+
}
|
|
2323
|
+
function countHeadings(html) {
|
|
2324
|
+
const headings = html.match(/<h[2-6][^>]*>/gi) || [];
|
|
2325
|
+
return headings.length;
|
|
2326
|
+
}
|
|
2327
|
+
function checkContentDepth(data, topicCoherenceScore) {
|
|
2328
|
+
const findings = [];
|
|
2329
|
+
if (!data.blogSample || data.blogSample.length < 2) {
|
|
2330
|
+
findings.push({ severity: "info", detail: `Only ${data.blogSample?.length || 0} blog pages found - insufficient for depth analysis` });
|
|
2331
|
+
return { criterion: "content_depth", criterion_label: "Content Depth", score: 3, status: "partial", findings, fix_priority: "P2" };
|
|
2332
|
+
}
|
|
2333
|
+
const blogPages = data.blogSample;
|
|
2334
|
+
const wordCounts = blogPages.map((p) => countWords(p.text));
|
|
2335
|
+
const headingCounts = blogPages.map((p) => countHeadings(p.text));
|
|
2336
|
+
const avgWords = wordCounts.reduce((a, b) => a + b, 0) / wordCounts.length;
|
|
2337
|
+
const avgHeadings = headingCounts.reduce((a, b) => a + b, 0) / headingCounts.length;
|
|
2338
|
+
const deepPages = wordCounts.filter((w) => w >= 1e3).length;
|
|
2339
|
+
const thinPages = wordCounts.filter((w) => w < 300).length;
|
|
2340
|
+
const deepRatio = deepPages / blogPages.length;
|
|
2341
|
+
const thinRatio = thinPages / blogPages.length;
|
|
2342
|
+
let score = 0;
|
|
2343
|
+
if (avgWords >= 2e3) {
|
|
2344
|
+
score += 5;
|
|
2345
|
+
findings.push({ severity: "info", detail: `Average ${Math.round(avgWords)} words per page across ${blogPages.length} pages - excellent depth` });
|
|
2346
|
+
} else if (avgWords >= 1200) {
|
|
2347
|
+
score += 4;
|
|
2348
|
+
findings.push({ severity: "info", detail: `Average ${Math.round(avgWords)} words per page across ${blogPages.length} pages - good depth` });
|
|
2349
|
+
} else if (avgWords >= 800) {
|
|
2350
|
+
score += 3;
|
|
2351
|
+
findings.push({ severity: "info", detail: `Average ${Math.round(avgWords)} words per page - moderate depth` });
|
|
2352
|
+
} else if (avgWords >= 400) {
|
|
2353
|
+
score += 2;
|
|
2354
|
+
findings.push({ severity: "low", detail: `Average ${Math.round(avgWords)} words per page - shallow content`, fix: "Expand articles with more detail, examples, and expert analysis to build AI citation authority" });
|
|
2355
|
+
} else {
|
|
2356
|
+
score += 1;
|
|
2357
|
+
findings.push({ severity: "medium", detail: `Average ${Math.round(avgWords)} words per page - very thin content`, fix: "Content is too thin for AI engines to cite. Aim for 1000+ words per article with structured sections." });
|
|
2358
|
+
}
|
|
2359
|
+
if (avgHeadings >= 8) {
|
|
2360
|
+
score += 3;
|
|
2361
|
+
findings.push({ severity: "info", detail: `Average ${avgHeadings.toFixed(1)} subheadings per page - well-structured` });
|
|
2362
|
+
} else if (avgHeadings >= 5) {
|
|
2363
|
+
score += 2;
|
|
2364
|
+
findings.push({ severity: "info", detail: `Average ${avgHeadings.toFixed(1)} subheadings per page - decent structure` });
|
|
2365
|
+
} else if (avgHeadings >= 2) {
|
|
2366
|
+
score += 1;
|
|
2367
|
+
findings.push({ severity: "low", detail: `Average ${avgHeadings.toFixed(1)} subheadings per page`, fix: "Add more H2/H3 headings to break content into extractable sections" });
|
|
2368
|
+
} else {
|
|
2369
|
+
findings.push({ severity: "medium", detail: `Average ${avgHeadings.toFixed(1)} subheadings per page - minimal structure`, fix: "Add question-format H2/H3 headings so AI engines can extract specific answers" });
|
|
2370
|
+
}
|
|
2371
|
+
if (deepRatio >= 0.5) {
|
|
2372
|
+
score += 2;
|
|
2373
|
+
findings.push({ severity: "info", detail: `${deepPages}/${blogPages.length} pages (${Math.round(deepRatio * 100)}%) have 1000+ words` });
|
|
2374
|
+
} else if (deepRatio >= 0.25) {
|
|
2375
|
+
score += 1;
|
|
2376
|
+
findings.push({ severity: "info", detail: `${deepPages}/${blogPages.length} pages have 1000+ words` });
|
|
2377
|
+
}
|
|
2378
|
+
if (thinRatio >= 0.5) {
|
|
2379
|
+
score = Math.max(0, score - 2);
|
|
2380
|
+
findings.push({ severity: "medium", detail: `${thinPages}/${blogPages.length} pages (${Math.round(thinRatio * 100)}%) have under 300 words - high thin content ratio`, fix: "Remove or expand thin pages. Thin content dilutes site quality for AI engines." });
|
|
2381
|
+
} else if (thinRatio >= 0.25) {
|
|
2382
|
+
score = Math.max(0, score - 1);
|
|
2383
|
+
findings.push({ severity: "low", detail: `${thinPages}/${blogPages.length} pages have under 300 words` });
|
|
2384
|
+
}
|
|
2385
|
+
let finalScore = Math.min(10, score);
|
|
2386
|
+
if (topicCoherenceScore !== void 0 && topicCoherenceScore <= 4 && finalScore >= 8) {
|
|
2387
|
+
finalScore = 7;
|
|
2388
|
+
findings.push({ severity: "low", detail: "Deep content but low topic coherence - depth on scattered topics has reduced AI citation value", fix: "Focus content depth on your core expertise area for maximum AI visibility" });
|
|
2389
|
+
}
|
|
2390
|
+
return { criterion: "content_depth", criterion_label: "Content Depth", score: finalScore, status: finalScore >= 7 ? "pass" : finalScore >= 4 ? "partial" : "fail", findings, fix_priority: finalScore >= 7 ? "P3" : "P1" };
|
|
2391
|
+
}
|
|
2119
2392
|
function auditSiteFromData(data) {
|
|
2393
|
+
const topicCoherence = checkTopicCoherence(data);
|
|
2394
|
+
const cannibalization = checkContentCannibalization(data, topicCoherence.score);
|
|
2120
2395
|
return [
|
|
2121
2396
|
checkLlmsTxt(data),
|
|
2122
2397
|
checkSchemaMarkup(data),
|
|
@@ -2142,47 +2417,55 @@ function auditSiteFromData(data) {
|
|
|
2142
2417
|
checkSchemaCoverage(data),
|
|
2143
2418
|
checkSpeakableSchema(data),
|
|
2144
2419
|
checkQueryAnswerAlignment(data),
|
|
2145
|
-
|
|
2146
|
-
checkVisibleDateSignal(data)
|
|
2420
|
+
cannibalization,
|
|
2421
|
+
checkVisibleDateSignal(data),
|
|
2422
|
+
topicCoherence,
|
|
2423
|
+
checkContentDepth(data, topicCoherence.score)
|
|
2147
2424
|
];
|
|
2148
2425
|
}
|
|
2149
2426
|
|
|
2150
2427
|
// src/scoring.ts
|
|
2151
2428
|
var WEIGHTS = {
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
robots_txt: 0.05,
|
|
2159
|
-
faq_section: 0.1,
|
|
2160
|
-
original_data: 0.1,
|
|
2161
|
-
internal_linking: 0.1,
|
|
2162
|
-
semantic_html: 0.05,
|
|
2163
|
-
// New 12
|
|
2164
|
-
content_freshness: 0.07,
|
|
2165
|
-
sitemap_completeness: 0.05,
|
|
2166
|
-
rss_feed: 0.03,
|
|
2167
|
-
table_list_extractability: 0.07,
|
|
2168
|
-
definition_patterns: 0.04,
|
|
2429
|
+
// ─── Core Content (high weight - these determine real AI citation quality) ──
|
|
2430
|
+
qa_content_format: 0.12,
|
|
2431
|
+
original_data: 0.12,
|
|
2432
|
+
topic_coherence: 0.14,
|
|
2433
|
+
// NEW v2.0: biggest predictor of AI citation quality
|
|
2434
|
+
fact_density: 0.08,
|
|
2169
2435
|
direct_answer_density: 0.07,
|
|
2170
|
-
|
|
2436
|
+
content_depth: 0.06,
|
|
2437
|
+
// NEW v2.0: substantive content vs thin pages
|
|
2438
|
+
// ─── Structure & Discovery (medium weight - technical readiness) ────────────
|
|
2439
|
+
schema_markup: 0.08,
|
|
2440
|
+
llms_txt: 0.08,
|
|
2441
|
+
clean_html: 0.08,
|
|
2442
|
+
entity_consistency: 0.08,
|
|
2443
|
+
faq_section: 0.08,
|
|
2444
|
+
internal_linking: 0.08,
|
|
2445
|
+
// ─── Content Signals (moderate weight) ──────────────────────────────────────
|
|
2446
|
+
content_freshness: 0.06,
|
|
2447
|
+
table_list_extractability: 0.05,
|
|
2448
|
+
query_answer_alignment: 0.06,
|
|
2449
|
+
definition_patterns: 0.04,
|
|
2171
2450
|
author_schema_depth: 0.04,
|
|
2172
|
-
fact_density: 0.05,
|
|
2173
|
-
canonical_url: 0.04,
|
|
2174
|
-
content_velocity: 0.03,
|
|
2175
|
-
schema_coverage: 0.03,
|
|
2176
|
-
speakable_schema: 0.03,
|
|
2177
|
-
query_answer_alignment: 0.08,
|
|
2178
2451
|
content_cannibalization: 0.05,
|
|
2179
|
-
visible_date_signal: 0.04
|
|
2452
|
+
visible_date_signal: 0.04,
|
|
2453
|
+
semantic_html: 0.04,
|
|
2454
|
+
// ─── Plumbing (low weight - nice to have but not what drives citations) ─────
|
|
2455
|
+
robots_txt: 0.03,
|
|
2456
|
+
sitemap_completeness: 0.03,
|
|
2457
|
+
content_velocity: 0.03,
|
|
2458
|
+
rss_feed: 0.02,
|
|
2459
|
+
content_licensing: 0.03,
|
|
2460
|
+
canonical_url: 0.02,
|
|
2461
|
+
schema_coverage: 0.02,
|
|
2462
|
+
speakable_schema: 0.02
|
|
2180
2463
|
};
|
|
2181
2464
|
function calculateOverallScore(criteria) {
|
|
2182
2465
|
let totalWeight = 0;
|
|
2183
2466
|
let weightedSum = 0;
|
|
2184
2467
|
for (const c of criteria) {
|
|
2185
|
-
const weight = WEIGHTS[c.criterion] ?? 0.
|
|
2468
|
+
const weight = WEIGHTS[c.criterion] ?? 0.05;
|
|
2186
2469
|
weightedSum += c.score / 10 * weight * 100;
|
|
2187
2470
|
totalWeight += weight;
|
|
2188
2471
|
}
|
|
@@ -2318,7 +2601,9 @@ var CRITERION_LABELS = {
|
|
|
2318
2601
|
"Speakable Schema": "Speakable Schema",
|
|
2319
2602
|
"Query-Answer Alignment": "Query-Answer Alignment",
|
|
2320
2603
|
"Content Cannibalization": "Content Cannibalization",
|
|
2321
|
-
"Visible Date Signal": "Visible Date Signal"
|
|
2604
|
+
"Visible Date Signal": "Visible Date Signal",
|
|
2605
|
+
"Topic Coherence": "Topic Coherence",
|
|
2606
|
+
"Content Depth": "Content Depth"
|
|
2322
2607
|
};
|
|
2323
2608
|
function scoreToStatus(score) {
|
|
2324
2609
|
if (score === 0) return "MISSING";
|
|
@@ -3227,7 +3512,7 @@ function extractTitle(html) {
|
|
|
3227
3512
|
function getTextContent2(html) {
|
|
3228
3513
|
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
3229
3514
|
}
|
|
3230
|
-
function
|
|
3515
|
+
function countWords2(text) {
|
|
3231
3516
|
if (!text) return 0;
|
|
3232
3517
|
return text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
3233
3518
|
}
|
|
@@ -3378,7 +3663,7 @@ function checkHasQuestionHeadings(html) {
|
|
|
3378
3663
|
function analyzePage(html, url, category) {
|
|
3379
3664
|
const title = extractTitle(html);
|
|
3380
3665
|
const textContent = getTextContent2(html);
|
|
3381
|
-
const wordCount =
|
|
3666
|
+
const wordCount = countWords2(textContent);
|
|
3382
3667
|
const issues = [];
|
|
3383
3668
|
const strengths = [];
|
|
3384
3669
|
const issueChecks = [
|
|
@@ -3506,6 +3791,1314 @@ async function audit(domain, options) {
|
|
|
3506
3791
|
// src/index.ts
|
|
3507
3792
|
init_full_site_crawler();
|
|
3508
3793
|
|
|
3794
|
+
// src/link-graph.ts
|
|
3795
|
+
function serializeLinkGraph(graph) {
|
|
3796
|
+
return {
|
|
3797
|
+
nodes: Array.from(graph.nodes.values()),
|
|
3798
|
+
stats: graph.stats,
|
|
3799
|
+
clusters: graph.clusters
|
|
3800
|
+
};
|
|
3801
|
+
}
|
|
3802
|
+
function normalizeUrl2(url) {
|
|
3803
|
+
try {
|
|
3804
|
+
const parsed = new URL(url);
|
|
3805
|
+
return (parsed.origin + parsed.pathname.replace(/\/+$/, "") + parsed.search).toLowerCase();
|
|
3806
|
+
} catch {
|
|
3807
|
+
return url.toLowerCase();
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
var RESOURCE_EXTENSIONS2 = /\.(js|css|png|jpg|jpeg|gif|svg|ico|pdf|xml|txt|woff|woff2|ttf|eot|mp4|mp3|webp|avif|zip|gz|tar|json)$/i;
|
|
3811
|
+
var SKIP_PATH_PATTERNS2 = /^\/(api|wp-admin|wp-json|static|assets|_next|auth|login|signup|cart|checkout|admin|feed|xmlrpc)\b/i;
|
|
3812
|
+
function extractTitle2(html) {
|
|
3813
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
3814
|
+
return match ? match[1].replace(/\s+/g, " ").trim() : "";
|
|
3815
|
+
}
|
|
3816
|
+
function getTextContent3(html) {
|
|
3817
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
3818
|
+
}
|
|
3819
|
+
function countWords3(text) {
|
|
3820
|
+
if (!text) return 0;
|
|
3821
|
+
return text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
3822
|
+
}
|
|
3823
|
+
function extractLinksWithAnchors(html, sourceUrl, domain) {
|
|
3824
|
+
const cleanDomain = domain.replace(/^www\./, "").toLowerCase();
|
|
3825
|
+
const edges = [];
|
|
3826
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3827
|
+
const anchorRegex = /<a\s[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
3828
|
+
let match;
|
|
3829
|
+
while ((match = anchorRegex.exec(html)) !== null) {
|
|
3830
|
+
const href = match[1];
|
|
3831
|
+
const rawAnchor = match[2];
|
|
3832
|
+
if (!href || !href.trim()) continue;
|
|
3833
|
+
let fullUrl;
|
|
3834
|
+
if (href.startsWith("//")) {
|
|
3835
|
+
fullUrl = `https:${href}`;
|
|
3836
|
+
} else if (href.startsWith("/")) {
|
|
3837
|
+
if (href === "/" || href.startsWith("/#")) continue;
|
|
3838
|
+
fullUrl = `https://${domain}${href}`;
|
|
3839
|
+
} else if (href.startsWith("http")) {
|
|
3840
|
+
fullUrl = href;
|
|
3841
|
+
} else if (href.startsWith("#") || href.startsWith("?") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) {
|
|
3842
|
+
continue;
|
|
3843
|
+
} else {
|
|
3844
|
+
fullUrl = `https://${domain}/${href}`;
|
|
3845
|
+
}
|
|
3846
|
+
try {
|
|
3847
|
+
const parsed = new URL(fullUrl);
|
|
3848
|
+
const linkDomain = parsed.hostname.replace(/^www\./, "").toLowerCase();
|
|
3849
|
+
if (linkDomain !== cleanDomain) continue;
|
|
3850
|
+
parsed.hash = "";
|
|
3851
|
+
const path = parsed.pathname;
|
|
3852
|
+
if (path === "/" || path === "") continue;
|
|
3853
|
+
if (RESOURCE_EXTENSIONS2.test(path)) continue;
|
|
3854
|
+
if (SKIP_PATH_PATTERNS2.test(path)) continue;
|
|
3855
|
+
const normalized = normalizeUrl2(fullUrl);
|
|
3856
|
+
const sourceNorm = normalizeUrl2(sourceUrl);
|
|
3857
|
+
if (normalized === sourceNorm) continue;
|
|
3858
|
+
const edgeKey = `${sourceNorm}->${normalized}`;
|
|
3859
|
+
if (seen.has(edgeKey)) continue;
|
|
3860
|
+
seen.add(edgeKey);
|
|
3861
|
+
const anchorText = rawAnchor.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
|
3862
|
+
edges.push({
|
|
3863
|
+
source: sourceNorm,
|
|
3864
|
+
target: normalized,
|
|
3865
|
+
anchorText
|
|
3866
|
+
});
|
|
3867
|
+
} catch {
|
|
3868
|
+
continue;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
return edges;
|
|
3872
|
+
}
|
|
3873
|
+
function calculateDepths(nodes, adjacency, homepageUrl) {
|
|
3874
|
+
const homeNorm = normalizeUrl2(homepageUrl);
|
|
3875
|
+
for (const node of nodes.values()) {
|
|
3876
|
+
node.depth = Infinity;
|
|
3877
|
+
}
|
|
3878
|
+
const homeNode = nodes.get(homeNorm);
|
|
3879
|
+
if (homeNode) {
|
|
3880
|
+
homeNode.depth = 0;
|
|
3881
|
+
}
|
|
3882
|
+
const queue = [homeNorm];
|
|
3883
|
+
const visited = /* @__PURE__ */ new Set([homeNorm]);
|
|
3884
|
+
while (queue.length > 0) {
|
|
3885
|
+
const current = queue.shift();
|
|
3886
|
+
const currentNode = nodes.get(current);
|
|
3887
|
+
if (!currentNode) continue;
|
|
3888
|
+
const nextDepth = currentNode.depth + 1;
|
|
3889
|
+
const neighbors = adjacency.get(current);
|
|
3890
|
+
if (!neighbors) continue;
|
|
3891
|
+
for (const neighbor of neighbors) {
|
|
3892
|
+
if (visited.has(neighbor)) continue;
|
|
3893
|
+
visited.add(neighbor);
|
|
3894
|
+
const neighborNode = nodes.get(neighbor);
|
|
3895
|
+
if (neighborNode) {
|
|
3896
|
+
neighborNode.depth = nextDepth;
|
|
3897
|
+
queue.push(neighbor);
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
var PILLAR_CATEGORIES = /* @__PURE__ */ new Set(["blog", "content", "resources", "docs"]);
|
|
3903
|
+
function detectPillars(nodes) {
|
|
3904
|
+
for (const node of nodes.values()) {
|
|
3905
|
+
node.isPillar = node.wordCount >= 1500 && node.inDegree >= 3 && node.outDegree >= 3 && PILLAR_CATEGORIES.has(node.category) && node.depth > 0;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
var HUB_CATEGORIES = /* @__PURE__ */ new Set(["homepage", "resources", "docs"]);
|
|
3909
|
+
function detectHubs(nodes) {
|
|
3910
|
+
for (const node of nodes.values()) {
|
|
3911
|
+
node.isHub = node.outDegree >= 10 && HUB_CATEGORIES.has(node.category) || node.outDegree >= 15;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
function detectClusters(nodes, edges) {
|
|
3915
|
+
const clusters = [];
|
|
3916
|
+
const edgeSet = /* @__PURE__ */ new Set();
|
|
3917
|
+
for (const edge of edges) {
|
|
3918
|
+
edgeSet.add(`${edge.source}->${edge.target}`);
|
|
3919
|
+
}
|
|
3920
|
+
for (const node of nodes.values()) {
|
|
3921
|
+
if (!node.isPillar) continue;
|
|
3922
|
+
const pillarNorm = normalizeUrl2(node.url);
|
|
3923
|
+
const spokeSet = /* @__PURE__ */ new Set();
|
|
3924
|
+
for (const edge of edges) {
|
|
3925
|
+
if (edge.source === pillarNorm && nodes.has(edge.target)) {
|
|
3926
|
+
spokeSet.add(edge.target);
|
|
3927
|
+
}
|
|
3928
|
+
if (edge.target === pillarNorm && nodes.has(edge.source)) {
|
|
3929
|
+
spokeSet.add(edge.source);
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
spokeSet.delete(pillarNorm);
|
|
3933
|
+
if (spokeSet.size < 2) continue;
|
|
3934
|
+
const spokes = Array.from(spokeSet);
|
|
3935
|
+
const members = [pillarNorm, ...spokes];
|
|
3936
|
+
let actualEdges = 0;
|
|
3937
|
+
const possibleEdges = members.length * (members.length - 1);
|
|
3938
|
+
for (const from of members) {
|
|
3939
|
+
for (const to of members) {
|
|
3940
|
+
if (from === to) continue;
|
|
3941
|
+
if (edgeSet.has(`${from}->${to}`)) {
|
|
3942
|
+
actualEdges++;
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
const cohesion = possibleEdges > 0 ? Math.round(actualEdges / possibleEdges * 100) : 0;
|
|
3947
|
+
clusters.push({
|
|
3948
|
+
pillarUrl: node.url,
|
|
3949
|
+
pillarTitle: node.title,
|
|
3950
|
+
spokes,
|
|
3951
|
+
cohesion
|
|
3952
|
+
});
|
|
3953
|
+
}
|
|
3954
|
+
return clusters;
|
|
3955
|
+
}
|
|
3956
|
+
function buildLinkGraph(pages, domain, homepageUrl) {
|
|
3957
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
3958
|
+
const allEdges = [];
|
|
3959
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
3960
|
+
const inDegreeMap = /* @__PURE__ */ new Map();
|
|
3961
|
+
for (const page of pages) {
|
|
3962
|
+
const url = page.finalUrl || `https://${domain}`;
|
|
3963
|
+
const norm = normalizeUrl2(url);
|
|
3964
|
+
if (nodes.has(norm)) continue;
|
|
3965
|
+
const title = extractTitle2(page.text);
|
|
3966
|
+
const text = getTextContent3(page.text);
|
|
3967
|
+
const wordCount = countWords3(text);
|
|
3968
|
+
nodes.set(norm, {
|
|
3969
|
+
url: norm,
|
|
3970
|
+
title,
|
|
3971
|
+
wordCount,
|
|
3972
|
+
category: page.category || "content",
|
|
3973
|
+
inDegree: 0,
|
|
3974
|
+
outDegree: 0,
|
|
3975
|
+
depth: Infinity,
|
|
3976
|
+
isPillar: false,
|
|
3977
|
+
isHub: false,
|
|
3978
|
+
isOrphan: false
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
for (const page of pages) {
|
|
3982
|
+
const url = page.finalUrl || `https://${domain}`;
|
|
3983
|
+
const sourceNorm = normalizeUrl2(url);
|
|
3984
|
+
const edges = extractLinksWithAnchors(page.text, url, domain);
|
|
3985
|
+
for (const edge of edges) {
|
|
3986
|
+
const targetNorm = normalizeUrl2(edge.target);
|
|
3987
|
+
if (!nodes.has(targetNorm)) continue;
|
|
3988
|
+
allEdges.push({
|
|
3989
|
+
source: sourceNorm,
|
|
3990
|
+
target: targetNorm,
|
|
3991
|
+
anchorText: edge.anchorText
|
|
3992
|
+
});
|
|
3993
|
+
if (!adjacency.has(sourceNorm)) {
|
|
3994
|
+
adjacency.set(sourceNorm, /* @__PURE__ */ new Set());
|
|
3995
|
+
}
|
|
3996
|
+
adjacency.get(sourceNorm).add(targetNorm);
|
|
3997
|
+
inDegreeMap.set(targetNorm, (inDegreeMap.get(targetNorm) || 0) + 1);
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
for (const [url, node] of nodes) {
|
|
4001
|
+
node.inDegree = inDegreeMap.get(url) || 0;
|
|
4002
|
+
node.outDegree = adjacency.get(url)?.size || 0;
|
|
4003
|
+
}
|
|
4004
|
+
calculateDepths(nodes, adjacency, homepageUrl);
|
|
4005
|
+
detectPillars(nodes);
|
|
4006
|
+
detectHubs(nodes);
|
|
4007
|
+
const homeNorm = normalizeUrl2(homepageUrl);
|
|
4008
|
+
for (const [url, node] of nodes) {
|
|
4009
|
+
node.isOrphan = node.inDegree === 0 && url !== homeNorm;
|
|
4010
|
+
}
|
|
4011
|
+
const clusters = detectClusters(nodes, allEdges);
|
|
4012
|
+
const depthValues = Array.from(nodes.values()).map((n) => n.depth).filter((d) => d !== Infinity);
|
|
4013
|
+
const avgDepth = depthValues.length > 0 ? Math.round(depthValues.reduce((s, d) => s + d, 0) / depthValues.length * 10) / 10 : 0;
|
|
4014
|
+
const maxDepth = depthValues.length > 0 ? Math.max(...depthValues) : 0;
|
|
4015
|
+
const stats = {
|
|
4016
|
+
totalPages: nodes.size,
|
|
4017
|
+
totalEdges: allEdges.length,
|
|
4018
|
+
orphanPages: Array.from(nodes.values()).filter((n) => n.isOrphan).length,
|
|
4019
|
+
pillarPages: Array.from(nodes.values()).filter((n) => n.isPillar).length,
|
|
4020
|
+
hubPages: Array.from(nodes.values()).filter((n) => n.isHub).length,
|
|
4021
|
+
avgDepth,
|
|
4022
|
+
maxDepth,
|
|
4023
|
+
clusters: clusters.length
|
|
4024
|
+
};
|
|
4025
|
+
return { nodes, edges: allEdges, stats, clusters };
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
// src/fix-engine.ts
|
|
4029
|
+
var CRITERION_WEIGHTS2 = {
|
|
4030
|
+
llms_txt: 0.1,
|
|
4031
|
+
schema_markup: 0.15,
|
|
4032
|
+
qa_content_format: 0.15,
|
|
4033
|
+
clean_html: 0.1,
|
|
4034
|
+
entity_consistency: 0.1,
|
|
4035
|
+
robots_txt: 0.05,
|
|
4036
|
+
faq_section: 0.1,
|
|
4037
|
+
original_data: 0.1,
|
|
4038
|
+
internal_linking: 0.1,
|
|
4039
|
+
semantic_html: 0.05,
|
|
4040
|
+
content_freshness: 0.07,
|
|
4041
|
+
sitemap_completeness: 0.05,
|
|
4042
|
+
rss_feed: 0.03,
|
|
4043
|
+
table_list_extractability: 0.07,
|
|
4044
|
+
definition_patterns: 0.04,
|
|
4045
|
+
direct_answer_density: 0.07,
|
|
4046
|
+
content_licensing: 0.04,
|
|
4047
|
+
author_schema_depth: 0.04,
|
|
4048
|
+
fact_density: 0.05,
|
|
4049
|
+
canonical_url: 0.04,
|
|
4050
|
+
content_velocity: 0.03,
|
|
4051
|
+
schema_coverage: 0.03,
|
|
4052
|
+
speakable_schema: 0.03,
|
|
4053
|
+
query_answer_alignment: 0.08,
|
|
4054
|
+
content_cannibalization: 0.05,
|
|
4055
|
+
visible_date_signal: 0.04
|
|
4056
|
+
};
|
|
4057
|
+
var PHASE_CONFIG = [
|
|
4058
|
+
{
|
|
4059
|
+
phase: 1,
|
|
4060
|
+
title: "Foundation",
|
|
4061
|
+
description: "Discovery and structural fixes that enable AI crawlers to access and parse your content.",
|
|
4062
|
+
criteria: ["robots_txt", "llms_txt", "canonical_url", "clean_html", "sitemap_completeness"]
|
|
4063
|
+
},
|
|
4064
|
+
{
|
|
4065
|
+
phase: 2,
|
|
4066
|
+
title: "Content",
|
|
4067
|
+
description: "Content quality and format improvements that make your pages citable by AI engines.",
|
|
4068
|
+
criteria: [
|
|
4069
|
+
"qa_content_format",
|
|
4070
|
+
"faq_section",
|
|
4071
|
+
"original_data",
|
|
4072
|
+
"definition_patterns",
|
|
4073
|
+
"direct_answer_density",
|
|
4074
|
+
"fact_density",
|
|
4075
|
+
"content_freshness",
|
|
4076
|
+
"table_list_extractability",
|
|
4077
|
+
"query_answer_alignment",
|
|
4078
|
+
"visible_date_signal"
|
|
4079
|
+
]
|
|
4080
|
+
},
|
|
4081
|
+
{
|
|
4082
|
+
phase: 3,
|
|
4083
|
+
title: "Authority",
|
|
4084
|
+
description: "Trust signals, schema depth, and semantic structure that establish credibility with AI engines.",
|
|
4085
|
+
criteria: [
|
|
4086
|
+
"schema_markup",
|
|
4087
|
+
"schema_coverage",
|
|
4088
|
+
"speakable_schema",
|
|
4089
|
+
"author_schema_depth",
|
|
4090
|
+
"content_licensing",
|
|
4091
|
+
"entity_consistency",
|
|
4092
|
+
"semantic_html"
|
|
4093
|
+
]
|
|
4094
|
+
},
|
|
4095
|
+
{
|
|
4096
|
+
phase: 4,
|
|
4097
|
+
title: "Architecture",
|
|
4098
|
+
description: "Site architecture, linking patterns, and publishing cadence that support long-term AI visibility.",
|
|
4099
|
+
criteria: ["internal_linking", "content_velocity", "content_cannibalization", "rss_feed"]
|
|
4100
|
+
}
|
|
4101
|
+
];
|
|
4102
|
+
function impactFromScore(score) {
|
|
4103
|
+
if (score <= 3) return "critical";
|
|
4104
|
+
if (score <= 5) return "high";
|
|
4105
|
+
if (score <= 7) return "medium";
|
|
4106
|
+
return "low";
|
|
4107
|
+
}
|
|
4108
|
+
function effortForCriterion(criterion, score) {
|
|
4109
|
+
const trivialCriteria = ["llms_txt", "robots_txt", "canonical_url", "content_licensing", "visible_date_signal"];
|
|
4110
|
+
const lowCriteria = ["rss_feed", "sitemap_completeness", "speakable_schema", "author_schema_depth", "semantic_html", "definition_patterns", "content_freshness"];
|
|
4111
|
+
const highCriteria = ["original_data", "content_velocity", "content_cannibalization"];
|
|
4112
|
+
if (trivialCriteria.includes(criterion)) return score <= 3 ? "low" : "trivial";
|
|
4113
|
+
if (lowCriteria.includes(criterion)) return score <= 3 ? "medium" : "low";
|
|
4114
|
+
if (highCriteria.includes(criterion)) return score <= 5 ? "high" : "medium";
|
|
4115
|
+
return score <= 3 ? "medium" : "low";
|
|
4116
|
+
}
|
|
4117
|
+
function getAffectedPages(criterion, pages, threshold = 7) {
|
|
4118
|
+
if (!pages || pages.length === 0) return void 0;
|
|
4119
|
+
const affected = pages.filter((p) => {
|
|
4120
|
+
const cs = p.criterionScores?.find((c) => c.criterion === criterion);
|
|
4121
|
+
return cs && cs.score < threshold;
|
|
4122
|
+
});
|
|
4123
|
+
if (affected.length === 0) return void 0;
|
|
4124
|
+
return affected.map((p) => p.url);
|
|
4125
|
+
}
|
|
4126
|
+
function effortToHours(effort) {
|
|
4127
|
+
switch (effort) {
|
|
4128
|
+
case "trivial":
|
|
4129
|
+
return 0.5;
|
|
4130
|
+
case "low":
|
|
4131
|
+
return 1;
|
|
4132
|
+
case "medium":
|
|
4133
|
+
return 3;
|
|
4134
|
+
case "high":
|
|
4135
|
+
return 8;
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
var FIX_GENERATORS = {
|
|
4139
|
+
llms_txt: (c) => {
|
|
4140
|
+
if (c.score >= 10) return [];
|
|
4141
|
+
const impact = impactFromScore(c.score);
|
|
4142
|
+
const effort = effortForCriterion("llms_txt", c.score);
|
|
4143
|
+
const fixes = [];
|
|
4144
|
+
if (c.score <= 6) {
|
|
4145
|
+
fixes.push({
|
|
4146
|
+
id: "fix-llms-txt-create",
|
|
4147
|
+
criterion: c.criterion_label,
|
|
4148
|
+
criterionId: c.criterion,
|
|
4149
|
+
title: "Create /llms.txt file",
|
|
4150
|
+
description: "Add a machine-readable llms.txt file at your domain root that describes your site, services, and key pages for AI engines.",
|
|
4151
|
+
impact,
|
|
4152
|
+
effort,
|
|
4153
|
+
impactScore: 0,
|
|
4154
|
+
// calculated later
|
|
4155
|
+
category: "discovery",
|
|
4156
|
+
steps: [
|
|
4157
|
+
"Create a file named llms.txt in your site root",
|
|
4158
|
+
"Add site name, description, and core URLs in markdown format",
|
|
4159
|
+
"Include key service/product pages and their descriptions",
|
|
4160
|
+
"Deploy and verify access at yourdomain.com/llms.txt"
|
|
4161
|
+
],
|
|
4162
|
+
codeExample: `# Site Name
|
|
4163
|
+
> One-line site description
|
|
4164
|
+
|
|
4165
|
+
## Core Pages
|
|
4166
|
+
- [About](/about): Company overview
|
|
4167
|
+
- [Services](/services): Service offerings
|
|
4168
|
+
- [Blog](/blog): Latest articles
|
|
4169
|
+
|
|
4170
|
+
## Key Topics
|
|
4171
|
+
- Topic 1
|
|
4172
|
+
- Topic 2`,
|
|
4173
|
+
successCriteria: "/llms.txt returns 200 with valid markdown content"
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
if (c.score <= 3) {
|
|
4177
|
+
fixes.push({
|
|
4178
|
+
id: "fix-llms-txt-full",
|
|
4179
|
+
criterion: c.criterion_label,
|
|
4180
|
+
criterionId: c.criterion,
|
|
4181
|
+
title: "Add llms-full.txt with extended content",
|
|
4182
|
+
description: "Create a comprehensive llms-full.txt with detailed page descriptions, content summaries, and topic taxonomy.",
|
|
4183
|
+
impact: "medium",
|
|
4184
|
+
effort: "low",
|
|
4185
|
+
impactScore: 0,
|
|
4186
|
+
category: "discovery",
|
|
4187
|
+
steps: [
|
|
4188
|
+
"Create llms-full.txt alongside llms.txt",
|
|
4189
|
+
"Include full page descriptions with word counts",
|
|
4190
|
+
"Add topic categories and content clusters",
|
|
4191
|
+
"Link from llms.txt to llms-full.txt"
|
|
4192
|
+
],
|
|
4193
|
+
successCriteria: "/llms-full.txt returns 200 with comprehensive site map"
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
return fixes;
|
|
4197
|
+
},
|
|
4198
|
+
schema_markup: (c, pages) => {
|
|
4199
|
+
if (c.score >= 10) return [];
|
|
4200
|
+
const impact = impactFromScore(c.score);
|
|
4201
|
+
const effort = effortForCriterion("schema_markup", c.score);
|
|
4202
|
+
const affected = getAffectedPages("schema_markup", pages);
|
|
4203
|
+
const fixes = [{
|
|
4204
|
+
id: "fix-schema-markup",
|
|
4205
|
+
criterion: c.criterion_label,
|
|
4206
|
+
criterionId: c.criterion,
|
|
4207
|
+
title: "Add JSON-LD structured data",
|
|
4208
|
+
description: "Implement Organization, WebSite, and page-specific schema.org JSON-LD to help AI engines extract your content.",
|
|
4209
|
+
impact,
|
|
4210
|
+
effort,
|
|
4211
|
+
impactScore: 0,
|
|
4212
|
+
category: "trust",
|
|
4213
|
+
steps: [
|
|
4214
|
+
"Add Organization JSON-LD to your homepage with name, url, logo, sameAs",
|
|
4215
|
+
"Add WebSite schema with SearchAction",
|
|
4216
|
+
"Add page-specific schema (Article, Service, Product, FAQPage) to relevant pages",
|
|
4217
|
+
"Validate with Google Rich Results Test"
|
|
4218
|
+
],
|
|
4219
|
+
codeExample: `<script type="application/ld+json">
|
|
4220
|
+
{
|
|
4221
|
+
"@context": "https://schema.org",
|
|
4222
|
+
"@type": "Organization",
|
|
4223
|
+
"name": "Your Company",
|
|
4224
|
+
"url": "https://example.com",
|
|
4225
|
+
"logo": "https://example.com/logo.png",
|
|
4226
|
+
"sameAs": [
|
|
4227
|
+
"https://twitter.com/company",
|
|
4228
|
+
"https://linkedin.com/company/company"
|
|
4229
|
+
]
|
|
4230
|
+
}
|
|
4231
|
+
</script>`,
|
|
4232
|
+
successCriteria: "Homepage and key pages have valid JSON-LD schema",
|
|
4233
|
+
dependsOn: ["fix-clean-html-structure"],
|
|
4234
|
+
affectedPages: affected,
|
|
4235
|
+
pageCount: affected?.length
|
|
4236
|
+
}];
|
|
4237
|
+
return fixes;
|
|
4238
|
+
},
|
|
4239
|
+
qa_content_format: (c, pages) => {
|
|
4240
|
+
if (c.score >= 10) return [];
|
|
4241
|
+
const impact = impactFromScore(c.score);
|
|
4242
|
+
const effort = effortForCriterion("qa_content_format", c.score);
|
|
4243
|
+
const affected = getAffectedPages("qa_content_format", pages);
|
|
4244
|
+
return [{
|
|
4245
|
+
id: "fix-qa-format",
|
|
4246
|
+
criterion: c.criterion_label,
|
|
4247
|
+
criterionId: c.criterion,
|
|
4248
|
+
title: "Add question-based headings",
|
|
4249
|
+
description: "Restructure content with H2/H3 question headings that match how users query AI assistants.",
|
|
4250
|
+
impact,
|
|
4251
|
+
effort,
|
|
4252
|
+
impactScore: 0,
|
|
4253
|
+
category: "content",
|
|
4254
|
+
steps: [
|
|
4255
|
+
"Identify top user questions for each page topic",
|
|
4256
|
+
"Convert section headings to question format (What, How, Why, When)",
|
|
4257
|
+
"Follow each question heading with a direct 2-3 sentence answer",
|
|
4258
|
+
"Add a summary answer box at the top of long-form content"
|
|
4259
|
+
],
|
|
4260
|
+
successCriteria: "At least 50% of H2/H3 headings use question format",
|
|
4261
|
+
affectedPages: affected,
|
|
4262
|
+
pageCount: affected?.length
|
|
4263
|
+
}];
|
|
4264
|
+
},
|
|
4265
|
+
clean_html: (c, pages) => {
|
|
4266
|
+
if (c.score >= 10) return [];
|
|
4267
|
+
const impact = impactFromScore(c.score);
|
|
4268
|
+
const effort = effortForCriterion("clean_html", c.score);
|
|
4269
|
+
const affected = getAffectedPages("clean_html", pages);
|
|
4270
|
+
const fixes = [{
|
|
4271
|
+
id: "fix-clean-html-structure",
|
|
4272
|
+
criterion: c.criterion_label,
|
|
4273
|
+
criterionId: c.criterion,
|
|
4274
|
+
title: "Fix HTML structure and meta tags",
|
|
4275
|
+
description: "Ensure clean, well-structured HTML with proper meta tags, HTTPS, and parseable content for AI crawlers.",
|
|
4276
|
+
impact,
|
|
4277
|
+
effort,
|
|
4278
|
+
impactScore: 0,
|
|
4279
|
+
category: "structure",
|
|
4280
|
+
steps: [
|
|
4281
|
+
"Enable HTTPS and redirect HTTP to HTTPS",
|
|
4282
|
+
"Add proper <title>, meta description, and viewport meta tags",
|
|
4283
|
+
"Fix HTML validation errors (unclosed tags, invalid nesting)",
|
|
4284
|
+
"Ensure content is server-rendered (not client-side only)"
|
|
4285
|
+
],
|
|
4286
|
+
successCriteria: "Pages pass HTML validation with proper meta tags and HTTPS",
|
|
4287
|
+
affectedPages: affected,
|
|
4288
|
+
pageCount: affected?.length
|
|
4289
|
+
}];
|
|
4290
|
+
return fixes;
|
|
4291
|
+
},
|
|
4292
|
+
entity_consistency: (c) => {
|
|
4293
|
+
if (c.score >= 10) return [];
|
|
4294
|
+
const impact = impactFromScore(c.score);
|
|
4295
|
+
const effort = effortForCriterion("entity_consistency", c.score);
|
|
4296
|
+
return [{
|
|
4297
|
+
id: "fix-entity-consistency",
|
|
4298
|
+
criterion: c.criterion_label,
|
|
4299
|
+
criterionId: c.criterion,
|
|
4300
|
+
title: "Strengthen entity authority (NAP)",
|
|
4301
|
+
description: "Add consistent name, address, phone (NAP) and sameAs links across all pages to strengthen entity recognition.",
|
|
4302
|
+
impact,
|
|
4303
|
+
effort,
|
|
4304
|
+
impactScore: 0,
|
|
4305
|
+
category: "trust",
|
|
4306
|
+
steps: [
|
|
4307
|
+
"Ensure company name is consistent across all pages",
|
|
4308
|
+
"Add Organization schema with full NAP details",
|
|
4309
|
+
"Include sameAs links to social profiles and directories",
|
|
4310
|
+
"Add logo and brand marks consistently"
|
|
4311
|
+
],
|
|
4312
|
+
successCriteria: "Organization schema present with consistent NAP on all pages"
|
|
4313
|
+
}];
|
|
4314
|
+
},
|
|
4315
|
+
robots_txt: (c) => {
|
|
4316
|
+
if (c.score >= 10) return [];
|
|
4317
|
+
const impact = impactFromScore(c.score);
|
|
4318
|
+
const effort = effortForCriterion("robots_txt", c.score);
|
|
4319
|
+
return [{
|
|
4320
|
+
id: "fix-robots-txt",
|
|
4321
|
+
criterion: c.criterion_label,
|
|
4322
|
+
criterionId: c.criterion,
|
|
4323
|
+
title: "Configure robots.txt for AI crawlers",
|
|
4324
|
+
description: "Update robots.txt to explicitly allow AI crawlers and include sitemap directive.",
|
|
4325
|
+
impact,
|
|
4326
|
+
effort,
|
|
4327
|
+
impactScore: 0,
|
|
4328
|
+
category: "discovery",
|
|
4329
|
+
steps: [
|
|
4330
|
+
"Create or update robots.txt at domain root",
|
|
4331
|
+
"Add User-agent rules for GPTBot, ClaudeBot, PerplexityBot",
|
|
4332
|
+
"Include Sitemap directive pointing to sitemap.xml",
|
|
4333
|
+
"Verify no accidental Disallow rules blocking content pages"
|
|
4334
|
+
],
|
|
4335
|
+
codeExample: `User-agent: *
|
|
4336
|
+
Allow: /
|
|
4337
|
+
|
|
4338
|
+
User-agent: GPTBot
|
|
4339
|
+
Allow: /
|
|
4340
|
+
|
|
4341
|
+
User-agent: ClaudeBot
|
|
4342
|
+
Allow: /
|
|
4343
|
+
|
|
4344
|
+
User-agent: PerplexityBot
|
|
4345
|
+
Allow: /
|
|
4346
|
+
|
|
4347
|
+
Sitemap: https://example.com/sitemap.xml`,
|
|
4348
|
+
successCriteria: "robots.txt returns 200 with AI crawler directives and Sitemap"
|
|
4349
|
+
}];
|
|
4350
|
+
},
|
|
4351
|
+
faq_section: (c, pages) => {
|
|
4352
|
+
if (c.score >= 10) return [];
|
|
4353
|
+
const impact = impactFromScore(c.score);
|
|
4354
|
+
const effort = effortForCriterion("faq_section", c.score);
|
|
4355
|
+
const affected = getAffectedPages("faq_section", pages);
|
|
4356
|
+
return [{
|
|
4357
|
+
id: "fix-faq-section",
|
|
4358
|
+
criterion: c.criterion_label,
|
|
4359
|
+
criterionId: c.criterion,
|
|
4360
|
+
title: "Build FAQ sections with schema",
|
|
4361
|
+
description: "Create FAQ content with FAQPage schema markup on key pages to become a direct answer source for AI engines.",
|
|
4362
|
+
impact,
|
|
4363
|
+
effort,
|
|
4364
|
+
impactScore: 0,
|
|
4365
|
+
category: "content",
|
|
4366
|
+
steps: [
|
|
4367
|
+
"Identify 8-10 most common customer questions per service area",
|
|
4368
|
+
"Create dedicated FAQ page with categorized Q&A pairs",
|
|
4369
|
+
"Add inline FAQ sections to key service/product pages",
|
|
4370
|
+
"Implement FAQPage JSON-LD schema on all FAQ content"
|
|
4371
|
+
],
|
|
4372
|
+
successCriteria: "FAQ page exists with FAQPage schema, key pages have inline FAQ sections",
|
|
4373
|
+
affectedPages: affected,
|
|
4374
|
+
pageCount: affected?.length
|
|
4375
|
+
}];
|
|
4376
|
+
},
|
|
4377
|
+
original_data: (c, pages) => {
|
|
4378
|
+
if (c.score >= 10) return [];
|
|
4379
|
+
const impact = impactFromScore(c.score);
|
|
4380
|
+
const effort = effortForCriterion("original_data", c.score);
|
|
4381
|
+
const affected = getAffectedPages("original_data", pages);
|
|
4382
|
+
return [{
|
|
4383
|
+
id: "fix-original-data",
|
|
4384
|
+
criterion: c.criterion_label,
|
|
4385
|
+
criterionId: c.criterion,
|
|
4386
|
+
title: "Add original data and case studies",
|
|
4387
|
+
description: "Publish proprietary data, statistics, case studies, or research that AI engines cannot find elsewhere.",
|
|
4388
|
+
impact,
|
|
4389
|
+
effort,
|
|
4390
|
+
impactScore: 0,
|
|
4391
|
+
category: "content",
|
|
4392
|
+
steps: [
|
|
4393
|
+
"Identify internal data assets (customer metrics, case study results, survey data)",
|
|
4394
|
+
"Create data-driven content with specific numbers and percentages",
|
|
4395
|
+
"Publish case studies with measurable outcomes",
|
|
4396
|
+
"Add comparison tables with proprietary benchmarks"
|
|
4397
|
+
],
|
|
4398
|
+
successCriteria: "At least 3 pages contain original data points not found elsewhere online",
|
|
4399
|
+
affectedPages: affected,
|
|
4400
|
+
pageCount: affected?.length
|
|
4401
|
+
}];
|
|
4402
|
+
},
|
|
4403
|
+
internal_linking: (c, pages, linkGraph) => {
|
|
4404
|
+
if (c.score >= 10) return [];
|
|
4405
|
+
const impact = impactFromScore(c.score);
|
|
4406
|
+
const effort = effortForCriterion("internal_linking", c.score);
|
|
4407
|
+
const fixes = [];
|
|
4408
|
+
if (linkGraph) {
|
|
4409
|
+
const orphans = [];
|
|
4410
|
+
linkGraph.nodes.forEach((node) => {
|
|
4411
|
+
if (node.isOrphan) orphans.push(node.url);
|
|
4412
|
+
});
|
|
4413
|
+
if (orphans.length > 0) {
|
|
4414
|
+
fixes.push({
|
|
4415
|
+
id: "fix-internal-linking-orphans",
|
|
4416
|
+
criterion: c.criterion_label,
|
|
4417
|
+
criterionId: c.criterion,
|
|
4418
|
+
title: "Link orphan pages into site navigation",
|
|
4419
|
+
description: `${orphans.length} pages have no incoming internal links. These are invisible to AI crawlers that follow links.`,
|
|
4420
|
+
impact: orphans.length > 5 ? "critical" : "high",
|
|
4421
|
+
effort: orphans.length > 10 ? "medium" : "low",
|
|
4422
|
+
impactScore: 0,
|
|
4423
|
+
category: "structure",
|
|
4424
|
+
steps: [
|
|
4425
|
+
`Identify the ${orphans.length} orphan pages with zero incoming links`,
|
|
4426
|
+
"Add contextual links from related content pages",
|
|
4427
|
+
"Include orphan pages in navigation menus or footer links",
|
|
4428
|
+
'Add "Related Content" sections on relevant pages'
|
|
4429
|
+
],
|
|
4430
|
+
successCriteria: "All content pages have at least 1 incoming internal link",
|
|
4431
|
+
affectedPages: orphans.slice(0, 20),
|
|
4432
|
+
pageCount: orphans.length
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
if (linkGraph.stats.maxDepth > 3) {
|
|
4436
|
+
fixes.push({
|
|
4437
|
+
id: "fix-internal-linking-depth",
|
|
4438
|
+
criterion: c.criterion_label,
|
|
4439
|
+
criterionId: c.criterion,
|
|
4440
|
+
title: "Reduce page depth for deep content",
|
|
4441
|
+
description: `Max depth is ${linkGraph.stats.maxDepth} clicks from homepage. AI crawlers rarely follow links beyond 3 levels.`,
|
|
4442
|
+
impact: "medium",
|
|
4443
|
+
effort: "medium",
|
|
4444
|
+
impactScore: 0,
|
|
4445
|
+
category: "structure",
|
|
4446
|
+
steps: [
|
|
4447
|
+
"Identify pages more than 3 clicks from the homepage",
|
|
4448
|
+
"Add direct links from high-level pages to deep content",
|
|
4449
|
+
"Consider flattening URL structure for key pages",
|
|
4450
|
+
"Add hub pages that aggregate related deep content"
|
|
4451
|
+
],
|
|
4452
|
+
successCriteria: "All important content pages reachable within 3 clicks from homepage"
|
|
4453
|
+
});
|
|
4454
|
+
}
|
|
4455
|
+
if (linkGraph.clusters.length === 0) {
|
|
4456
|
+
fixes.push({
|
|
4457
|
+
id: "fix-internal-linking-clusters",
|
|
4458
|
+
criterion: c.criterion_label,
|
|
4459
|
+
criterionId: c.criterion,
|
|
4460
|
+
title: "Create topic clusters with pillar pages",
|
|
4461
|
+
description: "No topic clusters detected. Organizing content into pillar-spoke clusters strengthens topical authority for AI engines.",
|
|
4462
|
+
impact: "high",
|
|
4463
|
+
effort: "high",
|
|
4464
|
+
impactScore: 0,
|
|
4465
|
+
category: "structure",
|
|
4466
|
+
steps: [
|
|
4467
|
+
"Identify 3-5 core topic areas for your business",
|
|
4468
|
+
"Create comprehensive pillar pages (3000+ words) for each topic",
|
|
4469
|
+
"Write 5-7 supporting articles per pillar linking back to pillar",
|
|
4470
|
+
"Interlink supporting articles within each cluster"
|
|
4471
|
+
],
|
|
4472
|
+
successCriteria: "At least 2 topic clusters with pillar page and 5+ spoke pages"
|
|
4473
|
+
});
|
|
4474
|
+
}
|
|
4475
|
+
} else {
|
|
4476
|
+
fixes.push({
|
|
4477
|
+
id: "fix-internal-linking-generic",
|
|
4478
|
+
criterion: c.criterion_label,
|
|
4479
|
+
criterionId: c.criterion,
|
|
4480
|
+
title: "Improve internal linking architecture",
|
|
4481
|
+
description: "Strengthen internal linking with descriptive anchor text between related pages.",
|
|
4482
|
+
impact,
|
|
4483
|
+
effort,
|
|
4484
|
+
impactScore: 0,
|
|
4485
|
+
category: "structure",
|
|
4486
|
+
steps: [
|
|
4487
|
+
"Audit current internal link structure",
|
|
4488
|
+
"Add contextual links between related content pages",
|
|
4489
|
+
"Ensure every key page is reachable within 3 clicks from homepage",
|
|
4490
|
+
'Use descriptive anchor text instead of "click here" or "read more"'
|
|
4491
|
+
],
|
|
4492
|
+
successCriteria: "Key pages have 3+ incoming internal links with descriptive anchors"
|
|
4493
|
+
});
|
|
4494
|
+
}
|
|
4495
|
+
return fixes;
|
|
4496
|
+
},
|
|
4497
|
+
semantic_html: (c, pages) => {
|
|
4498
|
+
if (c.score >= 10) return [];
|
|
4499
|
+
const impact = impactFromScore(c.score);
|
|
4500
|
+
const effort = effortForCriterion("semantic_html", c.score);
|
|
4501
|
+
const affected = getAffectedPages("semantic_html", pages);
|
|
4502
|
+
return [{
|
|
4503
|
+
id: "fix-semantic-html",
|
|
4504
|
+
criterion: c.criterion_label,
|
|
4505
|
+
criterionId: c.criterion,
|
|
4506
|
+
title: "Implement semantic HTML5 elements",
|
|
4507
|
+
description: "Use semantic HTML5 elements (main, article, nav, header, footer, section) to give AI parsers clear content structure.",
|
|
4508
|
+
impact,
|
|
4509
|
+
effort,
|
|
4510
|
+
impactScore: 0,
|
|
4511
|
+
category: "structure",
|
|
4512
|
+
steps: [
|
|
4513
|
+
"Wrap main content in <main> element",
|
|
4514
|
+
"Use <article> for self-contained content blocks",
|
|
4515
|
+
"Add <nav> for navigation and <aside> for sidebars",
|
|
4516
|
+
"Add lang attribute to <html> and ARIA labels for accessibility"
|
|
4517
|
+
],
|
|
4518
|
+
successCriteria: "Pages use semantic HTML5 elements with lang attribute",
|
|
4519
|
+
affectedPages: affected,
|
|
4520
|
+
pageCount: affected?.length
|
|
4521
|
+
}];
|
|
4522
|
+
},
|
|
4523
|
+
content_freshness: (c, pages) => {
|
|
4524
|
+
if (c.score >= 10) return [];
|
|
4525
|
+
const impact = impactFromScore(c.score);
|
|
4526
|
+
const effort = effortForCriterion("content_freshness", c.score);
|
|
4527
|
+
const affected = getAffectedPages("content_freshness", pages);
|
|
4528
|
+
return [{
|
|
4529
|
+
id: "fix-content-freshness",
|
|
4530
|
+
criterion: c.criterion_label,
|
|
4531
|
+
criterionId: c.criterion,
|
|
4532
|
+
title: "Add content freshness signals",
|
|
4533
|
+
description: "Include dateModified schema, visible dates, and recent content updates to signal freshness to AI engines.",
|
|
4534
|
+
impact,
|
|
4535
|
+
effort,
|
|
4536
|
+
impactScore: 0,
|
|
4537
|
+
category: "content",
|
|
4538
|
+
steps: [
|
|
4539
|
+
"Add datePublished and dateModified to Article schema",
|
|
4540
|
+
'Display visible "Last updated" dates on content pages',
|
|
4541
|
+
"Update stale content with current information",
|
|
4542
|
+
"Add <time> elements with datetime attributes for all dates"
|
|
4543
|
+
],
|
|
4544
|
+
successCriteria: "Content pages show visible dates and have dateModified in schema",
|
|
4545
|
+
affectedPages: affected,
|
|
4546
|
+
pageCount: affected?.length
|
|
4547
|
+
}];
|
|
4548
|
+
},
|
|
4549
|
+
sitemap_completeness: (c) => {
|
|
4550
|
+
if (c.score >= 10) return [];
|
|
4551
|
+
const impact = impactFromScore(c.score);
|
|
4552
|
+
const effort = effortForCriterion("sitemap_completeness", c.score);
|
|
4553
|
+
return [{
|
|
4554
|
+
id: "fix-sitemap",
|
|
4555
|
+
criterion: c.criterion_label,
|
|
4556
|
+
criterionId: c.criterion,
|
|
4557
|
+
title: "Create complete sitemap.xml",
|
|
4558
|
+
description: "Generate a comprehensive sitemap with lastmod dates for all important pages.",
|
|
4559
|
+
impact,
|
|
4560
|
+
effort,
|
|
4561
|
+
impactScore: 0,
|
|
4562
|
+
category: "discovery",
|
|
4563
|
+
steps: [
|
|
4564
|
+
"Generate sitemap.xml listing all content pages",
|
|
4565
|
+
"Include <lastmod> dates for each URL",
|
|
4566
|
+
"Set <changefreq> and <priority> appropriately",
|
|
4567
|
+
"Reference sitemap in robots.txt"
|
|
4568
|
+
],
|
|
4569
|
+
successCriteria: "sitemap.xml returns 200 with all content pages and lastmod dates"
|
|
4570
|
+
}];
|
|
4571
|
+
},
|
|
4572
|
+
rss_feed: (c) => {
|
|
4573
|
+
if (c.score >= 10) return [];
|
|
4574
|
+
const impact = impactFromScore(c.score);
|
|
4575
|
+
const effort = effortForCriterion("rss_feed", c.score);
|
|
4576
|
+
return [{
|
|
4577
|
+
id: "fix-rss-feed",
|
|
4578
|
+
criterion: c.criterion_label,
|
|
4579
|
+
criterionId: c.criterion,
|
|
4580
|
+
title: "Deploy RSS/Atom feed",
|
|
4581
|
+
description: "Add an RSS or Atom feed for your blog/news content to signal active publishing to AI engines.",
|
|
4582
|
+
impact,
|
|
4583
|
+
effort,
|
|
4584
|
+
impactScore: 0,
|
|
4585
|
+
category: "discovery",
|
|
4586
|
+
steps: [
|
|
4587
|
+
"Create RSS 2.0 or Atom feed for blog/news content",
|
|
4588
|
+
"Include title, description, pubDate, and full content for each item",
|
|
4589
|
+
'Add <link rel="alternate" type="application/rss+xml"> to page head',
|
|
4590
|
+
"Auto-generate feed on each new publish"
|
|
4591
|
+
],
|
|
4592
|
+
codeExample: `<?xml version="1.0" encoding="UTF-8"?>
|
|
4593
|
+
<rss version="2.0">
|
|
4594
|
+
<channel>
|
|
4595
|
+
<title>Your Site Blog</title>
|
|
4596
|
+
<link>https://example.com/blog</link>
|
|
4597
|
+
<description>Latest articles</description>
|
|
4598
|
+
<item>
|
|
4599
|
+
<title>Article Title</title>
|
|
4600
|
+
<link>https://example.com/blog/article</link>
|
|
4601
|
+
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
|
|
4602
|
+
<description>Article summary</description>
|
|
4603
|
+
</item>
|
|
4604
|
+
</channel>
|
|
4605
|
+
</rss>`,
|
|
4606
|
+
successCriteria: "RSS feed returns valid XML with recent content items"
|
|
4607
|
+
}];
|
|
4608
|
+
},
|
|
4609
|
+
table_list_extractability: (c, pages) => {
|
|
4610
|
+
if (c.score >= 10) return [];
|
|
4611
|
+
const impact = impactFromScore(c.score);
|
|
4612
|
+
const effort = effortForCriterion("table_list_extractability", c.score);
|
|
4613
|
+
const affected = getAffectedPages("table_list_extractability", pages);
|
|
4614
|
+
return [{
|
|
4615
|
+
id: "fix-tables-lists",
|
|
4616
|
+
criterion: c.criterion_label,
|
|
4617
|
+
criterionId: c.criterion,
|
|
4618
|
+
title: "Add structured tables and lists",
|
|
4619
|
+
description: "Use HTML tables for comparison data and lists for features, steps, and specifications.",
|
|
4620
|
+
impact,
|
|
4621
|
+
effort,
|
|
4622
|
+
impactScore: 0,
|
|
4623
|
+
category: "content",
|
|
4624
|
+
steps: [
|
|
4625
|
+
"Identify data suitable for table format (comparisons, pricing, specs)",
|
|
4626
|
+
"Convert bullet points to proper <ul>/<ol> lists",
|
|
4627
|
+
"Add comparison tables with <th> headers",
|
|
4628
|
+
"Ensure tables have descriptive captions"
|
|
4629
|
+
],
|
|
4630
|
+
successCriteria: "Key pages contain at least one HTML table or structured list",
|
|
4631
|
+
affectedPages: affected,
|
|
4632
|
+
pageCount: affected?.length
|
|
4633
|
+
}];
|
|
4634
|
+
},
|
|
4635
|
+
definition_patterns: (c, pages) => {
|
|
4636
|
+
if (c.score >= 10) return [];
|
|
4637
|
+
const impact = impactFromScore(c.score);
|
|
4638
|
+
const effort = effortForCriterion("definition_patterns", c.score);
|
|
4639
|
+
const affected = getAffectedPages("definition_patterns", pages);
|
|
4640
|
+
return [{
|
|
4641
|
+
id: "fix-definitions",
|
|
4642
|
+
criterion: c.criterion_label,
|
|
4643
|
+
criterionId: c.criterion,
|
|
4644
|
+
title: "Add definition-style content",
|
|
4645
|
+
description: 'Include clear definition patterns for key terms and concepts that AI engines can cite for "what is" queries.',
|
|
4646
|
+
impact,
|
|
4647
|
+
effort,
|
|
4648
|
+
impactScore: 0,
|
|
4649
|
+
category: "content",
|
|
4650
|
+
steps: [
|
|
4651
|
+
"Identify key industry terms your audience searches for",
|
|
4652
|
+
'Write clear definitions using "X is..." or "X refers to..." patterns',
|
|
4653
|
+
"Place definitions near the top of relevant pages",
|
|
4654
|
+
"Consider a glossary page for comprehensive term coverage"
|
|
4655
|
+
],
|
|
4656
|
+
successCriteria: "Key pages contain definition patterns for relevant terms",
|
|
4657
|
+
affectedPages: affected,
|
|
4658
|
+
pageCount: affected?.length
|
|
4659
|
+
}];
|
|
4660
|
+
},
|
|
4661
|
+
direct_answer_density: (c, pages) => {
|
|
4662
|
+
if (c.score >= 10) return [];
|
|
4663
|
+
const impact = impactFromScore(c.score);
|
|
4664
|
+
const effort = effortForCriterion("direct_answer_density", c.score);
|
|
4665
|
+
const affected = getAffectedPages("direct_answer_density", pages);
|
|
4666
|
+
return [{
|
|
4667
|
+
id: "fix-direct-answers",
|
|
4668
|
+
criterion: c.criterion_label,
|
|
4669
|
+
criterionId: c.criterion,
|
|
4670
|
+
title: "Add direct answer paragraphs",
|
|
4671
|
+
description: "Write concise, standalone answer paragraphs after question headings for AI engine citations.",
|
|
4672
|
+
impact,
|
|
4673
|
+
effort,
|
|
4674
|
+
impactScore: 0,
|
|
4675
|
+
category: "content",
|
|
4676
|
+
steps: [
|
|
4677
|
+
"Identify question-format headings on each page",
|
|
4678
|
+
"Write a 2-3 sentence direct answer immediately after each heading",
|
|
4679
|
+
"Ensure answers are self-contained (don't require context from other sections)",
|
|
4680
|
+
"Use bold for key facts within answer paragraphs"
|
|
4681
|
+
],
|
|
4682
|
+
successCriteria: "Question headings are followed by direct, concise answer paragraphs",
|
|
4683
|
+
affectedPages: affected,
|
|
4684
|
+
pageCount: affected?.length
|
|
4685
|
+
}];
|
|
4686
|
+
},
|
|
4687
|
+
content_licensing: (c) => {
|
|
4688
|
+
if (c.score >= 10) return [];
|
|
4689
|
+
const impact = impactFromScore(c.score);
|
|
4690
|
+
const effort = effortForCriterion("content_licensing", c.score);
|
|
4691
|
+
return [{
|
|
4692
|
+
id: "fix-content-licensing",
|
|
4693
|
+
criterion: c.criterion_label,
|
|
4694
|
+
criterionId: c.criterion,
|
|
4695
|
+
title: "Add ai.txt and content licensing",
|
|
4696
|
+
description: "Create an /ai.txt file specifying AI usage permissions and add license schema to structured data.",
|
|
4697
|
+
impact,
|
|
4698
|
+
effort,
|
|
4699
|
+
impactScore: 0,
|
|
4700
|
+
category: "trust",
|
|
4701
|
+
steps: [
|
|
4702
|
+
"Create ai.txt at domain root with usage permissions",
|
|
4703
|
+
"Specify allowed AI uses (training, citation, summarization)",
|
|
4704
|
+
"Add license information to schema markup",
|
|
4705
|
+
"Consider a content licensing page linked from footer"
|
|
4706
|
+
],
|
|
4707
|
+
codeExample: `# ai.txt - AI Usage Policy for example.com
|
|
4708
|
+
|
|
4709
|
+
User-Agent: *
|
|
4710
|
+
Allow: /blog/
|
|
4711
|
+
Allow: /docs/
|
|
4712
|
+
|
|
4713
|
+
# Permissions
|
|
4714
|
+
Training: yes
|
|
4715
|
+
Citation: yes with attribution
|
|
4716
|
+
Summarization: yes`,
|
|
4717
|
+
successCriteria: "/ai.txt returns 200 with clear AI usage permissions"
|
|
4718
|
+
}];
|
|
4719
|
+
},
|
|
4720
|
+
author_schema_depth: (c) => {
|
|
4721
|
+
if (c.score >= 10) return [];
|
|
4722
|
+
const impact = impactFromScore(c.score);
|
|
4723
|
+
const effort = effortForCriterion("author_schema_depth", c.score);
|
|
4724
|
+
return [{
|
|
4725
|
+
id: "fix-author-schema",
|
|
4726
|
+
criterion: c.criterion_label,
|
|
4727
|
+
criterionId: c.criterion,
|
|
4728
|
+
title: "Enhance author and expert schema",
|
|
4729
|
+
description: "Add Person schema for content authors with credentials and sameAs links for E-E-A-T signals.",
|
|
4730
|
+
impact,
|
|
4731
|
+
effort,
|
|
4732
|
+
impactScore: 0,
|
|
4733
|
+
category: "trust",
|
|
4734
|
+
steps: [
|
|
4735
|
+
"Create author profile pages for content creators",
|
|
4736
|
+
"Add Person schema with name, jobTitle, credentials, sameAs",
|
|
4737
|
+
"Link articles to author profiles via schema author property",
|
|
4738
|
+
"Include author bio and expertise on article pages"
|
|
4739
|
+
],
|
|
4740
|
+
successCriteria: "Articles have Person schema for authors with credentials"
|
|
4741
|
+
}];
|
|
4742
|
+
},
|
|
4743
|
+
fact_density: (c, pages) => {
|
|
4744
|
+
if (c.score >= 10) return [];
|
|
4745
|
+
const impact = impactFromScore(c.score);
|
|
4746
|
+
const effort = effortForCriterion("fact_density", c.score);
|
|
4747
|
+
const affected = getAffectedPages("fact_density", pages);
|
|
4748
|
+
return [{
|
|
4749
|
+
id: "fix-fact-density",
|
|
4750
|
+
criterion: c.criterion_label,
|
|
4751
|
+
criterionId: c.criterion,
|
|
4752
|
+
title: "Increase fact and data density",
|
|
4753
|
+
description: "Add specific numbers, percentages, statistics, and data points that AI engines can cite.",
|
|
4754
|
+
impact,
|
|
4755
|
+
effort,
|
|
4756
|
+
impactScore: 0,
|
|
4757
|
+
category: "content",
|
|
4758
|
+
steps: [
|
|
4759
|
+
"Review content for vague claims and replace with specific data",
|
|
4760
|
+
"Add statistics, percentages, and measurable outcomes",
|
|
4761
|
+
"Include source citations for data points",
|
|
4762
|
+
"Add data tables or comparison charts where appropriate"
|
|
4763
|
+
],
|
|
4764
|
+
successCriteria: "Key pages contain at least 3 specific data points per 500 words",
|
|
4765
|
+
affectedPages: affected,
|
|
4766
|
+
pageCount: affected?.length
|
|
4767
|
+
}];
|
|
4768
|
+
},
|
|
4769
|
+
canonical_url: (c, pages) => {
|
|
4770
|
+
if (c.score >= 10) return [];
|
|
4771
|
+
const impact = impactFromScore(c.score);
|
|
4772
|
+
const effort = effortForCriterion("canonical_url", c.score);
|
|
4773
|
+
const affected = getAffectedPages("canonical_url", pages);
|
|
4774
|
+
return [{
|
|
4775
|
+
id: "fix-canonical-url",
|
|
4776
|
+
criterion: c.criterion_label,
|
|
4777
|
+
criterionId: c.criterion,
|
|
4778
|
+
title: "Fix canonical URL strategy",
|
|
4779
|
+
description: 'Add rel="canonical" tags to all pages to prevent duplicate content confusion.',
|
|
4780
|
+
impact,
|
|
4781
|
+
effort,
|
|
4782
|
+
impactScore: 0,
|
|
4783
|
+
category: "structure",
|
|
4784
|
+
steps: [
|
|
4785
|
+
'Add <link rel="canonical"> to every page pointing to preferred URL',
|
|
4786
|
+
"Ensure canonical URLs use consistent scheme (https) and format",
|
|
4787
|
+
"Handle www vs non-www with proper redirects",
|
|
4788
|
+
"Set canonical for paginated content to the main page"
|
|
4789
|
+
],
|
|
4790
|
+
codeExample: `<link rel="canonical" href="https://example.com/page" />`,
|
|
4791
|
+
successCriteria: 'All pages have rel="canonical" pointing to the correct URL',
|
|
4792
|
+
affectedPages: affected,
|
|
4793
|
+
pageCount: affected?.length
|
|
4794
|
+
}];
|
|
4795
|
+
},
|
|
4796
|
+
content_velocity: (c) => {
|
|
4797
|
+
if (c.score >= 10) return [];
|
|
4798
|
+
const impact = impactFromScore(c.score);
|
|
4799
|
+
const effort = effortForCriterion("content_velocity", c.score);
|
|
4800
|
+
return [{
|
|
4801
|
+
id: "fix-content-velocity",
|
|
4802
|
+
criterion: c.criterion_label,
|
|
4803
|
+
criterionId: c.criterion,
|
|
4804
|
+
title: "Increase publishing frequency",
|
|
4805
|
+
description: "Establish a regular content publishing cadence to signal active, current information to AI engines.",
|
|
4806
|
+
impact,
|
|
4807
|
+
effort,
|
|
4808
|
+
impactScore: 0,
|
|
4809
|
+
category: "content",
|
|
4810
|
+
steps: [
|
|
4811
|
+
"Set a publishing schedule (weekly or bi-weekly minimum)",
|
|
4812
|
+
"Create a content calendar covering key topics",
|
|
4813
|
+
"Update sitemap and RSS feed with each new publish",
|
|
4814
|
+
"Refresh existing evergreen content with current data"
|
|
4815
|
+
],
|
|
4816
|
+
successCriteria: "At least 2 new or updated content pages per month with dated entries"
|
|
4817
|
+
}];
|
|
4818
|
+
},
|
|
4819
|
+
schema_coverage: (c) => {
|
|
4820
|
+
if (c.score >= 10) return [];
|
|
4821
|
+
const impact = impactFromScore(c.score);
|
|
4822
|
+
const effort = effortForCriterion("schema_coverage", c.score);
|
|
4823
|
+
return [{
|
|
4824
|
+
id: "fix-schema-coverage",
|
|
4825
|
+
criterion: c.criterion_label,
|
|
4826
|
+
criterionId: c.criterion,
|
|
4827
|
+
title: "Extend schema to inner pages",
|
|
4828
|
+
description: "Add page-specific structured data beyond the homepage to articles, services, and product pages.",
|
|
4829
|
+
impact,
|
|
4830
|
+
effort,
|
|
4831
|
+
impactScore: 0,
|
|
4832
|
+
category: "trust",
|
|
4833
|
+
steps: [
|
|
4834
|
+
"Add Article schema to blog/news pages",
|
|
4835
|
+
"Add Service/Product schema to service/product pages",
|
|
4836
|
+
"Add BreadcrumbList schema to all inner pages",
|
|
4837
|
+
"Validate each page type with Rich Results Test"
|
|
4838
|
+
],
|
|
4839
|
+
successCriteria: "At least 50% of content pages have page-specific schema",
|
|
4840
|
+
dependsOn: ["fix-schema-markup"]
|
|
4841
|
+
}];
|
|
4842
|
+
},
|
|
4843
|
+
speakable_schema: (c) => {
|
|
4844
|
+
if (c.score >= 10) return [];
|
|
4845
|
+
const impact = impactFromScore(c.score);
|
|
4846
|
+
const effort = effortForCriterion("speakable_schema", c.score);
|
|
4847
|
+
return [{
|
|
4848
|
+
id: "fix-speakable-schema",
|
|
4849
|
+
criterion: c.criterion_label,
|
|
4850
|
+
criterionId: c.criterion,
|
|
4851
|
+
title: "Add SpeakableSpecification schema",
|
|
4852
|
+
description: "Add Speakable schema to tell voice assistants which content sections are best for spoken answers.",
|
|
4853
|
+
impact,
|
|
4854
|
+
effort,
|
|
4855
|
+
impactScore: 0,
|
|
4856
|
+
category: "trust",
|
|
4857
|
+
steps: [
|
|
4858
|
+
"Identify key paragraphs suitable for voice readout",
|
|
4859
|
+
"Add SpeakableSpecification with CSS selectors to Article schema",
|
|
4860
|
+
"Point speakable selectors to headline and summary paragraphs",
|
|
4861
|
+
"Test with Google structured data testing tool"
|
|
4862
|
+
],
|
|
4863
|
+
codeExample: `"speakable": {
|
|
4864
|
+
"@type": "SpeakableSpecification",
|
|
4865
|
+
"cssSelector": [
|
|
4866
|
+
".article-headline",
|
|
4867
|
+
".article-summary"
|
|
4868
|
+
]
|
|
4869
|
+
}`,
|
|
4870
|
+
successCriteria: "Article pages include SpeakableSpecification in schema",
|
|
4871
|
+
dependsOn: ["fix-schema-markup"]
|
|
4872
|
+
}];
|
|
4873
|
+
},
|
|
4874
|
+
query_answer_alignment: (c, pages) => {
|
|
4875
|
+
if (c.score >= 10) return [];
|
|
4876
|
+
const impact = impactFromScore(c.score);
|
|
4877
|
+
const effort = effortForCriterion("query_answer_alignment", c.score);
|
|
4878
|
+
const affected = getAffectedPages("query_answer_alignment", pages);
|
|
4879
|
+
return [{
|
|
4880
|
+
id: "fix-query-answer-alignment",
|
|
4881
|
+
criterion: c.criterion_label,
|
|
4882
|
+
criterionId: c.criterion,
|
|
4883
|
+
title: "Improve query-answer alignment",
|
|
4884
|
+
description: "Ensure question headings are followed by direct, concise answers in the first paragraph.",
|
|
4885
|
+
impact,
|
|
4886
|
+
effort,
|
|
4887
|
+
impactScore: 0,
|
|
4888
|
+
category: "content",
|
|
4889
|
+
steps: [
|
|
4890
|
+
"Audit question-format headings and their following paragraphs",
|
|
4891
|
+
"Add direct answers in the first 1-2 sentences after each question heading",
|
|
4892
|
+
"Remove filler text between question and answer",
|
|
4893
|
+
"Ensure answers are self-contained and citable"
|
|
4894
|
+
],
|
|
4895
|
+
successCriteria: "Question headings have direct answer paragraphs within 50 words",
|
|
4896
|
+
affectedPages: affected,
|
|
4897
|
+
pageCount: affected?.length
|
|
4898
|
+
}];
|
|
4899
|
+
},
|
|
4900
|
+
content_cannibalization: (c, pages, linkGraph) => {
|
|
4901
|
+
if (c.score >= 10) return [];
|
|
4902
|
+
const impact = impactFromScore(c.score);
|
|
4903
|
+
const effort = effortForCriterion("content_cannibalization", c.score);
|
|
4904
|
+
const fixes = [];
|
|
4905
|
+
if (linkGraph && linkGraph.clusters.length > 0) {
|
|
4906
|
+
const lowCohesion = linkGraph.clusters.filter((cl) => cl.cohesion < 50);
|
|
4907
|
+
if (lowCohesion.length > 0) {
|
|
4908
|
+
const affected = lowCohesion.flatMap((cl) => [cl.pillarUrl, ...cl.spokes]);
|
|
4909
|
+
fixes.push({
|
|
4910
|
+
id: "fix-content-cannibalization-overlap",
|
|
4911
|
+
criterion: c.criterion_label,
|
|
4912
|
+
criterionId: c.criterion,
|
|
4913
|
+
title: "Consolidate overlapping content",
|
|
4914
|
+
description: `${lowCohesion.length} content clusters have low cohesion, suggesting pages compete for the same topics.`,
|
|
4915
|
+
impact,
|
|
4916
|
+
effort,
|
|
4917
|
+
impactScore: 0,
|
|
4918
|
+
category: "content",
|
|
4919
|
+
steps: [
|
|
4920
|
+
"Identify pages targeting the same keywords or topics",
|
|
4921
|
+
"Merge overlapping pages into single authoritative pages",
|
|
4922
|
+
"Set up 301 redirects from merged pages to consolidated page",
|
|
4923
|
+
"Differentiate remaining similar pages with distinct angles"
|
|
4924
|
+
],
|
|
4925
|
+
successCriteria: "No two pages target the same primary keyword or topic",
|
|
4926
|
+
affectedPages: affected.slice(0, 20),
|
|
4927
|
+
pageCount: affected.length
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
if (fixes.length === 0) {
|
|
4932
|
+
const affected = getAffectedPages("content_cannibalization", pages);
|
|
4933
|
+
fixes.push({
|
|
4934
|
+
id: "fix-content-cannibalization",
|
|
4935
|
+
criterion: c.criterion_label,
|
|
4936
|
+
criterionId: c.criterion,
|
|
4937
|
+
title: "Resolve content cannibalization",
|
|
4938
|
+
description: "Multiple pages may be targeting the same topics, diluting AI engine citations.",
|
|
4939
|
+
impact,
|
|
4940
|
+
effort,
|
|
4941
|
+
impactScore: 0,
|
|
4942
|
+
category: "content",
|
|
4943
|
+
steps: [
|
|
4944
|
+
"Audit pages for overlapping topic coverage",
|
|
4945
|
+
"Consolidate similar pages into comprehensive single pages",
|
|
4946
|
+
"Differentiate remaining pages with distinct angles and keywords",
|
|
4947
|
+
"Add canonical tags to prevent duplicate content issues"
|
|
4948
|
+
],
|
|
4949
|
+
successCriteria: "Each topic is covered by a single authoritative page",
|
|
4950
|
+
affectedPages: affected,
|
|
4951
|
+
pageCount: affected?.length
|
|
4952
|
+
});
|
|
4953
|
+
}
|
|
4954
|
+
return fixes;
|
|
4955
|
+
},
|
|
4956
|
+
visible_date_signal: (c, pages) => {
|
|
4957
|
+
if (c.score >= 10) return [];
|
|
4958
|
+
const impact = impactFromScore(c.score);
|
|
4959
|
+
const effort = effortForCriterion("visible_date_signal", c.score);
|
|
4960
|
+
const affected = getAffectedPages("visible_date_signal", pages);
|
|
4961
|
+
return [{
|
|
4962
|
+
id: "fix-visible-dates",
|
|
4963
|
+
criterion: c.criterion_label,
|
|
4964
|
+
criterionId: c.criterion,
|
|
4965
|
+
title: "Add visible date signals",
|
|
4966
|
+
description: "Add visible publication and modification dates using <time> elements for AI engine freshness assessment.",
|
|
4967
|
+
impact,
|
|
4968
|
+
effort,
|
|
4969
|
+
impactScore: 0,
|
|
4970
|
+
category: "content",
|
|
4971
|
+
steps: [
|
|
4972
|
+
'Add visible "Published" and "Last updated" dates to content pages',
|
|
4973
|
+
"Use <time> elements with datetime attributes",
|
|
4974
|
+
"Ensure dates match dateModified in schema markup",
|
|
4975
|
+
"Update dates when content is refreshed"
|
|
4976
|
+
],
|
|
4977
|
+
codeExample: `<time datetime="2024-01-15">January 15, 2024</time>`,
|
|
4978
|
+
successCriteria: "Content pages show visible dates with <time> elements",
|
|
4979
|
+
affectedPages: affected,
|
|
4980
|
+
pageCount: affected?.length
|
|
4981
|
+
}];
|
|
4982
|
+
}
|
|
4983
|
+
};
|
|
4984
|
+
function generateFixPlan(domain, overallScore, criteria, pagesReviewed, linkGraph) {
|
|
4985
|
+
const allFixes = [];
|
|
4986
|
+
for (const criterion of criteria) {
|
|
4987
|
+
const generator = FIX_GENERATORS[criterion.criterion];
|
|
4988
|
+
if (!generator) continue;
|
|
4989
|
+
const fixes = generator(criterion, pagesReviewed, linkGraph);
|
|
4990
|
+
for (const fix of fixes) {
|
|
4991
|
+
const weight = CRITERION_WEIGHTS2[criterion.criterion] ?? 0.05;
|
|
4992
|
+
fix.impactScore = Math.round((10 - criterion.score) * weight * 100);
|
|
4993
|
+
allFixes.push(fix);
|
|
4994
|
+
}
|
|
4995
|
+
}
|
|
4996
|
+
const phases = PHASE_CONFIG.map((config) => {
|
|
4997
|
+
const phaseFixes = allFixes.filter((fix) => config.criteria.includes(fix.criterionId)).sort((a, b) => b.impactScore - a.impactScore);
|
|
4998
|
+
return {
|
|
4999
|
+
phase: config.phase,
|
|
5000
|
+
title: config.title,
|
|
5001
|
+
description: config.description,
|
|
5002
|
+
fixes: phaseFixes,
|
|
5003
|
+
estimatedImpact: 0
|
|
5004
|
+
// calculated after projected score
|
|
5005
|
+
};
|
|
5006
|
+
});
|
|
5007
|
+
for (const phase of phases) {
|
|
5008
|
+
for (const fix of phase.fixes) {
|
|
5009
|
+
if (!fix.dependsOn) continue;
|
|
5010
|
+
for (const depId of fix.dependsOn) {
|
|
5011
|
+
const depPhase = phases.find((p) => p.fixes.some((f) => f.id === depId));
|
|
5012
|
+
if (depPhase && depPhase.phase > phase.phase) {
|
|
5013
|
+
phase.fixes = phase.fixes.filter((f) => f.id !== fix.id);
|
|
5014
|
+
depPhase.fixes.push(fix);
|
|
5015
|
+
depPhase.fixes.sort((a, b) => b.impactScore - a.impactScore);
|
|
5016
|
+
break;
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
}
|
|
5020
|
+
}
|
|
5021
|
+
const totalWeight = Object.values(CRITERION_WEIGHTS2).reduce((s, w) => s + w, 0);
|
|
5022
|
+
const bestDeltaPerCriterion = /* @__PURE__ */ new Map();
|
|
5023
|
+
for (const fix of allFixes) {
|
|
5024
|
+
const criterion = criteria.find((c) => c.criterion === fix.criterionId);
|
|
5025
|
+
if (!criterion) continue;
|
|
5026
|
+
const weight = CRITERION_WEIGHTS2[fix.criterionId] ?? 0.05;
|
|
5027
|
+
let targetScore;
|
|
5028
|
+
switch (fix.effort) {
|
|
5029
|
+
case "trivial":
|
|
5030
|
+
case "low":
|
|
5031
|
+
targetScore = 8;
|
|
5032
|
+
break;
|
|
5033
|
+
case "medium":
|
|
5034
|
+
targetScore = 7;
|
|
5035
|
+
break;
|
|
5036
|
+
case "high":
|
|
5037
|
+
targetScore = 6;
|
|
5038
|
+
break;
|
|
5039
|
+
}
|
|
5040
|
+
const improvement = Math.max(0, targetScore - criterion.score);
|
|
5041
|
+
const delta = improvement * weight / totalWeight * 100;
|
|
5042
|
+
const existing = bestDeltaPerCriterion.get(fix.criterionId) ?? 0;
|
|
5043
|
+
if (delta > existing) bestDeltaPerCriterion.set(fix.criterionId, delta);
|
|
5044
|
+
}
|
|
5045
|
+
const scoreDelta = Array.from(bestDeltaPerCriterion.values()).reduce((s, d) => s + d, 0);
|
|
5046
|
+
const projectedScore = Math.min(100, Math.round(overallScore + scoreDelta));
|
|
5047
|
+
for (const phase of phases) {
|
|
5048
|
+
let phaseImpact = 0;
|
|
5049
|
+
const seenCriteria = /* @__PURE__ */ new Set();
|
|
5050
|
+
for (const fix of phase.fixes) {
|
|
5051
|
+
if (seenCriteria.has(fix.criterionId)) continue;
|
|
5052
|
+
seenCriteria.add(fix.criterionId);
|
|
5053
|
+
const criterion = criteria.find((c) => c.criterion === fix.criterionId);
|
|
5054
|
+
if (!criterion) continue;
|
|
5055
|
+
const weight = CRITERION_WEIGHTS2[fix.criterionId] ?? 0.05;
|
|
5056
|
+
let targetScore;
|
|
5057
|
+
switch (fix.effort) {
|
|
5058
|
+
case "trivial":
|
|
5059
|
+
case "low":
|
|
5060
|
+
targetScore = 8;
|
|
5061
|
+
break;
|
|
5062
|
+
case "medium":
|
|
5063
|
+
targetScore = 7;
|
|
5064
|
+
break;
|
|
5065
|
+
case "high":
|
|
5066
|
+
targetScore = 6;
|
|
5067
|
+
break;
|
|
5068
|
+
}
|
|
5069
|
+
const improvement = Math.max(0, targetScore - criterion.score);
|
|
5070
|
+
phaseImpact += improvement * weight / totalWeight * 100;
|
|
5071
|
+
}
|
|
5072
|
+
phase.estimatedImpact = Math.round(phaseImpact);
|
|
5073
|
+
}
|
|
5074
|
+
const quickWins = allFixes.filter(
|
|
5075
|
+
(f) => (f.effort === "trivial" || f.effort === "low") && (f.impact === "critical" || f.impact === "high")
|
|
5076
|
+
);
|
|
5077
|
+
const summary = {
|
|
5078
|
+
criticalCount: allFixes.filter((f) => f.impact === "critical").length,
|
|
5079
|
+
highCount: allFixes.filter((f) => f.impact === "high").length,
|
|
5080
|
+
mediumCount: allFixes.filter((f) => f.impact === "medium").length,
|
|
5081
|
+
lowCount: allFixes.filter((f) => f.impact === "low").length,
|
|
5082
|
+
quickWinCount: quickWins.length,
|
|
5083
|
+
topOpportunity: allFixes.length > 0 ? allFixes.sort((a, b) => b.impactScore - a.impactScore)[0].title : "None",
|
|
5084
|
+
estimatedTotalEffort: formatEffort(allFixes.reduce((s, f) => s + effortToHours(f.effort), 0))
|
|
5085
|
+
};
|
|
5086
|
+
return {
|
|
5087
|
+
domain,
|
|
5088
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5089
|
+
overallScore,
|
|
5090
|
+
projectedScore,
|
|
5091
|
+
totalFixes: allFixes.length,
|
|
5092
|
+
phases,
|
|
5093
|
+
quickWins,
|
|
5094
|
+
summary
|
|
5095
|
+
};
|
|
5096
|
+
}
|
|
5097
|
+
function formatEffort(hours) {
|
|
5098
|
+
if (hours < 1) return "<1h";
|
|
5099
|
+
return `~${Math.round(hours)}h`;
|
|
5100
|
+
}
|
|
5101
|
+
|
|
3509
5102
|
// src/html-report.ts
|
|
3510
5103
|
function scoreColor(score) {
|
|
3511
5104
|
if (score <= 40) return "#F44336";
|
|
@@ -3842,21 +5435,28 @@ async function compare(domainA, domainB, options) {
|
|
|
3842
5435
|
audit,
|
|
3843
5436
|
auditSiteFromData,
|
|
3844
5437
|
buildDetailedFindings,
|
|
5438
|
+
buildLinkGraph,
|
|
3845
5439
|
buildScorecard,
|
|
5440
|
+
calculateDepths,
|
|
3846
5441
|
calculateOverallScore,
|
|
3847
5442
|
classifyRendering,
|
|
3848
5443
|
compare,
|
|
3849
5444
|
crawlFullSite,
|
|
5445
|
+
detectClusters,
|
|
5446
|
+
detectHubs,
|
|
3850
5447
|
detectParkedDomain,
|
|
5448
|
+
detectPillars,
|
|
3851
5449
|
extractAllUrlsFromSitemap,
|
|
3852
5450
|
extractContentPagesFromSitemap,
|
|
3853
5451
|
extractInternalLinks,
|
|
5452
|
+
extractLinksWithAnchors,
|
|
3854
5453
|
extractNavLinks,
|
|
3855
5454
|
extractRawDataSummary,
|
|
3856
5455
|
fetchMultiPageData,
|
|
3857
5456
|
fetchWithHeadless,
|
|
3858
5457
|
generateBottomLine,
|
|
3859
5458
|
generateComparisonHtmlReport,
|
|
5459
|
+
generateFixPlan,
|
|
3860
5460
|
generateHtmlReport,
|
|
3861
5461
|
generateOpportunities,
|
|
3862
5462
|
generatePitchNumbers,
|
|
@@ -3866,6 +5466,7 @@ async function compare(domainA, domainB, options) {
|
|
|
3866
5466
|
prefetchSiteData,
|
|
3867
5467
|
scoreAllPages,
|
|
3868
5468
|
scorePage,
|
|
3869
|
-
scoreToStatus
|
|
5469
|
+
scoreToStatus,
|
|
5470
|
+
serializeLinkGraph
|
|
3870
5471
|
});
|
|
3871
5472
|
//# sourceMappingURL=index.cjs.map
|