aeorank 1.2.3 → 1.3.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 +6 -3
- package/dist/cli.js +279 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +279 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +279 -7
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AEORank
|
|
2
2
|
|
|
3
|
-
Score any website for AI engine visibility across
|
|
3
|
+
Score any website for AI engine visibility across 26 criteria. Pure HTTP + regex - zero API keys required.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/aeorank)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -33,13 +33,13 @@ import { audit } from 'aeorank';
|
|
|
33
33
|
|
|
34
34
|
const result = await audit('example.com');
|
|
35
35
|
console.log(result.overallScore); // 0-100
|
|
36
|
-
console.log(result.scorecard); //
|
|
36
|
+
console.log(result.scorecard); // 26 criteria with scores
|
|
37
37
|
console.log(result.opportunities); // Prioritized improvements
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## What It Checks
|
|
41
41
|
|
|
42
|
-
AEORank evaluates
|
|
42
|
+
AEORank evaluates 26 criteria across 4 categories that determine how AI engines (ChatGPT, Claude, Perplexity, Google AI Overviews) discover, parse, and cite your content:
|
|
43
43
|
|
|
44
44
|
| # | Criterion | Weight | Category |
|
|
45
45
|
|---|-----------|--------|----------|
|
|
@@ -66,6 +66,9 @@ AEORank evaluates 23 criteria across 4 categories that determine how AI engines
|
|
|
66
66
|
| 21 | Content Publishing Velocity | 3% | Content |
|
|
67
67
|
| 22 | Schema Coverage & Depth | 3% | Structure |
|
|
68
68
|
| 23 | Speakable Schema | 3% | Structure |
|
|
69
|
+
| 24 | Query-Answer Alignment | 8% | Content |
|
|
70
|
+
| 25 | Content Cannibalization | 5% | Content |
|
|
71
|
+
| 26 | Visible Date Signal | 4% | Content |
|
|
69
72
|
|
|
70
73
|
## CLI Options
|
|
71
74
|
|
package/dist/cli.js
CHANGED
|
@@ -1392,6 +1392,209 @@ function checkSpeakableSchema(data) {
|
|
|
1392
1392
|
}
|
|
1393
1393
|
return { criterion: "speakable_schema", criterion_label: "Speakable Schema", score: Math.min(10, score), status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P2" };
|
|
1394
1394
|
}
|
|
1395
|
+
function extractQuestionHeadings(html) {
|
|
1396
|
+
const hTags = (html.match(/<h[23][^>]*>([\s\S]*?)<\/h[23]>/gi) || []).map((h) => h.replace(/<[^>]*>/g, "").trim());
|
|
1397
|
+
return hTags.filter((h) => h.includes("?") || /^(what|how|why|when|who|where|can|do|does|is|are|should)\s/i.test(h));
|
|
1398
|
+
}
|
|
1399
|
+
function checkQueryAnswerAlignment(data) {
|
|
1400
|
+
const findings = [];
|
|
1401
|
+
if (!data.homepage) {
|
|
1402
|
+
findings.push({ severity: "critical", detail: "No homepage available for query-answer alignment analysis" });
|
|
1403
|
+
return { criterion: "query_answer_alignment", criterion_label: "Query-Answer Alignment", score: 0, status: "fail", findings, fix_priority: "P1" };
|
|
1404
|
+
}
|
|
1405
|
+
const combinedHtml = getCombinedHtml(data);
|
|
1406
|
+
const questionHeadings = extractQuestionHeadings(combinedHtml);
|
|
1407
|
+
if (questionHeadings.length === 0) {
|
|
1408
|
+
findings.push({ severity: "info", detail: "No question-format headings (H2/H3) found - scoring neutral", fix: 'Add question-based headings like "What is...?", "How does...?" to enable Q&A snippet extraction' });
|
|
1409
|
+
return { criterion: "query_answer_alignment", criterion_label: "Query-Answer Alignment", score: 5, status: "partial", findings, fix_priority: "P2" };
|
|
1410
|
+
}
|
|
1411
|
+
let answered = 0;
|
|
1412
|
+
for (const qHeading of questionHeadings) {
|
|
1413
|
+
const escapedHeading = qHeading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1414
|
+
const pattern = new RegExp(escapedHeading + "[\\s\\S]{0,200}?<\\/h[23]>([\\s\\S]{0,1500}?)(?=<h[1-6]|$)", "i");
|
|
1415
|
+
const match = pattern.exec(combinedHtml);
|
|
1416
|
+
if (match) {
|
|
1417
|
+
const afterContent = match[1].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
1418
|
+
if (afterContent.length >= 20) {
|
|
1419
|
+
answered++;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const rate = Math.round(answered / questionHeadings.length * 100);
|
|
1424
|
+
let score;
|
|
1425
|
+
if (rate >= 80) {
|
|
1426
|
+
score = 10;
|
|
1427
|
+
findings.push({ severity: "info", detail: `${answered}/${questionHeadings.length} question headings (${rate}%) followed by direct answers - excellent alignment` });
|
|
1428
|
+
} else if (rate >= 50) {
|
|
1429
|
+
score = 7;
|
|
1430
|
+
findings.push({ severity: "low", detail: `${answered}/${questionHeadings.length} question headings (${rate}%) have answers`, fix: "Add concise answer paragraphs after remaining unanswered question headings" });
|
|
1431
|
+
} else if (rate > 0) {
|
|
1432
|
+
score = 4;
|
|
1433
|
+
findings.push({ severity: "medium", detail: `Only ${answered}/${questionHeadings.length} question headings (${rate}%) are followed by answers`, fix: "Ensure each question heading is immediately followed by a direct answer paragraph" });
|
|
1434
|
+
} else {
|
|
1435
|
+
score = 0;
|
|
1436
|
+
findings.push({ severity: "high", detail: `${questionHeadings.length} question headings found but none have direct answers`, fix: "Add answer paragraphs (2-3 sentences) immediately after each question heading" });
|
|
1437
|
+
}
|
|
1438
|
+
return { criterion: "query_answer_alignment", criterion_label: "Query-Answer Alignment", score, status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P1" };
|
|
1439
|
+
}
|
|
1440
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1441
|
+
"a",
|
|
1442
|
+
"an",
|
|
1443
|
+
"the",
|
|
1444
|
+
"and",
|
|
1445
|
+
"or",
|
|
1446
|
+
"but",
|
|
1447
|
+
"in",
|
|
1448
|
+
"on",
|
|
1449
|
+
"at",
|
|
1450
|
+
"to",
|
|
1451
|
+
"for",
|
|
1452
|
+
"of",
|
|
1453
|
+
"with",
|
|
1454
|
+
"by",
|
|
1455
|
+
"from",
|
|
1456
|
+
"is",
|
|
1457
|
+
"it",
|
|
1458
|
+
"as",
|
|
1459
|
+
"be",
|
|
1460
|
+
"was",
|
|
1461
|
+
"are",
|
|
1462
|
+
"this",
|
|
1463
|
+
"that",
|
|
1464
|
+
"your",
|
|
1465
|
+
"our",
|
|
1466
|
+
"we",
|
|
1467
|
+
"you",
|
|
1468
|
+
"how",
|
|
1469
|
+
"what",
|
|
1470
|
+
"why"
|
|
1471
|
+
]);
|
|
1472
|
+
function extractPageTitle(html) {
|
|
1473
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
1474
|
+
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
1475
|
+
const title = titleMatch?.[1]?.trim() || "";
|
|
1476
|
+
const h1 = h1Match?.[1]?.replace(/<[^>]*>/g, "").trim() || "";
|
|
1477
|
+
return (title + " " + h1).toLowerCase().trim();
|
|
1478
|
+
}
|
|
1479
|
+
function titleToWordSet(title) {
|
|
1480
|
+
return new Set(
|
|
1481
|
+
title.split(/\s+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w))
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
function jaccardSimilarity(a, b) {
|
|
1485
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
1486
|
+
let intersection = 0;
|
|
1487
|
+
for (const word of a) {
|
|
1488
|
+
if (b.has(word)) intersection++;
|
|
1489
|
+
}
|
|
1490
|
+
const union = a.size + b.size - intersection;
|
|
1491
|
+
return union === 0 ? 0 : intersection / union;
|
|
1492
|
+
}
|
|
1493
|
+
function checkContentCannibalization(data) {
|
|
1494
|
+
const findings = [];
|
|
1495
|
+
if (!data.homepage) {
|
|
1496
|
+
findings.push({ severity: "critical", detail: "No homepage available for cannibalization analysis" });
|
|
1497
|
+
return { criterion: "content_cannibalization", criterion_label: "Content Cannibalization", score: 0, status: "fail", findings, fix_priority: "P1" };
|
|
1498
|
+
}
|
|
1499
|
+
const pages = [
|
|
1500
|
+
{ html: data.homepage.text, url: data.homepage.finalUrl || `https://${data.domain}/` }
|
|
1501
|
+
];
|
|
1502
|
+
if (data.blogSample) {
|
|
1503
|
+
for (const page of data.blogSample.slice(0, 5)) {
|
|
1504
|
+
pages.push({ html: page.text, url: page.finalUrl || "" });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (pages.length <= 1) {
|
|
1508
|
+
findings.push({ severity: "info", detail: "Only homepage available - cannot assess content cannibalization", fix: "Add blog/content pages to enable cross-page topic overlap analysis" });
|
|
1509
|
+
return { criterion: "content_cannibalization", criterion_label: "Content Cannibalization", score: 5, status: "partial", findings, fix_priority: "P3" };
|
|
1510
|
+
}
|
|
1511
|
+
const pageTitles = pages.map((p) => ({ title: extractPageTitle(p.html), url: p.url }));
|
|
1512
|
+
const wordSets = pageTitles.map((p) => titleToWordSet(p.title));
|
|
1513
|
+
const cannibalPairs = [];
|
|
1514
|
+
for (let i = 0; i < pages.length; i++) {
|
|
1515
|
+
for (let j = i + 1; j < pages.length; j++) {
|
|
1516
|
+
const sim = jaccardSimilarity(wordSets[i], wordSets[j]);
|
|
1517
|
+
if (sim > 0.6) {
|
|
1518
|
+
cannibalPairs.push({
|
|
1519
|
+
urlA: pageTitles[i].url.slice(0, 60),
|
|
1520
|
+
urlB: pageTitles[j].url.slice(0, 60),
|
|
1521
|
+
similarity: Math.round(sim * 100)
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
let score;
|
|
1527
|
+
if (cannibalPairs.length === 0) {
|
|
1528
|
+
score = 10;
|
|
1529
|
+
findings.push({ severity: "info", detail: `${pages.length} pages analyzed - no content cannibalization detected` });
|
|
1530
|
+
} else if (cannibalPairs.length === 1) {
|
|
1531
|
+
score = 8;
|
|
1532
|
+
findings.push({ severity: "low", detail: `1 pair of pages with overlapping topics (${cannibalPairs[0].similarity}% similarity)`, fix: "Differentiate titles and H1 headings to reduce topic overlap" });
|
|
1533
|
+
} else if (cannibalPairs.length === 2) {
|
|
1534
|
+
score = 5;
|
|
1535
|
+
findings.push({ severity: "medium", detail: `${cannibalPairs.length} pairs of pages with overlapping topics`, fix: "Consolidate overlapping pages or differentiate their titles and content focus" });
|
|
1536
|
+
} else {
|
|
1537
|
+
score = 0;
|
|
1538
|
+
findings.push({ severity: "high", detail: `${cannibalPairs.length} pairs of pages competing for the same topics`, fix: "Significant content overlap detected - consolidate or clearly differentiate competing pages" });
|
|
1539
|
+
}
|
|
1540
|
+
for (const pair of cannibalPairs.slice(0, 3)) {
|
|
1541
|
+
findings.push({ severity: "low", detail: `Overlap (${pair.similarity}%): ${pair.urlA} vs ${pair.urlB}` });
|
|
1542
|
+
}
|
|
1543
|
+
return { criterion: "content_cannibalization", criterion_label: "Content Cannibalization", score, status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P1" };
|
|
1544
|
+
}
|
|
1545
|
+
function checkVisibleDateSignal(data) {
|
|
1546
|
+
const findings = [];
|
|
1547
|
+
if (!data.homepage) {
|
|
1548
|
+
findings.push({ severity: "critical", detail: "No homepage available for date signal analysis" });
|
|
1549
|
+
return { criterion: "visible_date_signal", criterion_label: "Visible Date Signal", score: 0, status: "fail", findings, fix_priority: "P1" };
|
|
1550
|
+
}
|
|
1551
|
+
const combinedHtml = getCombinedHtml(data);
|
|
1552
|
+
let score = 0;
|
|
1553
|
+
const timeElements = combinedHtml.match(/<time[^>]*datetime="[^"]*"[^>]*>[^<]+<\/time>/gi) || [];
|
|
1554
|
+
const hasVisibleTime = timeElements.length > 0;
|
|
1555
|
+
if (hasVisibleTime) {
|
|
1556
|
+
score += 5;
|
|
1557
|
+
findings.push({ severity: "info", detail: `${timeElements.length} visible <time> element(s) with datetime attribute found` });
|
|
1558
|
+
}
|
|
1559
|
+
const ldJsonBlocks = combinedHtml.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || [];
|
|
1560
|
+
const ldJsonText = ldJsonBlocks.join(" ");
|
|
1561
|
+
const hasDatePublished = /datePublished/i.test(ldJsonText);
|
|
1562
|
+
const hasDateModified = /dateModified/i.test(ldJsonText);
|
|
1563
|
+
const hasSchemaDate = hasDatePublished || hasDateModified;
|
|
1564
|
+
if (hasSchemaDate) {
|
|
1565
|
+
if (!hasVisibleTime) score += 7;
|
|
1566
|
+
else score += 5;
|
|
1567
|
+
const dateTypes = [hasDatePublished && "datePublished", hasDateModified && "dateModified"].filter(Boolean).join(" + ");
|
|
1568
|
+
findings.push({ severity: "info", detail: `JSON-LD schema contains ${dateTypes}` });
|
|
1569
|
+
}
|
|
1570
|
+
const hasMetaPublished = /<meta[^>]*property="article:published_time"[^>]*>/i.test(combinedHtml);
|
|
1571
|
+
const hasMetaModified = /<meta[^>]*property="article:modified_time"[^>]*>/i.test(combinedHtml);
|
|
1572
|
+
const hasMetaDate = hasMetaPublished || hasMetaModified;
|
|
1573
|
+
if (hasMetaDate && !hasVisibleTime && !hasSchemaDate) {
|
|
1574
|
+
score += 3;
|
|
1575
|
+
findings.push({ severity: "info", detail: "Article meta tags with date information found" });
|
|
1576
|
+
} else if (hasMetaDate) {
|
|
1577
|
+
findings.push({ severity: "info", detail: "Article meta date tags also present (supplementary)" });
|
|
1578
|
+
}
|
|
1579
|
+
if (hasDateModified) {
|
|
1580
|
+
const dateModMatch = ldJsonText.match(/"dateModified"\s*:\s*"([^"]+)"/i);
|
|
1581
|
+
if (dateModMatch) {
|
|
1582
|
+
const modDate = new Date(dateModMatch[1]);
|
|
1583
|
+
if (!isNaN(modDate.getTime())) {
|
|
1584
|
+
const daysDiff = Math.floor((Date.now() - modDate.getTime()) / (1e3 * 60 * 60 * 24));
|
|
1585
|
+
if (daysDiff <= 180) {
|
|
1586
|
+
score += 1;
|
|
1587
|
+
findings.push({ severity: "info", detail: `dateModified is recent (${daysDiff} days ago) - freshness bonus applied` });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
score = Math.min(10, score);
|
|
1593
|
+
if (score === 0) {
|
|
1594
|
+
findings.push({ severity: "high", detail: "No visible date signals found (no <time> elements, no JSON-LD dates, no article meta dates)", fix: 'Add <time datetime="..."> elements for user-visible dates and datePublished/dateModified to JSON-LD schema' });
|
|
1595
|
+
}
|
|
1596
|
+
return { criterion: "visible_date_signal", criterion_label: "Visible Date Signal", score, status: score >= 7 ? "pass" : score >= 4 ? "partial" : "fail", findings, fix_priority: score >= 7 ? "P3" : "P1" };
|
|
1597
|
+
}
|
|
1395
1598
|
function extractRawDataSummary(data) {
|
|
1396
1599
|
const html = data.homepage?.text || "";
|
|
1397
1600
|
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ");
|
|
@@ -1513,6 +1716,48 @@ function extractRawDataSummary(data) {
|
|
|
1513
1716
|
if (!data.blogSample || data.blogSample.length === 0) return false;
|
|
1514
1717
|
const blogHtml = data.blogSample.map((p) => p.text).join("\n");
|
|
1515
1718
|
return /faqpage/i.test(blogHtml) && /application\/ld\+json/i.test(blogHtml);
|
|
1719
|
+
})(),
|
|
1720
|
+
// Criteria 24-26 fields
|
|
1721
|
+
question_heading_answer_rate: (() => {
|
|
1722
|
+
const combinedHtml = getCombinedHtml(data);
|
|
1723
|
+
const qHeadings = extractQuestionHeadings(combinedHtml);
|
|
1724
|
+
if (qHeadings.length === 0) return -1;
|
|
1725
|
+
let answered = 0;
|
|
1726
|
+
for (const qh of qHeadings) {
|
|
1727
|
+
const escaped = qh.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1728
|
+
const pat = new RegExp(escaped + "[\\s\\S]{0,200}?<\\/h[23]>([\\s\\S]{0,1500}?)(?=<h[1-6]|$)", "i");
|
|
1729
|
+
const m = pat.exec(combinedHtml);
|
|
1730
|
+
if (m && m[1].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length >= 20) answered++;
|
|
1731
|
+
}
|
|
1732
|
+
return Math.round(answered / qHeadings.length * 100);
|
|
1733
|
+
})(),
|
|
1734
|
+
question_heading_total: extractQuestionHeadings(getCombinedHtml(data)).length,
|
|
1735
|
+
cannibalizing_pairs_count: (() => {
|
|
1736
|
+
const pages = [{ html: data.homepage?.text || "" }];
|
|
1737
|
+
if (data.blogSample) for (const p of data.blogSample.slice(0, 5)) pages.push({ html: p.text });
|
|
1738
|
+
if (pages.length <= 1) return 0;
|
|
1739
|
+
const ws = pages.map((p) => titleToWordSet(extractPageTitle(p.html)));
|
|
1740
|
+
let pairs = 0;
|
|
1741
|
+
for (let i = 0; i < ws.length; i++) {
|
|
1742
|
+
for (let j = i + 1; j < ws.length; j++) {
|
|
1743
|
+
if (jaccardSimilarity(ws[i], ws[j]) > 0.6) pairs++;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return pairs;
|
|
1747
|
+
})(),
|
|
1748
|
+
page_titles_sampled: 1 + (data.blogSample?.length ?? 0),
|
|
1749
|
+
has_visible_date: /<time[^>]*datetime="[^"]*"[^>]*>[^<]+<\/time>/i.test(getCombinedHtml(data)),
|
|
1750
|
+
has_schema_date_in_ld: (() => {
|
|
1751
|
+
const ld = (getCombinedHtml(data).match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || []).join(" ");
|
|
1752
|
+
return /datePublished|dateModified/i.test(ld);
|
|
1753
|
+
})(),
|
|
1754
|
+
date_modified_recency_days: (() => {
|
|
1755
|
+
const ld = (getCombinedHtml(data).match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi) || []).join(" ");
|
|
1756
|
+
const m = ld.match(/"dateModified"\s*:\s*"([^"]+)"/i);
|
|
1757
|
+
if (!m) return null;
|
|
1758
|
+
const d = new Date(m[1]);
|
|
1759
|
+
if (isNaN(d.getTime())) return null;
|
|
1760
|
+
return Math.floor((Date.now() - d.getTime()) / (1e3 * 60 * 60 * 24));
|
|
1516
1761
|
})()
|
|
1517
1762
|
};
|
|
1518
1763
|
}
|
|
@@ -1540,7 +1785,10 @@ function auditSiteFromData(data) {
|
|
|
1540
1785
|
checkCanonicalUrl(data),
|
|
1541
1786
|
checkContentVelocity(data),
|
|
1542
1787
|
checkSchemaCoverage(data),
|
|
1543
|
-
checkSpeakableSchema(data)
|
|
1788
|
+
checkSpeakableSchema(data),
|
|
1789
|
+
checkQueryAnswerAlignment(data),
|
|
1790
|
+
checkContentCannibalization(data),
|
|
1791
|
+
checkVisibleDateSignal(data)
|
|
1544
1792
|
];
|
|
1545
1793
|
}
|
|
1546
1794
|
|
|
@@ -1570,7 +1818,10 @@ var WEIGHTS = {
|
|
|
1570
1818
|
canonical_url: 0.04,
|
|
1571
1819
|
content_velocity: 0.03,
|
|
1572
1820
|
schema_coverage: 0.03,
|
|
1573
|
-
speakable_schema: 0.03
|
|
1821
|
+
speakable_schema: 0.03,
|
|
1822
|
+
query_answer_alignment: 0.08,
|
|
1823
|
+
content_cannibalization: 0.05,
|
|
1824
|
+
visible_date_signal: 0.04
|
|
1574
1825
|
};
|
|
1575
1826
|
function calculateOverallScore(criteria) {
|
|
1576
1827
|
let totalWeight = 0;
|
|
@@ -1692,7 +1943,10 @@ var CRITERION_LABELS = {
|
|
|
1692
1943
|
"Canonical URL Strategy": "Canonical URL Strategy",
|
|
1693
1944
|
"Content Publishing Velocity": "Content Publishing Velocity",
|
|
1694
1945
|
"Schema Coverage & Depth": "Schema Coverage & Depth",
|
|
1695
|
-
"Speakable Schema": "Speakable Schema"
|
|
1946
|
+
"Speakable Schema": "Speakable Schema",
|
|
1947
|
+
"Query-Answer Alignment": "Query-Answer Alignment",
|
|
1948
|
+
"Content Cannibalization": "Content Cannibalization",
|
|
1949
|
+
"Visible Date Signal": "Visible Date Signal"
|
|
1696
1950
|
};
|
|
1697
1951
|
function scoreToStatus(score) {
|
|
1698
1952
|
if (score === 0) return "MISSING";
|
|
@@ -1800,7 +2054,10 @@ var CRITERION_WEIGHTS = {
|
|
|
1800
2054
|
canonical_url: 0.04,
|
|
1801
2055
|
content_velocity: 0.03,
|
|
1802
2056
|
schema_coverage: 0.03,
|
|
1803
|
-
speakable_schema: 0.03
|
|
2057
|
+
speakable_schema: 0.03,
|
|
2058
|
+
query_answer_alignment: 0.08,
|
|
2059
|
+
content_cannibalization: 0.05,
|
|
2060
|
+
visible_date_signal: 0.04
|
|
1804
2061
|
};
|
|
1805
2062
|
var OPPORTUNITY_TEMPLATES = {
|
|
1806
2063
|
llms_txt: {
|
|
@@ -1917,6 +2174,21 @@ var OPPORTUNITY_TEMPLATES = {
|
|
|
1917
2174
|
name: "Add Speakable Schema",
|
|
1918
2175
|
effort: "Low",
|
|
1919
2176
|
description: "Add SpeakableSpecification schema with CSS selectors pointing to key content sections. This tells voice assistants and AI engines which parts of your page are most suitable for spoken answers."
|
|
2177
|
+
},
|
|
2178
|
+
query_answer_alignment: {
|
|
2179
|
+
name: "Improve Question-Answer Alignment",
|
|
2180
|
+
effort: "Medium",
|
|
2181
|
+
description: "Ensure every question-format heading (H2/H3) is followed by a direct answer paragraph. This pattern is ideal for AI engine snippet extraction."
|
|
2182
|
+
},
|
|
2183
|
+
content_cannibalization: {
|
|
2184
|
+
name: "Resolve Content Cannibalization",
|
|
2185
|
+
effort: "Medium",
|
|
2186
|
+
description: "Multiple pages compete for the same topic. Consolidate overlapping pages or differentiate titles and H1 headings."
|
|
2187
|
+
},
|
|
2188
|
+
visible_date_signal: {
|
|
2189
|
+
name: "Add Visible Date Signals",
|
|
2190
|
+
effort: "Low",
|
|
2191
|
+
description: "Display publication/modification dates visibly using <time> elements and add datePublished/dateModified to JSON-LD schema."
|
|
1920
2192
|
}
|
|
1921
2193
|
};
|
|
1922
2194
|
function calculateImpact(score, weight, effort) {
|
|
@@ -2038,8 +2310,8 @@ function generatePitchNumbers(score, rawData, scorecard) {
|
|
|
2038
2310
|
const passing = scorecard.filter((s) => s.score >= 7).length;
|
|
2039
2311
|
metrics.push({
|
|
2040
2312
|
metric: "Criteria Passing",
|
|
2041
|
-
value: `${passing}/
|
|
2042
|
-
significance: passing >= 18 ? "Excellent coverage across AEO dimensions" : passing >= 12 ? "Good foundation with room to improve remaining criteria" : `${
|
|
2313
|
+
value: `${passing}/26`,
|
|
2314
|
+
significance: passing >= 18 ? "Excellent coverage across AEO dimensions" : passing >= 12 ? "Good foundation with room to improve remaining criteria" : `${26 - passing} criteria need attention for full AI visibility`
|
|
2043
2315
|
});
|
|
2044
2316
|
return metrics;
|
|
2045
2317
|
}
|
|
@@ -2681,7 +2953,7 @@ function generateHtmlReport(result) {
|
|
|
2681
2953
|
|
|
2682
2954
|
<div class="verdict">${escapeHtml(result.verdict)}</div>
|
|
2683
2955
|
|
|
2684
|
-
<h2 class="section-title">Scorecard (
|
|
2956
|
+
<h2 class="section-title">Scorecard (26 Criteria)</h2>
|
|
2685
2957
|
<div class="scorecard-grid">
|
|
2686
2958
|
${scorecardCards}
|
|
2687
2959
|
</div>
|