aeorank 1.4.0 → 1.5.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
@@ -125,7 +125,7 @@ Run a complete audit. Returns `AuditResult` with:
125
125
  - `pitchNumbers` - Key metrics (schema types, AI crawler access, etc.)
126
126
  - `verdict` - Human-readable summary paragraph
127
127
  - `bottomLine` - Actionable recommendation
128
- - `pagesReviewed` - Per-page analysis with issues and strengths
128
+ - `pagesReviewed` - Per-page analysis with issues, strengths, and AEO score (0-100)
129
129
  - `elapsed` - Wall-clock seconds
130
130
 
131
131
  **Options:**
@@ -139,6 +139,17 @@ Run a complete audit. Returns `AuditResult` with:
139
139
  | `maxPages` | `number` | `200` | Max pages for full crawl |
140
140
  | `concurrency` | `number` | `5` | Parallel fetches for full crawl |
141
141
 
142
+ ### `scorePage(html, url?)`
143
+
144
+ Score a single HTML page against 14 per-page AEO criteria. Returns `PageScoreResult` with:
145
+
146
+ - `aeoScore` - 0-100 weighted score
147
+ - `criterionScores` - 14 `PageCriterionScore` entries (criterion, score 0-10, weight)
148
+
149
+ ### `scoreAllPages(siteData)`
150
+
151
+ Batch-score all pages (homepage + blogSample) from a `SiteData` object. Returns `PageScoreResult[]`.
152
+
142
153
  ### Advanced API
143
154
 
144
155
  For custom pipelines, import individual stages:
@@ -152,6 +163,8 @@ import {
152
163
  buildDetailedFindings,
153
164
  generateVerdict,
154
165
  generateOpportunities,
166
+ scorePage,
167
+ scoreAllPages,
155
168
  isSpaShell,
156
169
  fetchWithHeadless,
157
170
  } from 'aeorank';
@@ -208,6 +221,43 @@ console.log(crawlResult.pages.length); // Pages fetched
208
221
  console.log(crawlResult.discoveredUrls.length); // Total URLs found
209
222
  ```
210
223
 
224
+ ## Per-Page Scoring
225
+
226
+ AEORank scores each individual page (0-100) against the 14 criteria that apply at page level. Instead of only seeing "your site scores 62," you get "your /about page scores 45, your /blog/guide scores 78."
227
+
228
+ The 14 per-page criteria: Schema.org Structured Data, Q&A Content Format, Clean Crawlable HTML, FAQ Section Content, Original Data & Expert Content, Query-Answer Alignment, Content Freshness Signals, Table & List Extractability, Direct Answer Paragraphs, Semantic HTML5 & Accessibility, Fact & Data Density, Definition Patterns, Canonical URL Strategy, Visible Date Signal.
229
+
230
+ The remaining 12 criteria (llms.txt, robots.txt, sitemap, RSS, entity consistency, internal linking, content licensing, author schema, content velocity, schema coverage, speakable schema, content cannibalization) are site-level only.
231
+
232
+ ### CLI Output
233
+
234
+ Per-page scores appear in the pages section:
235
+
236
+ ```
237
+ Pages reviewed (47):
238
+ Homepage https://example.com 0 issues [AEO: 72]
239
+ Blog https://example.com/blog/post 2 issues [AEO: 58]
240
+
241
+ Average page AEO score: 62/100
242
+ Top: /blog/medicare-walkers-guide (92)
243
+ Bottom: /thank-you (23)
244
+ ```
245
+
246
+ ### Programmatic API
247
+
248
+ ```ts
249
+ import { scorePage, scoreAllPages } from 'aeorank';
250
+ import type { PageScoreResult, PageCriterionScore } from 'aeorank';
251
+
252
+ // Score a single page
253
+ const result = scorePage(html, url);
254
+ console.log(result.aeoScore); // 0-100
255
+ console.log(result.criterionScores); // 14 per-criterion scores
256
+
257
+ // Score all pages from site data
258
+ const allScores = scoreAllPages(siteData);
259
+ ```
260
+
211
261
  ## Scoring
212
262
 
213
263
  Each criterion is scored 0-10 by deterministic checks (regex, HTML parsing, HTTP headers). The overall score is a weighted average normalized to 0-100.
@@ -266,7 +316,7 @@ console.log(result.comparison.tied); // Criteria with equal scores
266
316
 
267
317
  ## Benchmark Dataset
268
318
 
269
- The `data/` directory contains the largest open dataset of AI visibility scores - **13,619 domains** scored across 23 criteria, including **4,328 Y Combinator startups** across 48 batches (W06-W26):
319
+ The `data/` directory contains the largest open dataset of AI visibility scores - **13,619 domains** scored across 26 criteria, including **4,328 Y Combinator startups** across 48 batches (W06-W26):
270
320
 
271
321
  | File | Contents |
272
322
  |------|----------|
package/dist/cli.js CHANGED
@@ -2504,12 +2504,345 @@ async function fetchMultiPageData(siteData, options) {
2504
2504
  return added;
2505
2505
  }
2506
2506
 
2507
+ // src/page-scorer.ts
2508
+ var PAGE_CRITERIA = {
2509
+ schema_markup: { weight: 0.15, label: "Schema.org Structured Data" },
2510
+ qa_content_format: { weight: 0.15, label: "Q&A Content Format" },
2511
+ clean_html: { weight: 0.1, label: "Clean, Crawlable HTML" },
2512
+ faq_section: { weight: 0.1, label: "FAQ Section Content" },
2513
+ original_data: { weight: 0.1, label: "Original Data & Expert Content" },
2514
+ query_answer_alignment: { weight: 0.08, label: "Query-Answer Alignment" },
2515
+ content_freshness: { weight: 0.07, label: "Content Freshness Signals" },
2516
+ table_list_extractability: { weight: 0.07, label: "Table & List Extractability" },
2517
+ direct_answer_density: { weight: 0.07, label: "Direct Answer Paragraphs" },
2518
+ semantic_html: { weight: 0.05, label: "Semantic HTML5 & Accessibility" },
2519
+ fact_density: { weight: 0.05, label: "Fact & Data Density" },
2520
+ definition_patterns: { weight: 0.04, label: "Definition Patterns" },
2521
+ canonical_url: { weight: 0.04, label: "Canonical URL Strategy" },
2522
+ visible_date_signal: { weight: 0.04, label: "Visible Date Signal" }
2523
+ };
2524
+ function extractJsonLdBlocks(html) {
2525
+ const blocks = [];
2526
+ const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
2527
+ let match;
2528
+ while ((match = regex.exec(html)) !== null) {
2529
+ blocks.push(match[1]);
2530
+ }
2531
+ return blocks;
2532
+ }
2533
+ function extractTypesFromJsonLd(blocks) {
2534
+ const types = /* @__PURE__ */ new Set();
2535
+ for (const block of blocks) {
2536
+ const typeMatches = block.match(/"@type"\s*:\s*"([^"]+)"/g) || [];
2537
+ for (const m of typeMatches) {
2538
+ const t = m.match(/"@type"\s*:\s*"([^"]+)"/);
2539
+ if (t) types.add(t[1]);
2540
+ }
2541
+ }
2542
+ return types;
2543
+ }
2544
+ function getTextContent(html) {
2545
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
2546
+ }
2547
+ function extractQuestionHeadings2(html) {
2548
+ const headings = html.match(/<h[2-3][^>]*>([\s\S]*?)<\/h[2-3]>/gi) || [];
2549
+ const questions = [];
2550
+ for (const h of headings) {
2551
+ const text = h.replace(/<[^>]*>/g, "").trim();
2552
+ if (/\?$/.test(text) || /^(what|how|why|when|where|who|which|can|do|does|is|are|should|will)\b/i.test(text)) {
2553
+ questions.push(text);
2554
+ }
2555
+ }
2556
+ return questions;
2557
+ }
2558
+ function countAnsweredQuestions(html) {
2559
+ const questions = extractQuestionHeadings2(html);
2560
+ if (questions.length === 0) return { total: 0, answered: 0 };
2561
+ let answered = 0;
2562
+ for (const q of questions) {
2563
+ const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2564
+ const pattern = new RegExp(escaped + "[\\s\\S]*?</h[2-3]>\\s*<p[^>]*>([\\s\\S]*?)</p>", "i");
2565
+ const match = html.match(pattern);
2566
+ if (match && match[1].replace(/<[^>]*>/g, "").trim().length >= 20) {
2567
+ answered++;
2568
+ }
2569
+ }
2570
+ return { total: questions.length, answered };
2571
+ }
2572
+ function cap(value, max) {
2573
+ return Math.min(value, max);
2574
+ }
2575
+ function scoreSchemaMarkup(html) {
2576
+ const blocks = extractJsonLdBlocks(html);
2577
+ if (blocks.length === 0) return 0;
2578
+ let score = 3;
2579
+ const types = extractTypesFromJsonLd(blocks);
2580
+ const knownTypes = [
2581
+ "Organization",
2582
+ "LocalBusiness",
2583
+ "Article",
2584
+ "FAQPage",
2585
+ "Product",
2586
+ "WebPage",
2587
+ "BreadcrumbList",
2588
+ "HowTo",
2589
+ "Person",
2590
+ "WebSite",
2591
+ "BlogPosting",
2592
+ "Service"
2593
+ ];
2594
+ let knownCount = 0;
2595
+ for (const t of types) {
2596
+ if (knownTypes.includes(t)) knownCount++;
2597
+ }
2598
+ score += cap(knownCount * 2, 4);
2599
+ if (types.has("Organization") || types.has("LocalBusiness")) score += 2;
2600
+ if (types.has("FAQPage")) score += 1;
2601
+ return cap(score, 10);
2602
+ }
2603
+ function scoreQAFormat(html) {
2604
+ const questions = extractQuestionHeadings2(html);
2605
+ let score = 0;
2606
+ if (questions.length >= 10) score += 5;
2607
+ else if (questions.length >= 3) score += 3;
2608
+ else if (questions.length >= 1) score += 1;
2609
+ const { answered } = countAnsweredQuestions(html);
2610
+ if (answered >= 1) score += 3;
2611
+ const h1Matches = html.match(/<h1[\s>]/gi) || [];
2612
+ if (h1Matches.length === 1) score += 2;
2613
+ return cap(score, 10);
2614
+ }
2615
+ function scoreCleanHtml(html) {
2616
+ let score = 0;
2617
+ const semantics = ["<main", "<article", "<section"];
2618
+ let semCount = 0;
2619
+ for (const tag of semantics) {
2620
+ if (html.toLowerCase().includes(tag)) semCount++;
2621
+ }
2622
+ score += cap(semCount, 3);
2623
+ const h1Matches = html.match(/<h1[\s>]/gi) || [];
2624
+ if (h1Matches.length === 1) score += 2;
2625
+ const text = getTextContent(html);
2626
+ if (text.length > 500) score += 3;
2627
+ const hasTitle = /<title[^>]*>[^<]+<\/title>/i.test(html);
2628
+ const hasDesc = /<meta\s[^>]*name=["']description["'][^>]*content=["'][^"']+["']/i.test(html) || /<meta\s[^>]*content=["'][^"']+["'][^>]*name=["']description["']/i.test(html);
2629
+ if (hasTitle && hasDesc) score += 2;
2630
+ return cap(score, 10);
2631
+ }
2632
+ function scoreFaqSection(html) {
2633
+ let score = 0;
2634
+ const lowerHtml = html.toLowerCase();
2635
+ if (/frequently\s*asked|faq/i.test(html)) score += 2;
2636
+ const blocks = extractJsonLdBlocks(html);
2637
+ const types = extractTypesFromJsonLd(blocks);
2638
+ if (types.has("FAQPage")) score += 3;
2639
+ const questions = extractQuestionHeadings2(html);
2640
+ if (questions.length >= 10) score += 1;
2641
+ if (/<details[\s>]/i.test(html) || /accordion|collapsible|toggle/i.test(lowerHtml)) score += 1;
2642
+ return cap(score, 10);
2643
+ }
2644
+ function scoreOriginalData(html) {
2645
+ let score = 0;
2646
+ const text = getTextContent(html);
2647
+ if (/\b(our (study|analysis|research|survey|data|findings))\b/i.test(text)) {
2648
+ score += 3;
2649
+ } else if (/\d+(\.\d+)?%|\$[\d,.]+|\b\d{1,3}(,\d{3})+\b/.test(text)) {
2650
+ score += 1;
2651
+ }
2652
+ if (/\bcase\s+stud(y|ies)\b/i.test(text) && /\d+(\.\d+)?%|\$[\d,.]+/.test(text)) {
2653
+ score += 3;
2654
+ } else if (/\bcase\s+stud(y|ies)\b/i.test(text)) {
2655
+ score += 1;
2656
+ }
2657
+ if (/\baccording\s+to\b|\bexpert|\b(Ph\.?D|MD|professor|analyst|researcher)\b/i.test(text)) {
2658
+ score += 2;
2659
+ }
2660
+ if (/href=["'][^"']*\/blog\b/i.test(html)) {
2661
+ score += 2;
2662
+ }
2663
+ return cap(score, 10);
2664
+ }
2665
+ function scoreQueryAnswerAlignment(html) {
2666
+ const { total, answered } = countAnsweredQuestions(html);
2667
+ if (total === 0) return 5;
2668
+ const ratio = answered / total;
2669
+ if (ratio >= 0.8) return 10;
2670
+ if (ratio >= 0.5) return 7;
2671
+ if (answered > 0) return 4;
2672
+ return 0;
2673
+ }
2674
+ function scoreContentFreshness(html) {
2675
+ let score = 0;
2676
+ const blocks = extractJsonLdBlocks(html);
2677
+ const allJsonLd = blocks.join(" ");
2678
+ if (/datePublished|dateModified/i.test(allJsonLd)) score += 3;
2679
+ const timeElements = html.match(/<time[\s>]/gi) || [];
2680
+ if (timeElements.length >= 2) score += 3;
2681
+ else if (timeElements.length === 1) score += 1;
2682
+ if (/<meta\s[^>]*property=["']article:(published_time|modified_time)["']/i.test(html)) score += 2;
2683
+ const currentYear = (/* @__PURE__ */ new Date()).getFullYear();
2684
+ const yearPattern = new RegExp(`\\b(${currentYear}|${currentYear - 1})\\b`);
2685
+ if (yearPattern.test(html)) score += 2;
2686
+ return cap(score, 10);
2687
+ }
2688
+ function scoreTableListExtractability(html) {
2689
+ let score = 0;
2690
+ const tablesWithHeaders = html.match(/<table[\s\S]*?<th[\s>]/gi) || [];
2691
+ if (tablesWithHeaders.length >= 2) score += 4;
2692
+ else if (tablesWithHeaders.length === 1) score += 3;
2693
+ if (tablesWithHeaders.length === 0 && /<table[\s>]/i.test(html)) score += 1;
2694
+ if (/<ol[\s>]/i.test(html)) score += 2;
2695
+ if (/<ul[\s>]/i.test(html)) score += 2;
2696
+ const listItems = html.match(/<li[\s>]/gi) || [];
2697
+ if (listItems.length >= 10) score += 1;
2698
+ if (/<dl[\s>]/i.test(html)) score += 1;
2699
+ return cap(score, 10);
2700
+ }
2701
+ function scoreDirectAnswerDensity(html) {
2702
+ let score = 0;
2703
+ const { answered } = countAnsweredQuestions(html);
2704
+ if (answered >= 3) score += 6;
2705
+ else if (answered >= 1) score += 3;
2706
+ const paragraphs = html.match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
2707
+ let snippetCount = 0;
2708
+ for (const p of paragraphs) {
2709
+ const text = p.replace(/<[^>]*>/g, "").trim();
2710
+ const words = text.split(/\s+/).filter((w) => w.length > 0).length;
2711
+ if (words >= 40 && words <= 150) snippetCount++;
2712
+ }
2713
+ if (snippetCount >= 3) score += 2;
2714
+ else if (snippetCount >= 1) score += 1;
2715
+ const directOpeners = getTextContent(html).match(/\b(yes|no|in short|the answer is|simply put|in summary)\b/gi) || [];
2716
+ if (directOpeners.length >= 2) score += 2;
2717
+ return cap(score, 10);
2718
+ }
2719
+ function scoreSemanticHtml(html) {
2720
+ let score = 0;
2721
+ const lowerHtml = html.toLowerCase();
2722
+ const elements = ["<main", "<article", "<time", "<nav", "<header", "<footer"];
2723
+ let count = 0;
2724
+ for (const el of elements) {
2725
+ if (lowerHtml.includes(el)) count++;
2726
+ }
2727
+ score += cap(Math.floor(count * 0.7), 4);
2728
+ const imgTags = html.match(/<img\s[^>]*>/gi) || [];
2729
+ if (imgTags.length > 0) {
2730
+ let withAlt = 0;
2731
+ for (const img of imgTags) {
2732
+ if (/\salt=["'][^"']*["']/i.test(img)) withAlt++;
2733
+ }
2734
+ if (withAlt / imgTags.length >= 0.8) score += 2;
2735
+ }
2736
+ if (/<html[^>]*\slang=["'][^"']+["']/i.test(html)) score += 2;
2737
+ if (/\baria-/i.test(html)) score += 2;
2738
+ return cap(score, 10);
2739
+ }
2740
+ function scoreFactDensity(html) {
2741
+ let score = 0;
2742
+ const text = getTextContent(html);
2743
+ const numericPatterns = text.match(/\d+(\.\d+)?%|\$[\d,.]+|\b\d{1,3}(,\d{3})+\b|\b\d+\s*(million|billion|thousand|users|customers|employees)\b/gi) || [];
2744
+ if (numericPatterns.length >= 6) score += 5;
2745
+ else if (numericPatterns.length >= 3) score += 3;
2746
+ else if (numericPatterns.length >= 1) score += 1;
2747
+ const years = /* @__PURE__ */ new Set();
2748
+ const yearMatches = text.match(/\b(19|20)\d{2}\b/g) || [];
2749
+ for (const y of yearMatches) years.add(y);
2750
+ if (years.size >= 2) score += 2;
2751
+ else if (years.size === 1) score += 1;
2752
+ if (/\baccording to\b|\bsource:\s|\bcited\b|\breported by\b/i.test(text)) score += 2;
2753
+ const units = text.match(/\b\d+\s*(kg|lb|miles|km|hours|minutes|days|months|years|GB|MB|TB)\b/gi) || [];
2754
+ if (units.length >= 2) score += 1;
2755
+ return cap(score, 10);
2756
+ }
2757
+ function scoreDefinitionPatterns(html) {
2758
+ let score = 0;
2759
+ const text = getTextContent(html);
2760
+ const defPatterns = text.match(/\b(is a|is an|refers to|defined as|means that|also known as|abbreviated as)\b/gi) || [];
2761
+ if (defPatterns.length >= 3) score += 5;
2762
+ else if (defPatterns.length >= 1) score += 3;
2763
+ const early = text.slice(0, 2e3);
2764
+ if (/\b(is a|is an|refers to|defined as)\b/i.test(early)) score += 2;
2765
+ if (/<dfn[\s>]/i.test(html) || /<abbr[\s>]/i.test(html)) score += 1;
2766
+ if (/<dl[\s>]/i.test(html) || /glossary/i.test(html)) score += 2;
2767
+ return cap(score, 10);
2768
+ }
2769
+ function scoreCanonicalUrl(html, url) {
2770
+ let score = 0;
2771
+ const canonicalMatch = html.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i) || html.match(/<link[^>]*href=["']([^"']+)["'][^>]*rel=["']canonical["']/i);
2772
+ if (!canonicalMatch) return 0;
2773
+ score += 4;
2774
+ const canonicalHref = canonicalMatch[1];
2775
+ if (url) {
2776
+ try {
2777
+ const canonicalUrl = new URL(canonicalHref, url);
2778
+ const pageUrl = new URL(url);
2779
+ if (canonicalUrl.pathname === pageUrl.pathname && canonicalUrl.hostname === pageUrl.hostname) {
2780
+ score += 3;
2781
+ }
2782
+ } catch {
2783
+ }
2784
+ }
2785
+ if (canonicalHref.startsWith("https://")) score += 2;
2786
+ const allCanonicals = html.match(/<link[^>]*rel=["']canonical["'][^>]*>/gi) || [];
2787
+ if (allCanonicals.length === 1) score += 1;
2788
+ return cap(score, 10);
2789
+ }
2790
+ function scoreVisibleDateSignal(html) {
2791
+ let score = 0;
2792
+ const timeWithDatetime = html.match(/<time[^>]*datetime=["'][^"']+["'][^>]*>[^<]+<\/time>/gi) || [];
2793
+ if (timeWithDatetime.length > 0) score += 5;
2794
+ const blocks = extractJsonLdBlocks(html);
2795
+ const allJsonLd = blocks.join(" ");
2796
+ if (/datePublished|dateModified/i.test(allJsonLd)) score += 3;
2797
+ if (/<meta\s[^>]*property=["']article:(published_time|modified_time)["']/i.test(html)) score += 2;
2798
+ const modifiedMatch = allJsonLd.match(/"dateModified"\s*:\s*"([^"]+)"/i);
2799
+ if (modifiedMatch) {
2800
+ try {
2801
+ const modified = new Date(modifiedMatch[1]);
2802
+ const daysDiff = (Date.now() - modified.getTime()) / (1e3 * 60 * 60 * 24);
2803
+ if (daysDiff <= 180) score += 1;
2804
+ } catch {
2805
+ }
2806
+ }
2807
+ return cap(score, 10);
2808
+ }
2809
+ var SCORING_FUNCTIONS = {
2810
+ schema_markup: scoreSchemaMarkup,
2811
+ qa_content_format: scoreQAFormat,
2812
+ clean_html: scoreCleanHtml,
2813
+ faq_section: scoreFaqSection,
2814
+ original_data: scoreOriginalData,
2815
+ query_answer_alignment: scoreQueryAnswerAlignment,
2816
+ content_freshness: scoreContentFreshness,
2817
+ table_list_extractability: scoreTableListExtractability,
2818
+ direct_answer_density: scoreDirectAnswerDensity,
2819
+ semantic_html: scoreSemanticHtml,
2820
+ fact_density: scoreFactDensity,
2821
+ definition_patterns: scoreDefinitionPatterns,
2822
+ canonical_url: scoreCanonicalUrl,
2823
+ visible_date_signal: scoreVisibleDateSignal
2824
+ };
2825
+ function scorePage(html, url) {
2826
+ let totalWeight = 0;
2827
+ let weightedSum = 0;
2828
+ const criterionScores = [];
2829
+ for (const [criterion, { weight, label }] of Object.entries(PAGE_CRITERIA)) {
2830
+ const fn = SCORING_FUNCTIONS[criterion];
2831
+ const score = fn(html, url);
2832
+ criterionScores.push({ criterion, criterion_label: label, score, weight });
2833
+ weightedSum += score / 10 * weight * 100;
2834
+ totalWeight += weight;
2835
+ }
2836
+ const aeoScore = totalWeight === 0 ? 0 : Math.round(weightedSum / totalWeight);
2837
+ return { aeoScore, criterionScores };
2838
+ }
2839
+
2507
2840
  // src/page-analyzer.ts
2508
2841
  function extractTitle(html) {
2509
2842
  const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
2510
2843
  return match ? match[1].replace(/\s+/g, " ").trim() : "";
2511
2844
  }
2512
- function getTextContent(html) {
2845
+ function getTextContent2(html) {
2513
2846
  return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
2514
2847
  }
2515
2848
  function countWords(text) {
@@ -2662,7 +2995,7 @@ function checkHasQuestionHeadings(html) {
2662
2995
  }
2663
2996
  function analyzePage(html, url, category) {
2664
2997
  const title = extractTitle(html);
2665
- const textContent = getTextContent(html);
2998
+ const textContent = getTextContent2(html);
2666
2999
  const wordCount = countWords(textContent);
2667
3000
  const issues = [];
2668
3001
  const strengths = [];
@@ -2688,7 +3021,8 @@ function analyzePage(html, url, category) {
2688
3021
  for (const result of strengthChecks) {
2689
3022
  if (result) strengths.push(result);
2690
3023
  }
2691
- return { url, title, category, wordCount, issues, strengths };
3024
+ const { aeoScore, criterionScores } = scorePage(html, url);
3025
+ return { url, title, category, wordCount, issues, strengths, aeoScore, criterionScores };
2692
3026
  }
2693
3027
  function analyzeAllPages(siteData) {
2694
3028
  const reviews = [];
@@ -2937,17 +3271,22 @@ function generateHtmlReport(result) {
2937
3271
  <td>${escapeHtml(opp.description)}</td>
2938
3272
  </tr>`;
2939
3273
  }).join("\n");
2940
- const pagesRows = (result.pagesReviewed || []).map((page) => {
3274
+ const pagesReviewed = result.pagesReviewed || [];
3275
+ const pagesRows = pagesReviewed.map((page) => {
2941
3276
  const issueCount = page.issues.length;
2942
3277
  const strengthCount = page.strengths.length;
3278
+ const aeoDisplay = page.aeoScore != null ? `<span style="font-weight:600;color:${scoreColor(page.aeoScore)}">${page.aeoScore}</span>` : "-";
2943
3279
  return `<tr>
2944
3280
  <td>${escapeHtml(page.url)}</td>
2945
3281
  <td>${escapeHtml(page.category)}</td>
2946
3282
  <td>${page.wordCount}</td>
3283
+ <td>${aeoDisplay}</td>
2947
3284
  <td>${issueCount}</td>
2948
3285
  <td>${strengthCount}</td>
2949
3286
  </tr>`;
2950
3287
  }).join("\n");
3288
+ const scoredPages = pagesReviewed.filter((p) => p.aeoScore != null);
3289
+ const avgPageScore = scoredPages.length > 0 ? Math.round(scoredPages.reduce((sum, p) => sum + p.aeoScore, 0) / scoredPages.length) : null;
2951
3290
  const now = (/* @__PURE__ */ new Date()).toISOString();
2952
3291
  return `<!DOCTYPE html>
2953
3292
  <html lang="en">
@@ -2983,10 +3322,11 @@ function generateHtmlReport(result) {
2983
3322
  </table>
2984
3323
  ` : ""}
2985
3324
 
2986
- ${(result.pagesReviewed || []).length > 0 ? `
2987
- <h2 class="section-title">Pages Reviewed (${(result.pagesReviewed || []).length})</h2>
3325
+ ${pagesReviewed.length > 0 ? `
3326
+ <h2 class="section-title">Pages Reviewed (${pagesReviewed.length})</h2>
3327
+ ${avgPageScore != null ? `<div class="summary-box"><div class="summary-stat"><div class="num" style="color:${scoreColor(avgPageScore)}">${avgPageScore}</div><div class="label">Avg Page AEO Score</div></div></div>` : ""}
2988
3328
  <table>
2989
- <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>Issues</th><th>Strengths</th></tr></thead>
3329
+ <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>AEO Score</th><th>Issues</th><th>Strengths</th></tr></thead>
2990
3330
  <tbody>${pagesRows}</tbody>
2991
3331
  </table>
2992
3332
  ` : ""}
@@ -3111,7 +3451,7 @@ function generateComparisonHtmlReport(result) {
3111
3451
  }
3112
3452
 
3113
3453
  // src/cli.ts
3114
- var VERSION = "1.4.0";
3454
+ var VERSION = "1.5.0";
3115
3455
  function printHelp() {
3116
3456
  console.log(`
3117
3457
  aeorank - AI Engine Optimization audit
@@ -3221,7 +3561,27 @@ function printSummary(result) {
3221
3561
  const cat = page.category.charAt(0).toUpperCase() + page.category.slice(1);
3222
3562
  const issueCount = page.issues.length;
3223
3563
  const issueLabel = issueCount === 0 ? "0 issues" : issueCount === 1 ? "1 issue" : `${issueCount} issues`;
3224
- log(` ${cat.padEnd(10)} ${page.url.padEnd(50)} ${issueLabel}`);
3564
+ const aeoLabel = page.aeoScore != null ? ` [AEO: ${page.aeoScore}]` : "";
3565
+ log(` ${cat.padEnd(10)} ${page.url.padEnd(50)} ${issueLabel}${aeoLabel}`);
3566
+ }
3567
+ const scored = result.pagesReviewed.filter((p) => p.aeoScore != null);
3568
+ if (scored.length > 0) {
3569
+ const avg = Math.round(scored.reduce((sum, p) => sum + p.aeoScore, 0) / scored.length);
3570
+ const sorted = [...scored].sort((a, b) => b.aeoScore - a.aeoScore);
3571
+ const top = sorted[0];
3572
+ const bottom = sorted[sorted.length - 1];
3573
+ log("");
3574
+ log(` Average page AEO score: ${avg}/100`);
3575
+ try {
3576
+ log(` Top: ${new URL(top.url).pathname} (${top.aeoScore})`);
3577
+ } catch {
3578
+ log(` Top: ${top.url} (${top.aeoScore})`);
3579
+ }
3580
+ try {
3581
+ log(` Bottom: ${new URL(bottom.url).pathname} (${bottom.aeoScore})`);
3582
+ } catch {
3583
+ log(` Bottom: ${bottom.url} (${bottom.aeoScore})`);
3584
+ }
3225
3585
  }
3226
3586
  log("");
3227
3587
  }