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/dist/index.d.cts CHANGED
@@ -36,18 +36,26 @@ interface PitchMetric {
36
36
  significance: string;
37
37
  }
38
38
  type PageCategory$1 = 'homepage' | 'blog' | 'about' | 'pricing' | 'services' | 'contact' | 'team' | 'resources' | 'docs' | 'cases' | 'faq' | 'content';
39
- interface PageIssue$1 {
39
+ interface PageCriterionScore$1 {
40
+ criterion: string;
41
+ criterion_label: string;
42
+ score: number;
43
+ weight: number;
44
+ }
45
+ interface PageIssue {
40
46
  check: string;
41
47
  label: string;
42
48
  severity: 'error' | 'warning' | 'info';
43
49
  }
44
- interface PageReview$1 {
50
+ interface PageReview {
45
51
  url: string;
46
52
  title: string;
47
53
  category: PageCategory$1;
48
54
  wordCount: number;
49
- issues: PageIssue$1[];
50
- strengths: PageIssue$1[];
55
+ issues: PageIssue[];
56
+ strengths: PageIssue[];
57
+ aeoScore?: number;
58
+ criterionScores?: PageCriterionScore$1[];
51
59
  }
52
60
  interface AuditData {
53
61
  site: string;
@@ -61,7 +69,7 @@ interface AuditData {
61
69
  opportunities: Deliverable[];
62
70
  pitchNumbers: PitchMetric[];
63
71
  bottomLine: string;
64
- pagesReviewed?: PageReview$1[];
72
+ pagesReviewed?: PageReview[];
65
73
  }
66
74
  type AuditStatus = 'pass' | 'fail' | 'partial' | 'not_found';
67
75
  type Priority = 'P0' | 'P1' | 'P2' | 'P3';
@@ -260,22 +268,35 @@ declare function generateBottomLine(score: number, opportunities: Deliverable[],
260
268
  * Runs 12 deterministic checks on each crawled page (no LLM).
261
269
  */
262
270
 
263
- interface PageIssue {
264
- check: string;
265
- label: string;
266
- severity: 'error' | 'warning' | 'info';
267
- }
268
- interface PageReview {
269
- url: string;
270
- title: string;
271
- category: PageCategory;
272
- wordCount: number;
273
- issues: PageIssue[];
274
- strengths: PageIssue[];
275
- }
276
271
  declare function analyzePage(html: string, url: string, category: PageCategory): PageReview;
277
272
  declare function analyzeAllPages(siteData: SiteData): PageReview[];
278
273
 
274
+ /**
275
+ * Per-page AEO scoring.
276
+ * Evaluates 14 of 26 criteria that apply at individual page level.
277
+ * Produces a 0-100 AEO score per page.
278
+ */
279
+
280
+ interface PageCriterionScore {
281
+ criterion: string;
282
+ criterion_label: string;
283
+ score: number;
284
+ weight: number;
285
+ }
286
+ interface PageScoreResult {
287
+ aeoScore: number;
288
+ criterionScores: PageCriterionScore[];
289
+ }
290
+ /**
291
+ * Score a single page against 14 AEO criteria.
292
+ * Returns a 0-100 AEO score and individual criterion scores.
293
+ */
294
+ declare function scorePage(html: string, url?: string): PageScoreResult;
295
+ /**
296
+ * Score all crawled pages (homepage + blogSample).
297
+ */
298
+ declare function scoreAllPages(siteData: SiteData): PageScoreResult[];
299
+
279
300
  /**
280
301
  * Extended page discovery for instant audit.
281
302
  * Fetches additional pages beyond what prefetchSiteData provides,
@@ -456,4 +477,4 @@ interface ComparisonResult {
456
477
  */
457
478
  declare function compare(domainA: string, domainB: string, options?: AuditOptions): Promise<ComparisonResult>;
458
479
 
459
- export { type AuditData, type AuditFinding, type AuditOptions, type AuditResult, type AuditStatus, CRITERION_LABELS, type ComparisonResult, type CrawlOptions, type CrawlResult, type CriterionComparison, type CriterionDetail, type CriterionResult, type Deliverable, type DetailedFinding, type FetchResult, type FindingSeverity, type FindingType, type HeadlessOptions, type ImpactLevel, type PageCategory$1 as PageCategory, type PageIssue$1 as PageIssue, type PageReview$1 as PageReview, type ParkedDomainResult, type PitchMetric, type Priority, type RawDataSummary, type RenderingMethod, type ScoreCardItem, type Severity, type SiteData, type Status, analyzeAllPages, analyzePage, audit, auditSiteFromData, buildDetailedFindings, buildScorecard, calculateOverallScore, classifyRendering, compare, crawlFullSite, detectParkedDomain, extractAllUrlsFromSitemap, extractContentPagesFromSitemap, extractInternalLinks, extractNavLinks, extractRawDataSummary, fetchMultiPageData, fetchWithHeadless, generateBottomLine, generateComparisonHtmlReport, generateHtmlReport, generateOpportunities, generatePitchNumbers, generateVerdict, inferCategory, isSpaShell, prefetchSiteData, scoreToStatus };
480
+ export { type AuditData, type AuditFinding, type AuditOptions, type AuditResult, type AuditStatus, CRITERION_LABELS, type ComparisonResult, type CrawlOptions, type CrawlResult, type CriterionComparison, type CriterionDetail, type CriterionResult, type Deliverable, type DetailedFinding, type FetchResult, type FindingSeverity, type FindingType, type HeadlessOptions, type ImpactLevel, type PageCategory$1 as PageCategory, type PageCriterionScore$1 as PageCriterionScore, type PageIssue, type PageReview, type PageScoreResult, type ParkedDomainResult, type PitchMetric, type Priority, type RawDataSummary, type RenderingMethod, type ScoreCardItem, type Severity, type SiteData, type Status, analyzeAllPages, analyzePage, audit, auditSiteFromData, buildDetailedFindings, buildScorecard, calculateOverallScore, classifyRendering, compare, crawlFullSite, detectParkedDomain, extractAllUrlsFromSitemap, extractContentPagesFromSitemap, extractInternalLinks, extractNavLinks, extractRawDataSummary, fetchMultiPageData, fetchWithHeadless, generateBottomLine, generateComparisonHtmlReport, generateHtmlReport, generateOpportunities, generatePitchNumbers, generateVerdict, inferCategory, isSpaShell, prefetchSiteData, scoreAllPages, scorePage, scoreToStatus };
package/dist/index.d.ts CHANGED
@@ -36,18 +36,26 @@ interface PitchMetric {
36
36
  significance: string;
37
37
  }
38
38
  type PageCategory$1 = 'homepage' | 'blog' | 'about' | 'pricing' | 'services' | 'contact' | 'team' | 'resources' | 'docs' | 'cases' | 'faq' | 'content';
39
- interface PageIssue$1 {
39
+ interface PageCriterionScore$1 {
40
+ criterion: string;
41
+ criterion_label: string;
42
+ score: number;
43
+ weight: number;
44
+ }
45
+ interface PageIssue {
40
46
  check: string;
41
47
  label: string;
42
48
  severity: 'error' | 'warning' | 'info';
43
49
  }
44
- interface PageReview$1 {
50
+ interface PageReview {
45
51
  url: string;
46
52
  title: string;
47
53
  category: PageCategory$1;
48
54
  wordCount: number;
49
- issues: PageIssue$1[];
50
- strengths: PageIssue$1[];
55
+ issues: PageIssue[];
56
+ strengths: PageIssue[];
57
+ aeoScore?: number;
58
+ criterionScores?: PageCriterionScore$1[];
51
59
  }
52
60
  interface AuditData {
53
61
  site: string;
@@ -61,7 +69,7 @@ interface AuditData {
61
69
  opportunities: Deliverable[];
62
70
  pitchNumbers: PitchMetric[];
63
71
  bottomLine: string;
64
- pagesReviewed?: PageReview$1[];
72
+ pagesReviewed?: PageReview[];
65
73
  }
66
74
  type AuditStatus = 'pass' | 'fail' | 'partial' | 'not_found';
67
75
  type Priority = 'P0' | 'P1' | 'P2' | 'P3';
@@ -260,22 +268,35 @@ declare function generateBottomLine(score: number, opportunities: Deliverable[],
260
268
  * Runs 12 deterministic checks on each crawled page (no LLM).
261
269
  */
262
270
 
263
- interface PageIssue {
264
- check: string;
265
- label: string;
266
- severity: 'error' | 'warning' | 'info';
267
- }
268
- interface PageReview {
269
- url: string;
270
- title: string;
271
- category: PageCategory;
272
- wordCount: number;
273
- issues: PageIssue[];
274
- strengths: PageIssue[];
275
- }
276
271
  declare function analyzePage(html: string, url: string, category: PageCategory): PageReview;
277
272
  declare function analyzeAllPages(siteData: SiteData): PageReview[];
278
273
 
274
+ /**
275
+ * Per-page AEO scoring.
276
+ * Evaluates 14 of 26 criteria that apply at individual page level.
277
+ * Produces a 0-100 AEO score per page.
278
+ */
279
+
280
+ interface PageCriterionScore {
281
+ criterion: string;
282
+ criterion_label: string;
283
+ score: number;
284
+ weight: number;
285
+ }
286
+ interface PageScoreResult {
287
+ aeoScore: number;
288
+ criterionScores: PageCriterionScore[];
289
+ }
290
+ /**
291
+ * Score a single page against 14 AEO criteria.
292
+ * Returns a 0-100 AEO score and individual criterion scores.
293
+ */
294
+ declare function scorePage(html: string, url?: string): PageScoreResult;
295
+ /**
296
+ * Score all crawled pages (homepage + blogSample).
297
+ */
298
+ declare function scoreAllPages(siteData: SiteData): PageScoreResult[];
299
+
279
300
  /**
280
301
  * Extended page discovery for instant audit.
281
302
  * Fetches additional pages beyond what prefetchSiteData provides,
@@ -456,4 +477,4 @@ interface ComparisonResult {
456
477
  */
457
478
  declare function compare(domainA: string, domainB: string, options?: AuditOptions): Promise<ComparisonResult>;
458
479
 
459
- export { type AuditData, type AuditFinding, type AuditOptions, type AuditResult, type AuditStatus, CRITERION_LABELS, type ComparisonResult, type CrawlOptions, type CrawlResult, type CriterionComparison, type CriterionDetail, type CriterionResult, type Deliverable, type DetailedFinding, type FetchResult, type FindingSeverity, type FindingType, type HeadlessOptions, type ImpactLevel, type PageCategory$1 as PageCategory, type PageIssue$1 as PageIssue, type PageReview$1 as PageReview, type ParkedDomainResult, type PitchMetric, type Priority, type RawDataSummary, type RenderingMethod, type ScoreCardItem, type Severity, type SiteData, type Status, analyzeAllPages, analyzePage, audit, auditSiteFromData, buildDetailedFindings, buildScorecard, calculateOverallScore, classifyRendering, compare, crawlFullSite, detectParkedDomain, extractAllUrlsFromSitemap, extractContentPagesFromSitemap, extractInternalLinks, extractNavLinks, extractRawDataSummary, fetchMultiPageData, fetchWithHeadless, generateBottomLine, generateComparisonHtmlReport, generateHtmlReport, generateOpportunities, generatePitchNumbers, generateVerdict, inferCategory, isSpaShell, prefetchSiteData, scoreToStatus };
480
+ export { type AuditData, type AuditFinding, type AuditOptions, type AuditResult, type AuditStatus, CRITERION_LABELS, type ComparisonResult, type CrawlOptions, type CrawlResult, type CriterionComparison, type CriterionDetail, type CriterionResult, type Deliverable, type DetailedFinding, type FetchResult, type FindingSeverity, type FindingType, type HeadlessOptions, type ImpactLevel, type PageCategory$1 as PageCategory, type PageCriterionScore$1 as PageCriterionScore, type PageIssue, type PageReview, type PageScoreResult, type ParkedDomainResult, type PitchMetric, type Priority, type RawDataSummary, type RenderingMethod, type ScoreCardItem, type Severity, type SiteData, type Status, analyzeAllPages, analyzePage, audit, auditSiteFromData, buildDetailedFindings, buildScorecard, calculateOverallScore, classifyRendering, compare, crawlFullSite, detectParkedDomain, extractAllUrlsFromSitemap, extractContentPagesFromSitemap, extractInternalLinks, extractNavLinks, extractRawDataSummary, fetchMultiPageData, fetchWithHeadless, generateBottomLine, generateComparisonHtmlReport, generateHtmlReport, generateOpportunities, generatePitchNumbers, generateVerdict, inferCategory, isSpaShell, prefetchSiteData, scoreAllPages, scorePage, scoreToStatus };
package/dist/index.js CHANGED
@@ -2523,12 +2523,359 @@ async function fetchMultiPageData(siteData, options) {
2523
2523
  return added;
2524
2524
  }
2525
2525
 
2526
+ // src/page-scorer.ts
2527
+ var PAGE_CRITERIA = {
2528
+ schema_markup: { weight: 0.15, label: "Schema.org Structured Data" },
2529
+ qa_content_format: { weight: 0.15, label: "Q&A Content Format" },
2530
+ clean_html: { weight: 0.1, label: "Clean, Crawlable HTML" },
2531
+ faq_section: { weight: 0.1, label: "FAQ Section Content" },
2532
+ original_data: { weight: 0.1, label: "Original Data & Expert Content" },
2533
+ query_answer_alignment: { weight: 0.08, label: "Query-Answer Alignment" },
2534
+ content_freshness: { weight: 0.07, label: "Content Freshness Signals" },
2535
+ table_list_extractability: { weight: 0.07, label: "Table & List Extractability" },
2536
+ direct_answer_density: { weight: 0.07, label: "Direct Answer Paragraphs" },
2537
+ semantic_html: { weight: 0.05, label: "Semantic HTML5 & Accessibility" },
2538
+ fact_density: { weight: 0.05, label: "Fact & Data Density" },
2539
+ definition_patterns: { weight: 0.04, label: "Definition Patterns" },
2540
+ canonical_url: { weight: 0.04, label: "Canonical URL Strategy" },
2541
+ visible_date_signal: { weight: 0.04, label: "Visible Date Signal" }
2542
+ };
2543
+ function extractJsonLdBlocks(html) {
2544
+ const blocks = [];
2545
+ const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
2546
+ let match;
2547
+ while ((match = regex.exec(html)) !== null) {
2548
+ blocks.push(match[1]);
2549
+ }
2550
+ return blocks;
2551
+ }
2552
+ function extractTypesFromJsonLd(blocks) {
2553
+ const types = /* @__PURE__ */ new Set();
2554
+ for (const block of blocks) {
2555
+ const typeMatches = block.match(/"@type"\s*:\s*"([^"]+)"/g) || [];
2556
+ for (const m of typeMatches) {
2557
+ const t = m.match(/"@type"\s*:\s*"([^"]+)"/);
2558
+ if (t) types.add(t[1]);
2559
+ }
2560
+ }
2561
+ return types;
2562
+ }
2563
+ function getTextContent(html) {
2564
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
2565
+ }
2566
+ function extractQuestionHeadings2(html) {
2567
+ const headings = html.match(/<h[2-3][^>]*>([\s\S]*?)<\/h[2-3]>/gi) || [];
2568
+ const questions = [];
2569
+ for (const h of headings) {
2570
+ const text = h.replace(/<[^>]*>/g, "").trim();
2571
+ if (/\?$/.test(text) || /^(what|how|why|when|where|who|which|can|do|does|is|are|should|will)\b/i.test(text)) {
2572
+ questions.push(text);
2573
+ }
2574
+ }
2575
+ return questions;
2576
+ }
2577
+ function countAnsweredQuestions(html) {
2578
+ const questions = extractQuestionHeadings2(html);
2579
+ if (questions.length === 0) return { total: 0, answered: 0 };
2580
+ let answered = 0;
2581
+ for (const q of questions) {
2582
+ const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2583
+ const pattern = new RegExp(escaped + "[\\s\\S]*?</h[2-3]>\\s*<p[^>]*>([\\s\\S]*?)</p>", "i");
2584
+ const match = html.match(pattern);
2585
+ if (match && match[1].replace(/<[^>]*>/g, "").trim().length >= 20) {
2586
+ answered++;
2587
+ }
2588
+ }
2589
+ return { total: questions.length, answered };
2590
+ }
2591
+ function cap(value, max) {
2592
+ return Math.min(value, max);
2593
+ }
2594
+ function scoreSchemaMarkup(html) {
2595
+ const blocks = extractJsonLdBlocks(html);
2596
+ if (blocks.length === 0) return 0;
2597
+ let score = 3;
2598
+ const types = extractTypesFromJsonLd(blocks);
2599
+ const knownTypes = [
2600
+ "Organization",
2601
+ "LocalBusiness",
2602
+ "Article",
2603
+ "FAQPage",
2604
+ "Product",
2605
+ "WebPage",
2606
+ "BreadcrumbList",
2607
+ "HowTo",
2608
+ "Person",
2609
+ "WebSite",
2610
+ "BlogPosting",
2611
+ "Service"
2612
+ ];
2613
+ let knownCount = 0;
2614
+ for (const t of types) {
2615
+ if (knownTypes.includes(t)) knownCount++;
2616
+ }
2617
+ score += cap(knownCount * 2, 4);
2618
+ if (types.has("Organization") || types.has("LocalBusiness")) score += 2;
2619
+ if (types.has("FAQPage")) score += 1;
2620
+ return cap(score, 10);
2621
+ }
2622
+ function scoreQAFormat(html) {
2623
+ const questions = extractQuestionHeadings2(html);
2624
+ let score = 0;
2625
+ if (questions.length >= 10) score += 5;
2626
+ else if (questions.length >= 3) score += 3;
2627
+ else if (questions.length >= 1) score += 1;
2628
+ const { answered } = countAnsweredQuestions(html);
2629
+ if (answered >= 1) score += 3;
2630
+ const h1Matches = html.match(/<h1[\s>]/gi) || [];
2631
+ if (h1Matches.length === 1) score += 2;
2632
+ return cap(score, 10);
2633
+ }
2634
+ function scoreCleanHtml(html) {
2635
+ let score = 0;
2636
+ const semantics = ["<main", "<article", "<section"];
2637
+ let semCount = 0;
2638
+ for (const tag of semantics) {
2639
+ if (html.toLowerCase().includes(tag)) semCount++;
2640
+ }
2641
+ score += cap(semCount, 3);
2642
+ const h1Matches = html.match(/<h1[\s>]/gi) || [];
2643
+ if (h1Matches.length === 1) score += 2;
2644
+ const text = getTextContent(html);
2645
+ if (text.length > 500) score += 3;
2646
+ const hasTitle = /<title[^>]*>[^<]+<\/title>/i.test(html);
2647
+ const hasDesc = /<meta\s[^>]*name=["']description["'][^>]*content=["'][^"']+["']/i.test(html) || /<meta\s[^>]*content=["'][^"']+["'][^>]*name=["']description["']/i.test(html);
2648
+ if (hasTitle && hasDesc) score += 2;
2649
+ return cap(score, 10);
2650
+ }
2651
+ function scoreFaqSection(html) {
2652
+ let score = 0;
2653
+ const lowerHtml = html.toLowerCase();
2654
+ if (/frequently\s*asked|faq/i.test(html)) score += 2;
2655
+ const blocks = extractJsonLdBlocks(html);
2656
+ const types = extractTypesFromJsonLd(blocks);
2657
+ if (types.has("FAQPage")) score += 3;
2658
+ const questions = extractQuestionHeadings2(html);
2659
+ if (questions.length >= 10) score += 1;
2660
+ if (/<details[\s>]/i.test(html) || /accordion|collapsible|toggle/i.test(lowerHtml)) score += 1;
2661
+ return cap(score, 10);
2662
+ }
2663
+ function scoreOriginalData(html) {
2664
+ let score = 0;
2665
+ const text = getTextContent(html);
2666
+ if (/\b(our (study|analysis|research|survey|data|findings))\b/i.test(text)) {
2667
+ score += 3;
2668
+ } else if (/\d+(\.\d+)?%|\$[\d,.]+|\b\d{1,3}(,\d{3})+\b/.test(text)) {
2669
+ score += 1;
2670
+ }
2671
+ if (/\bcase\s+stud(y|ies)\b/i.test(text) && /\d+(\.\d+)?%|\$[\d,.]+/.test(text)) {
2672
+ score += 3;
2673
+ } else if (/\bcase\s+stud(y|ies)\b/i.test(text)) {
2674
+ score += 1;
2675
+ }
2676
+ if (/\baccording\s+to\b|\bexpert|\b(Ph\.?D|MD|professor|analyst|researcher)\b/i.test(text)) {
2677
+ score += 2;
2678
+ }
2679
+ if (/href=["'][^"']*\/blog\b/i.test(html)) {
2680
+ score += 2;
2681
+ }
2682
+ return cap(score, 10);
2683
+ }
2684
+ function scoreQueryAnswerAlignment(html) {
2685
+ const { total, answered } = countAnsweredQuestions(html);
2686
+ if (total === 0) return 5;
2687
+ const ratio = answered / total;
2688
+ if (ratio >= 0.8) return 10;
2689
+ if (ratio >= 0.5) return 7;
2690
+ if (answered > 0) return 4;
2691
+ return 0;
2692
+ }
2693
+ function scoreContentFreshness(html) {
2694
+ let score = 0;
2695
+ const blocks = extractJsonLdBlocks(html);
2696
+ const allJsonLd = blocks.join(" ");
2697
+ if (/datePublished|dateModified/i.test(allJsonLd)) score += 3;
2698
+ const timeElements = html.match(/<time[\s>]/gi) || [];
2699
+ if (timeElements.length >= 2) score += 3;
2700
+ else if (timeElements.length === 1) score += 1;
2701
+ if (/<meta\s[^>]*property=["']article:(published_time|modified_time)["']/i.test(html)) score += 2;
2702
+ const currentYear = (/* @__PURE__ */ new Date()).getFullYear();
2703
+ const yearPattern = new RegExp(`\\b(${currentYear}|${currentYear - 1})\\b`);
2704
+ if (yearPattern.test(html)) score += 2;
2705
+ return cap(score, 10);
2706
+ }
2707
+ function scoreTableListExtractability(html) {
2708
+ let score = 0;
2709
+ const tablesWithHeaders = html.match(/<table[\s\S]*?<th[\s>]/gi) || [];
2710
+ if (tablesWithHeaders.length >= 2) score += 4;
2711
+ else if (tablesWithHeaders.length === 1) score += 3;
2712
+ if (tablesWithHeaders.length === 0 && /<table[\s>]/i.test(html)) score += 1;
2713
+ if (/<ol[\s>]/i.test(html)) score += 2;
2714
+ if (/<ul[\s>]/i.test(html)) score += 2;
2715
+ const listItems = html.match(/<li[\s>]/gi) || [];
2716
+ if (listItems.length >= 10) score += 1;
2717
+ if (/<dl[\s>]/i.test(html)) score += 1;
2718
+ return cap(score, 10);
2719
+ }
2720
+ function scoreDirectAnswerDensity(html) {
2721
+ let score = 0;
2722
+ const { answered } = countAnsweredQuestions(html);
2723
+ if (answered >= 3) score += 6;
2724
+ else if (answered >= 1) score += 3;
2725
+ const paragraphs = html.match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
2726
+ let snippetCount = 0;
2727
+ for (const p of paragraphs) {
2728
+ const text = p.replace(/<[^>]*>/g, "").trim();
2729
+ const words = text.split(/\s+/).filter((w) => w.length > 0).length;
2730
+ if (words >= 40 && words <= 150) snippetCount++;
2731
+ }
2732
+ if (snippetCount >= 3) score += 2;
2733
+ else if (snippetCount >= 1) score += 1;
2734
+ const directOpeners = getTextContent(html).match(/\b(yes|no|in short|the answer is|simply put|in summary)\b/gi) || [];
2735
+ if (directOpeners.length >= 2) score += 2;
2736
+ return cap(score, 10);
2737
+ }
2738
+ function scoreSemanticHtml(html) {
2739
+ let score = 0;
2740
+ const lowerHtml = html.toLowerCase();
2741
+ const elements = ["<main", "<article", "<time", "<nav", "<header", "<footer"];
2742
+ let count = 0;
2743
+ for (const el of elements) {
2744
+ if (lowerHtml.includes(el)) count++;
2745
+ }
2746
+ score += cap(Math.floor(count * 0.7), 4);
2747
+ const imgTags = html.match(/<img\s[^>]*>/gi) || [];
2748
+ if (imgTags.length > 0) {
2749
+ let withAlt = 0;
2750
+ for (const img of imgTags) {
2751
+ if (/\salt=["'][^"']*["']/i.test(img)) withAlt++;
2752
+ }
2753
+ if (withAlt / imgTags.length >= 0.8) score += 2;
2754
+ }
2755
+ if (/<html[^>]*\slang=["'][^"']+["']/i.test(html)) score += 2;
2756
+ if (/\baria-/i.test(html)) score += 2;
2757
+ return cap(score, 10);
2758
+ }
2759
+ function scoreFactDensity(html) {
2760
+ let score = 0;
2761
+ const text = getTextContent(html);
2762
+ 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) || [];
2763
+ if (numericPatterns.length >= 6) score += 5;
2764
+ else if (numericPatterns.length >= 3) score += 3;
2765
+ else if (numericPatterns.length >= 1) score += 1;
2766
+ const years = /* @__PURE__ */ new Set();
2767
+ const yearMatches = text.match(/\b(19|20)\d{2}\b/g) || [];
2768
+ for (const y of yearMatches) years.add(y);
2769
+ if (years.size >= 2) score += 2;
2770
+ else if (years.size === 1) score += 1;
2771
+ if (/\baccording to\b|\bsource:\s|\bcited\b|\breported by\b/i.test(text)) score += 2;
2772
+ const units = text.match(/\b\d+\s*(kg|lb|miles|km|hours|minutes|days|months|years|GB|MB|TB)\b/gi) || [];
2773
+ if (units.length >= 2) score += 1;
2774
+ return cap(score, 10);
2775
+ }
2776
+ function scoreDefinitionPatterns(html) {
2777
+ let score = 0;
2778
+ const text = getTextContent(html);
2779
+ const defPatterns = text.match(/\b(is a|is an|refers to|defined as|means that|also known as|abbreviated as)\b/gi) || [];
2780
+ if (defPatterns.length >= 3) score += 5;
2781
+ else if (defPatterns.length >= 1) score += 3;
2782
+ const early = text.slice(0, 2e3);
2783
+ if (/\b(is a|is an|refers to|defined as)\b/i.test(early)) score += 2;
2784
+ if (/<dfn[\s>]/i.test(html) || /<abbr[\s>]/i.test(html)) score += 1;
2785
+ if (/<dl[\s>]/i.test(html) || /glossary/i.test(html)) score += 2;
2786
+ return cap(score, 10);
2787
+ }
2788
+ function scoreCanonicalUrl(html, url) {
2789
+ let score = 0;
2790
+ const canonicalMatch = html.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i) || html.match(/<link[^>]*href=["']([^"']+)["'][^>]*rel=["']canonical["']/i);
2791
+ if (!canonicalMatch) return 0;
2792
+ score += 4;
2793
+ const canonicalHref = canonicalMatch[1];
2794
+ if (url) {
2795
+ try {
2796
+ const canonicalUrl = new URL(canonicalHref, url);
2797
+ const pageUrl = new URL(url);
2798
+ if (canonicalUrl.pathname === pageUrl.pathname && canonicalUrl.hostname === pageUrl.hostname) {
2799
+ score += 3;
2800
+ }
2801
+ } catch {
2802
+ }
2803
+ }
2804
+ if (canonicalHref.startsWith("https://")) score += 2;
2805
+ const allCanonicals = html.match(/<link[^>]*rel=["']canonical["'][^>]*>/gi) || [];
2806
+ if (allCanonicals.length === 1) score += 1;
2807
+ return cap(score, 10);
2808
+ }
2809
+ function scoreVisibleDateSignal(html) {
2810
+ let score = 0;
2811
+ const timeWithDatetime = html.match(/<time[^>]*datetime=["'][^"']+["'][^>]*>[^<]+<\/time>/gi) || [];
2812
+ if (timeWithDatetime.length > 0) score += 5;
2813
+ const blocks = extractJsonLdBlocks(html);
2814
+ const allJsonLd = blocks.join(" ");
2815
+ if (/datePublished|dateModified/i.test(allJsonLd)) score += 3;
2816
+ if (/<meta\s[^>]*property=["']article:(published_time|modified_time)["']/i.test(html)) score += 2;
2817
+ const modifiedMatch = allJsonLd.match(/"dateModified"\s*:\s*"([^"]+)"/i);
2818
+ if (modifiedMatch) {
2819
+ try {
2820
+ const modified = new Date(modifiedMatch[1]);
2821
+ const daysDiff = (Date.now() - modified.getTime()) / (1e3 * 60 * 60 * 24);
2822
+ if (daysDiff <= 180) score += 1;
2823
+ } catch {
2824
+ }
2825
+ }
2826
+ return cap(score, 10);
2827
+ }
2828
+ var SCORING_FUNCTIONS = {
2829
+ schema_markup: scoreSchemaMarkup,
2830
+ qa_content_format: scoreQAFormat,
2831
+ clean_html: scoreCleanHtml,
2832
+ faq_section: scoreFaqSection,
2833
+ original_data: scoreOriginalData,
2834
+ query_answer_alignment: scoreQueryAnswerAlignment,
2835
+ content_freshness: scoreContentFreshness,
2836
+ table_list_extractability: scoreTableListExtractability,
2837
+ direct_answer_density: scoreDirectAnswerDensity,
2838
+ semantic_html: scoreSemanticHtml,
2839
+ fact_density: scoreFactDensity,
2840
+ definition_patterns: scoreDefinitionPatterns,
2841
+ canonical_url: scoreCanonicalUrl,
2842
+ visible_date_signal: scoreVisibleDateSignal
2843
+ };
2844
+ function scorePage(html, url) {
2845
+ let totalWeight = 0;
2846
+ let weightedSum = 0;
2847
+ const criterionScores = [];
2848
+ for (const [criterion, { weight, label }] of Object.entries(PAGE_CRITERIA)) {
2849
+ const fn = SCORING_FUNCTIONS[criterion];
2850
+ const score = fn(html, url);
2851
+ criterionScores.push({ criterion, criterion_label: label, score, weight });
2852
+ weightedSum += score / 10 * weight * 100;
2853
+ totalWeight += weight;
2854
+ }
2855
+ const aeoScore = totalWeight === 0 ? 0 : Math.round(weightedSum / totalWeight);
2856
+ return { aeoScore, criterionScores };
2857
+ }
2858
+ function scoreAllPages(siteData) {
2859
+ const results = [];
2860
+ if (siteData.homepage) {
2861
+ const url = siteData.protocol ? `${siteData.protocol}://${siteData.domain}` : void 0;
2862
+ results.push(scorePage(siteData.homepage.text, url));
2863
+ }
2864
+ if (siteData.blogSample) {
2865
+ for (const page of siteData.blogSample) {
2866
+ const url = page.finalUrl || void 0;
2867
+ results.push(scorePage(page.text, url));
2868
+ }
2869
+ }
2870
+ return results;
2871
+ }
2872
+
2526
2873
  // src/page-analyzer.ts
2527
2874
  function extractTitle(html) {
2528
2875
  const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
2529
2876
  return match ? match[1].replace(/\s+/g, " ").trim() : "";
2530
2877
  }
2531
- function getTextContent(html) {
2878
+ function getTextContent2(html) {
2532
2879
  return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
2533
2880
  }
2534
2881
  function countWords(text) {
@@ -2681,7 +3028,7 @@ function checkHasQuestionHeadings(html) {
2681
3028
  }
2682
3029
  function analyzePage(html, url, category) {
2683
3030
  const title = extractTitle(html);
2684
- const textContent = getTextContent(html);
3031
+ const textContent = getTextContent2(html);
2685
3032
  const wordCount = countWords(textContent);
2686
3033
  const issues = [];
2687
3034
  const strengths = [];
@@ -2707,7 +3054,8 @@ function analyzePage(html, url, category) {
2707
3054
  for (const result of strengthChecks) {
2708
3055
  if (result) strengths.push(result);
2709
3056
  }
2710
- return { url, title, category, wordCount, issues, strengths };
3057
+ const { aeoScore, criterionScores } = scorePage(html, url);
3058
+ return { url, title, category, wordCount, issues, strengths, aeoScore, criterionScores };
2711
3059
  }
2712
3060
  function analyzeAllPages(siteData) {
2713
3061
  const reviews = [];
@@ -2915,17 +3263,22 @@ function generateHtmlReport(result) {
2915
3263
  <td>${escapeHtml(opp.description)}</td>
2916
3264
  </tr>`;
2917
3265
  }).join("\n");
2918
- const pagesRows = (result.pagesReviewed || []).map((page) => {
3266
+ const pagesReviewed = result.pagesReviewed || [];
3267
+ const pagesRows = pagesReviewed.map((page) => {
2919
3268
  const issueCount = page.issues.length;
2920
3269
  const strengthCount = page.strengths.length;
3270
+ const aeoDisplay = page.aeoScore != null ? `<span style="font-weight:600;color:${scoreColor(page.aeoScore)}">${page.aeoScore}</span>` : "-";
2921
3271
  return `<tr>
2922
3272
  <td>${escapeHtml(page.url)}</td>
2923
3273
  <td>${escapeHtml(page.category)}</td>
2924
3274
  <td>${page.wordCount}</td>
3275
+ <td>${aeoDisplay}</td>
2925
3276
  <td>${issueCount}</td>
2926
3277
  <td>${strengthCount}</td>
2927
3278
  </tr>`;
2928
3279
  }).join("\n");
3280
+ const scoredPages = pagesReviewed.filter((p) => p.aeoScore != null);
3281
+ const avgPageScore = scoredPages.length > 0 ? Math.round(scoredPages.reduce((sum, p) => sum + p.aeoScore, 0) / scoredPages.length) : null;
2929
3282
  const now = (/* @__PURE__ */ new Date()).toISOString();
2930
3283
  return `<!DOCTYPE html>
2931
3284
  <html lang="en">
@@ -2961,10 +3314,11 @@ function generateHtmlReport(result) {
2961
3314
  </table>
2962
3315
  ` : ""}
2963
3316
 
2964
- ${(result.pagesReviewed || []).length > 0 ? `
2965
- <h2 class="section-title">Pages Reviewed (${(result.pagesReviewed || []).length})</h2>
3317
+ ${pagesReviewed.length > 0 ? `
3318
+ <h2 class="section-title">Pages Reviewed (${pagesReviewed.length})</h2>
3319
+ ${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>` : ""}
2966
3320
  <table>
2967
- <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>Issues</th><th>Strengths</th></tr></thead>
3321
+ <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>AEO Score</th><th>Issues</th><th>Strengths</th></tr></thead>
2968
3322
  <tbody>${pagesRows}</tbody>
2969
3323
  </table>
2970
3324
  ` : ""}
@@ -3157,6 +3511,8 @@ export {
3157
3511
  inferCategory,
3158
3512
  isSpaShell,
3159
3513
  prefetchSiteData,
3514
+ scoreAllPages,
3515
+ scorePage,
3160
3516
  scoreToStatus
3161
3517
  };
3162
3518
  //# sourceMappingURL=index.js.map