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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # AEORank
2
2
 
3
- Score any website for AI engine visibility across 23 criteria. Pure HTTP + regex - zero API keys required.
3
+ Score any website for AI engine visibility across 26 criteria. Pure HTTP + regex - zero API keys required.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/aeorank.svg)](https://www.npmjs.com/package/aeorank)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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); // 23 criteria with scores
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 23 criteria across 4 categories that determine how AI engines (ChatGPT, Claude, Perplexity, Google AI Overviews) discover, parse, and cite your content:
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}/23`,
2042
- significance: passing >= 18 ? "Excellent coverage across AEO dimensions" : passing >= 12 ? "Good foundation with room to improve remaining criteria" : `${23 - passing} criteria need attention for full AI visibility`
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 (23 Criteria)</h2>
2956
+ <h2 class="section-title">Scorecard (26 Criteria)</h2>
2685
2957
  <div class="scorecard-grid">
2686
2958
  ${scorecardCards}
2687
2959
  </div>