aeorank 1.4.0 → 1.6.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.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 = [];
@@ -2806,6 +3154,1314 @@ async function audit(domain, options) {
2806
3154
  };
2807
3155
  }
2808
3156
 
3157
+ // src/link-graph.ts
3158
+ function serializeLinkGraph(graph) {
3159
+ return {
3160
+ nodes: Array.from(graph.nodes.values()),
3161
+ stats: graph.stats,
3162
+ clusters: graph.clusters
3163
+ };
3164
+ }
3165
+ function normalizeUrl(url) {
3166
+ try {
3167
+ const parsed = new URL(url);
3168
+ return (parsed.origin + parsed.pathname.replace(/\/+$/, "") + parsed.search).toLowerCase();
3169
+ } catch {
3170
+ return url.toLowerCase();
3171
+ }
3172
+ }
3173
+ var RESOURCE_EXTENSIONS = /\.(js|css|png|jpg|jpeg|gif|svg|ico|pdf|xml|txt|woff|woff2|ttf|eot|mp4|mp3|webp|avif|zip|gz|tar|json)$/i;
3174
+ var SKIP_PATH_PATTERNS = /^\/(api|wp-admin|wp-json|static|assets|_next|auth|login|signup|cart|checkout|admin|feed|xmlrpc)\b/i;
3175
+ function extractTitle2(html) {
3176
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
3177
+ return match ? match[1].replace(/\s+/g, " ").trim() : "";
3178
+ }
3179
+ function getTextContent3(html) {
3180
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
3181
+ }
3182
+ function countWords2(text) {
3183
+ if (!text) return 0;
3184
+ return text.split(/\s+/).filter((w) => w.length > 0).length;
3185
+ }
3186
+ function extractLinksWithAnchors(html, sourceUrl, domain) {
3187
+ const cleanDomain = domain.replace(/^www\./, "").toLowerCase();
3188
+ const edges = [];
3189
+ const seen = /* @__PURE__ */ new Set();
3190
+ const anchorRegex = /<a\s[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
3191
+ let match;
3192
+ while ((match = anchorRegex.exec(html)) !== null) {
3193
+ const href = match[1];
3194
+ const rawAnchor = match[2];
3195
+ if (!href || !href.trim()) continue;
3196
+ let fullUrl;
3197
+ if (href.startsWith("//")) {
3198
+ fullUrl = `https:${href}`;
3199
+ } else if (href.startsWith("/")) {
3200
+ if (href === "/" || href.startsWith("/#")) continue;
3201
+ fullUrl = `https://${domain}${href}`;
3202
+ } else if (href.startsWith("http")) {
3203
+ fullUrl = href;
3204
+ } else if (href.startsWith("#") || href.startsWith("?") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) {
3205
+ continue;
3206
+ } else {
3207
+ fullUrl = `https://${domain}/${href}`;
3208
+ }
3209
+ try {
3210
+ const parsed = new URL(fullUrl);
3211
+ const linkDomain = parsed.hostname.replace(/^www\./, "").toLowerCase();
3212
+ if (linkDomain !== cleanDomain) continue;
3213
+ parsed.hash = "";
3214
+ const path = parsed.pathname;
3215
+ if (path === "/" || path === "") continue;
3216
+ if (RESOURCE_EXTENSIONS.test(path)) continue;
3217
+ if (SKIP_PATH_PATTERNS.test(path)) continue;
3218
+ const normalized = normalizeUrl(fullUrl);
3219
+ const sourceNorm = normalizeUrl(sourceUrl);
3220
+ if (normalized === sourceNorm) continue;
3221
+ const edgeKey = `${sourceNorm}->${normalized}`;
3222
+ if (seen.has(edgeKey)) continue;
3223
+ seen.add(edgeKey);
3224
+ const anchorText = rawAnchor.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
3225
+ edges.push({
3226
+ source: sourceNorm,
3227
+ target: normalized,
3228
+ anchorText
3229
+ });
3230
+ } catch {
3231
+ continue;
3232
+ }
3233
+ }
3234
+ return edges;
3235
+ }
3236
+ function calculateDepths(nodes, adjacency, homepageUrl) {
3237
+ const homeNorm = normalizeUrl(homepageUrl);
3238
+ for (const node of nodes.values()) {
3239
+ node.depth = Infinity;
3240
+ }
3241
+ const homeNode = nodes.get(homeNorm);
3242
+ if (homeNode) {
3243
+ homeNode.depth = 0;
3244
+ }
3245
+ const queue = [homeNorm];
3246
+ const visited = /* @__PURE__ */ new Set([homeNorm]);
3247
+ while (queue.length > 0) {
3248
+ const current = queue.shift();
3249
+ const currentNode = nodes.get(current);
3250
+ if (!currentNode) continue;
3251
+ const nextDepth = currentNode.depth + 1;
3252
+ const neighbors = adjacency.get(current);
3253
+ if (!neighbors) continue;
3254
+ for (const neighbor of neighbors) {
3255
+ if (visited.has(neighbor)) continue;
3256
+ visited.add(neighbor);
3257
+ const neighborNode = nodes.get(neighbor);
3258
+ if (neighborNode) {
3259
+ neighborNode.depth = nextDepth;
3260
+ queue.push(neighbor);
3261
+ }
3262
+ }
3263
+ }
3264
+ }
3265
+ var PILLAR_CATEGORIES = /* @__PURE__ */ new Set(["blog", "content", "resources", "docs"]);
3266
+ function detectPillars(nodes) {
3267
+ for (const node of nodes.values()) {
3268
+ node.isPillar = node.wordCount >= 1500 && node.inDegree >= 3 && node.outDegree >= 3 && PILLAR_CATEGORIES.has(node.category) && node.depth > 0;
3269
+ }
3270
+ }
3271
+ var HUB_CATEGORIES = /* @__PURE__ */ new Set(["homepage", "resources", "docs"]);
3272
+ function detectHubs(nodes) {
3273
+ for (const node of nodes.values()) {
3274
+ node.isHub = node.outDegree >= 10 && HUB_CATEGORIES.has(node.category) || node.outDegree >= 15;
3275
+ }
3276
+ }
3277
+ function detectClusters(nodes, edges) {
3278
+ const clusters = [];
3279
+ const edgeSet = /* @__PURE__ */ new Set();
3280
+ for (const edge of edges) {
3281
+ edgeSet.add(`${edge.source}->${edge.target}`);
3282
+ }
3283
+ for (const node of nodes.values()) {
3284
+ if (!node.isPillar) continue;
3285
+ const pillarNorm = normalizeUrl(node.url);
3286
+ const spokeSet = /* @__PURE__ */ new Set();
3287
+ for (const edge of edges) {
3288
+ if (edge.source === pillarNorm && nodes.has(edge.target)) {
3289
+ spokeSet.add(edge.target);
3290
+ }
3291
+ if (edge.target === pillarNorm && nodes.has(edge.source)) {
3292
+ spokeSet.add(edge.source);
3293
+ }
3294
+ }
3295
+ spokeSet.delete(pillarNorm);
3296
+ if (spokeSet.size < 2) continue;
3297
+ const spokes = Array.from(spokeSet);
3298
+ const members = [pillarNorm, ...spokes];
3299
+ let actualEdges = 0;
3300
+ const possibleEdges = members.length * (members.length - 1);
3301
+ for (const from of members) {
3302
+ for (const to of members) {
3303
+ if (from === to) continue;
3304
+ if (edgeSet.has(`${from}->${to}`)) {
3305
+ actualEdges++;
3306
+ }
3307
+ }
3308
+ }
3309
+ const cohesion = possibleEdges > 0 ? Math.round(actualEdges / possibleEdges * 100) : 0;
3310
+ clusters.push({
3311
+ pillarUrl: node.url,
3312
+ pillarTitle: node.title,
3313
+ spokes,
3314
+ cohesion
3315
+ });
3316
+ }
3317
+ return clusters;
3318
+ }
3319
+ function buildLinkGraph(pages, domain, homepageUrl) {
3320
+ const nodes = /* @__PURE__ */ new Map();
3321
+ const allEdges = [];
3322
+ const adjacency = /* @__PURE__ */ new Map();
3323
+ const inDegreeMap = /* @__PURE__ */ new Map();
3324
+ for (const page of pages) {
3325
+ const url = page.finalUrl || `https://${domain}`;
3326
+ const norm = normalizeUrl(url);
3327
+ if (nodes.has(norm)) continue;
3328
+ const title = extractTitle2(page.text);
3329
+ const text = getTextContent3(page.text);
3330
+ const wordCount = countWords2(text);
3331
+ nodes.set(norm, {
3332
+ url: norm,
3333
+ title,
3334
+ wordCount,
3335
+ category: page.category || "content",
3336
+ inDegree: 0,
3337
+ outDegree: 0,
3338
+ depth: Infinity,
3339
+ isPillar: false,
3340
+ isHub: false,
3341
+ isOrphan: false
3342
+ });
3343
+ }
3344
+ for (const page of pages) {
3345
+ const url = page.finalUrl || `https://${domain}`;
3346
+ const sourceNorm = normalizeUrl(url);
3347
+ const edges = extractLinksWithAnchors(page.text, url, domain);
3348
+ for (const edge of edges) {
3349
+ const targetNorm = normalizeUrl(edge.target);
3350
+ if (!nodes.has(targetNorm)) continue;
3351
+ allEdges.push({
3352
+ source: sourceNorm,
3353
+ target: targetNorm,
3354
+ anchorText: edge.anchorText
3355
+ });
3356
+ if (!adjacency.has(sourceNorm)) {
3357
+ adjacency.set(sourceNorm, /* @__PURE__ */ new Set());
3358
+ }
3359
+ adjacency.get(sourceNorm).add(targetNorm);
3360
+ inDegreeMap.set(targetNorm, (inDegreeMap.get(targetNorm) || 0) + 1);
3361
+ }
3362
+ }
3363
+ for (const [url, node] of nodes) {
3364
+ node.inDegree = inDegreeMap.get(url) || 0;
3365
+ node.outDegree = adjacency.get(url)?.size || 0;
3366
+ }
3367
+ calculateDepths(nodes, adjacency, homepageUrl);
3368
+ detectPillars(nodes);
3369
+ detectHubs(nodes);
3370
+ const homeNorm = normalizeUrl(homepageUrl);
3371
+ for (const [url, node] of nodes) {
3372
+ node.isOrphan = node.inDegree === 0 && url !== homeNorm;
3373
+ }
3374
+ const clusters = detectClusters(nodes, allEdges);
3375
+ const depthValues = Array.from(nodes.values()).map((n) => n.depth).filter((d) => d !== Infinity);
3376
+ const avgDepth = depthValues.length > 0 ? Math.round(depthValues.reduce((s, d) => s + d, 0) / depthValues.length * 10) / 10 : 0;
3377
+ const maxDepth = depthValues.length > 0 ? Math.max(...depthValues) : 0;
3378
+ const stats = {
3379
+ totalPages: nodes.size,
3380
+ totalEdges: allEdges.length,
3381
+ orphanPages: Array.from(nodes.values()).filter((n) => n.isOrphan).length,
3382
+ pillarPages: Array.from(nodes.values()).filter((n) => n.isPillar).length,
3383
+ hubPages: Array.from(nodes.values()).filter((n) => n.isHub).length,
3384
+ avgDepth,
3385
+ maxDepth,
3386
+ clusters: clusters.length
3387
+ };
3388
+ return { nodes, edges: allEdges, stats, clusters };
3389
+ }
3390
+
3391
+ // src/fix-engine.ts
3392
+ var CRITERION_WEIGHTS2 = {
3393
+ llms_txt: 0.1,
3394
+ schema_markup: 0.15,
3395
+ qa_content_format: 0.15,
3396
+ clean_html: 0.1,
3397
+ entity_consistency: 0.1,
3398
+ robots_txt: 0.05,
3399
+ faq_section: 0.1,
3400
+ original_data: 0.1,
3401
+ internal_linking: 0.1,
3402
+ semantic_html: 0.05,
3403
+ content_freshness: 0.07,
3404
+ sitemap_completeness: 0.05,
3405
+ rss_feed: 0.03,
3406
+ table_list_extractability: 0.07,
3407
+ definition_patterns: 0.04,
3408
+ direct_answer_density: 0.07,
3409
+ content_licensing: 0.04,
3410
+ author_schema_depth: 0.04,
3411
+ fact_density: 0.05,
3412
+ canonical_url: 0.04,
3413
+ content_velocity: 0.03,
3414
+ schema_coverage: 0.03,
3415
+ speakable_schema: 0.03,
3416
+ query_answer_alignment: 0.08,
3417
+ content_cannibalization: 0.05,
3418
+ visible_date_signal: 0.04
3419
+ };
3420
+ var PHASE_CONFIG = [
3421
+ {
3422
+ phase: 1,
3423
+ title: "Foundation",
3424
+ description: "Discovery and structural fixes that enable AI crawlers to access and parse your content.",
3425
+ criteria: ["robots_txt", "llms_txt", "canonical_url", "clean_html", "sitemap_completeness"]
3426
+ },
3427
+ {
3428
+ phase: 2,
3429
+ title: "Content",
3430
+ description: "Content quality and format improvements that make your pages citable by AI engines.",
3431
+ criteria: [
3432
+ "qa_content_format",
3433
+ "faq_section",
3434
+ "original_data",
3435
+ "definition_patterns",
3436
+ "direct_answer_density",
3437
+ "fact_density",
3438
+ "content_freshness",
3439
+ "table_list_extractability",
3440
+ "query_answer_alignment",
3441
+ "visible_date_signal"
3442
+ ]
3443
+ },
3444
+ {
3445
+ phase: 3,
3446
+ title: "Authority",
3447
+ description: "Trust signals, schema depth, and semantic structure that establish credibility with AI engines.",
3448
+ criteria: [
3449
+ "schema_markup",
3450
+ "schema_coverage",
3451
+ "speakable_schema",
3452
+ "author_schema_depth",
3453
+ "content_licensing",
3454
+ "entity_consistency",
3455
+ "semantic_html"
3456
+ ]
3457
+ },
3458
+ {
3459
+ phase: 4,
3460
+ title: "Architecture",
3461
+ description: "Site architecture, linking patterns, and publishing cadence that support long-term AI visibility.",
3462
+ criteria: ["internal_linking", "content_velocity", "content_cannibalization", "rss_feed"]
3463
+ }
3464
+ ];
3465
+ function impactFromScore(score) {
3466
+ if (score <= 3) return "critical";
3467
+ if (score <= 5) return "high";
3468
+ if (score <= 7) return "medium";
3469
+ return "low";
3470
+ }
3471
+ function effortForCriterion(criterion, score) {
3472
+ const trivialCriteria = ["llms_txt", "robots_txt", "canonical_url", "content_licensing", "visible_date_signal"];
3473
+ const lowCriteria = ["rss_feed", "sitemap_completeness", "speakable_schema", "author_schema_depth", "semantic_html", "definition_patterns", "content_freshness"];
3474
+ const highCriteria = ["original_data", "content_velocity", "content_cannibalization"];
3475
+ if (trivialCriteria.includes(criterion)) return score <= 3 ? "low" : "trivial";
3476
+ if (lowCriteria.includes(criterion)) return score <= 3 ? "medium" : "low";
3477
+ if (highCriteria.includes(criterion)) return score <= 5 ? "high" : "medium";
3478
+ return score <= 3 ? "medium" : "low";
3479
+ }
3480
+ function getAffectedPages(criterion, pages, threshold = 7) {
3481
+ if (!pages || pages.length === 0) return void 0;
3482
+ const affected = pages.filter((p) => {
3483
+ const cs = p.criterionScores?.find((c) => c.criterion === criterion);
3484
+ return cs && cs.score < threshold;
3485
+ });
3486
+ if (affected.length === 0) return void 0;
3487
+ return affected.map((p) => p.url);
3488
+ }
3489
+ function effortToHours(effort) {
3490
+ switch (effort) {
3491
+ case "trivial":
3492
+ return 0.5;
3493
+ case "low":
3494
+ return 1;
3495
+ case "medium":
3496
+ return 3;
3497
+ case "high":
3498
+ return 8;
3499
+ }
3500
+ }
3501
+ var FIX_GENERATORS = {
3502
+ llms_txt: (c) => {
3503
+ if (c.score >= 10) return [];
3504
+ const impact = impactFromScore(c.score);
3505
+ const effort = effortForCriterion("llms_txt", c.score);
3506
+ const fixes = [];
3507
+ if (c.score <= 6) {
3508
+ fixes.push({
3509
+ id: "fix-llms-txt-create",
3510
+ criterion: c.criterion_label,
3511
+ criterionId: c.criterion,
3512
+ title: "Create /llms.txt file",
3513
+ description: "Add a machine-readable llms.txt file at your domain root that describes your site, services, and key pages for AI engines.",
3514
+ impact,
3515
+ effort,
3516
+ impactScore: 0,
3517
+ // calculated later
3518
+ category: "discovery",
3519
+ steps: [
3520
+ "Create a file named llms.txt in your site root",
3521
+ "Add site name, description, and core URLs in markdown format",
3522
+ "Include key service/product pages and their descriptions",
3523
+ "Deploy and verify access at yourdomain.com/llms.txt"
3524
+ ],
3525
+ codeExample: `# Site Name
3526
+ > One-line site description
3527
+
3528
+ ## Core Pages
3529
+ - [About](/about): Company overview
3530
+ - [Services](/services): Service offerings
3531
+ - [Blog](/blog): Latest articles
3532
+
3533
+ ## Key Topics
3534
+ - Topic 1
3535
+ - Topic 2`,
3536
+ successCriteria: "/llms.txt returns 200 with valid markdown content"
3537
+ });
3538
+ }
3539
+ if (c.score <= 3) {
3540
+ fixes.push({
3541
+ id: "fix-llms-txt-full",
3542
+ criterion: c.criterion_label,
3543
+ criterionId: c.criterion,
3544
+ title: "Add llms-full.txt with extended content",
3545
+ description: "Create a comprehensive llms-full.txt with detailed page descriptions, content summaries, and topic taxonomy.",
3546
+ impact: "medium",
3547
+ effort: "low",
3548
+ impactScore: 0,
3549
+ category: "discovery",
3550
+ steps: [
3551
+ "Create llms-full.txt alongside llms.txt",
3552
+ "Include full page descriptions with word counts",
3553
+ "Add topic categories and content clusters",
3554
+ "Link from llms.txt to llms-full.txt"
3555
+ ],
3556
+ successCriteria: "/llms-full.txt returns 200 with comprehensive site map"
3557
+ });
3558
+ }
3559
+ return fixes;
3560
+ },
3561
+ schema_markup: (c, pages) => {
3562
+ if (c.score >= 10) return [];
3563
+ const impact = impactFromScore(c.score);
3564
+ const effort = effortForCriterion("schema_markup", c.score);
3565
+ const affected = getAffectedPages("schema_markup", pages);
3566
+ const fixes = [{
3567
+ id: "fix-schema-markup",
3568
+ criterion: c.criterion_label,
3569
+ criterionId: c.criterion,
3570
+ title: "Add JSON-LD structured data",
3571
+ description: "Implement Organization, WebSite, and page-specific schema.org JSON-LD to help AI engines extract your content.",
3572
+ impact,
3573
+ effort,
3574
+ impactScore: 0,
3575
+ category: "trust",
3576
+ steps: [
3577
+ "Add Organization JSON-LD to your homepage with name, url, logo, sameAs",
3578
+ "Add WebSite schema with SearchAction",
3579
+ "Add page-specific schema (Article, Service, Product, FAQPage) to relevant pages",
3580
+ "Validate with Google Rich Results Test"
3581
+ ],
3582
+ codeExample: `<script type="application/ld+json">
3583
+ {
3584
+ "@context": "https://schema.org",
3585
+ "@type": "Organization",
3586
+ "name": "Your Company",
3587
+ "url": "https://example.com",
3588
+ "logo": "https://example.com/logo.png",
3589
+ "sameAs": [
3590
+ "https://twitter.com/company",
3591
+ "https://linkedin.com/company/company"
3592
+ ]
3593
+ }
3594
+ </script>`,
3595
+ successCriteria: "Homepage and key pages have valid JSON-LD schema",
3596
+ dependsOn: ["fix-clean-html-structure"],
3597
+ affectedPages: affected,
3598
+ pageCount: affected?.length
3599
+ }];
3600
+ return fixes;
3601
+ },
3602
+ qa_content_format: (c, pages) => {
3603
+ if (c.score >= 10) return [];
3604
+ const impact = impactFromScore(c.score);
3605
+ const effort = effortForCriterion("qa_content_format", c.score);
3606
+ const affected = getAffectedPages("qa_content_format", pages);
3607
+ return [{
3608
+ id: "fix-qa-format",
3609
+ criterion: c.criterion_label,
3610
+ criterionId: c.criterion,
3611
+ title: "Add question-based headings",
3612
+ description: "Restructure content with H2/H3 question headings that match how users query AI assistants.",
3613
+ impact,
3614
+ effort,
3615
+ impactScore: 0,
3616
+ category: "content",
3617
+ steps: [
3618
+ "Identify top user questions for each page topic",
3619
+ "Convert section headings to question format (What, How, Why, When)",
3620
+ "Follow each question heading with a direct 2-3 sentence answer",
3621
+ "Add a summary answer box at the top of long-form content"
3622
+ ],
3623
+ successCriteria: "At least 50% of H2/H3 headings use question format",
3624
+ affectedPages: affected,
3625
+ pageCount: affected?.length
3626
+ }];
3627
+ },
3628
+ clean_html: (c, pages) => {
3629
+ if (c.score >= 10) return [];
3630
+ const impact = impactFromScore(c.score);
3631
+ const effort = effortForCriterion("clean_html", c.score);
3632
+ const affected = getAffectedPages("clean_html", pages);
3633
+ const fixes = [{
3634
+ id: "fix-clean-html-structure",
3635
+ criterion: c.criterion_label,
3636
+ criterionId: c.criterion,
3637
+ title: "Fix HTML structure and meta tags",
3638
+ description: "Ensure clean, well-structured HTML with proper meta tags, HTTPS, and parseable content for AI crawlers.",
3639
+ impact,
3640
+ effort,
3641
+ impactScore: 0,
3642
+ category: "structure",
3643
+ steps: [
3644
+ "Enable HTTPS and redirect HTTP to HTTPS",
3645
+ "Add proper <title>, meta description, and viewport meta tags",
3646
+ "Fix HTML validation errors (unclosed tags, invalid nesting)",
3647
+ "Ensure content is server-rendered (not client-side only)"
3648
+ ],
3649
+ successCriteria: "Pages pass HTML validation with proper meta tags and HTTPS",
3650
+ affectedPages: affected,
3651
+ pageCount: affected?.length
3652
+ }];
3653
+ return fixes;
3654
+ },
3655
+ entity_consistency: (c) => {
3656
+ if (c.score >= 10) return [];
3657
+ const impact = impactFromScore(c.score);
3658
+ const effort = effortForCriterion("entity_consistency", c.score);
3659
+ return [{
3660
+ id: "fix-entity-consistency",
3661
+ criterion: c.criterion_label,
3662
+ criterionId: c.criterion,
3663
+ title: "Strengthen entity authority (NAP)",
3664
+ description: "Add consistent name, address, phone (NAP) and sameAs links across all pages to strengthen entity recognition.",
3665
+ impact,
3666
+ effort,
3667
+ impactScore: 0,
3668
+ category: "trust",
3669
+ steps: [
3670
+ "Ensure company name is consistent across all pages",
3671
+ "Add Organization schema with full NAP details",
3672
+ "Include sameAs links to social profiles and directories",
3673
+ "Add logo and brand marks consistently"
3674
+ ],
3675
+ successCriteria: "Organization schema present with consistent NAP on all pages"
3676
+ }];
3677
+ },
3678
+ robots_txt: (c) => {
3679
+ if (c.score >= 10) return [];
3680
+ const impact = impactFromScore(c.score);
3681
+ const effort = effortForCriterion("robots_txt", c.score);
3682
+ return [{
3683
+ id: "fix-robots-txt",
3684
+ criterion: c.criterion_label,
3685
+ criterionId: c.criterion,
3686
+ title: "Configure robots.txt for AI crawlers",
3687
+ description: "Update robots.txt to explicitly allow AI crawlers and include sitemap directive.",
3688
+ impact,
3689
+ effort,
3690
+ impactScore: 0,
3691
+ category: "discovery",
3692
+ steps: [
3693
+ "Create or update robots.txt at domain root",
3694
+ "Add User-agent rules for GPTBot, ClaudeBot, PerplexityBot",
3695
+ "Include Sitemap directive pointing to sitemap.xml",
3696
+ "Verify no accidental Disallow rules blocking content pages"
3697
+ ],
3698
+ codeExample: `User-agent: *
3699
+ Allow: /
3700
+
3701
+ User-agent: GPTBot
3702
+ Allow: /
3703
+
3704
+ User-agent: ClaudeBot
3705
+ Allow: /
3706
+
3707
+ User-agent: PerplexityBot
3708
+ Allow: /
3709
+
3710
+ Sitemap: https://example.com/sitemap.xml`,
3711
+ successCriteria: "robots.txt returns 200 with AI crawler directives and Sitemap"
3712
+ }];
3713
+ },
3714
+ faq_section: (c, pages) => {
3715
+ if (c.score >= 10) return [];
3716
+ const impact = impactFromScore(c.score);
3717
+ const effort = effortForCriterion("faq_section", c.score);
3718
+ const affected = getAffectedPages("faq_section", pages);
3719
+ return [{
3720
+ id: "fix-faq-section",
3721
+ criterion: c.criterion_label,
3722
+ criterionId: c.criterion,
3723
+ title: "Build FAQ sections with schema",
3724
+ description: "Create FAQ content with FAQPage schema markup on key pages to become a direct answer source for AI engines.",
3725
+ impact,
3726
+ effort,
3727
+ impactScore: 0,
3728
+ category: "content",
3729
+ steps: [
3730
+ "Identify 8-10 most common customer questions per service area",
3731
+ "Create dedicated FAQ page with categorized Q&A pairs",
3732
+ "Add inline FAQ sections to key service/product pages",
3733
+ "Implement FAQPage JSON-LD schema on all FAQ content"
3734
+ ],
3735
+ successCriteria: "FAQ page exists with FAQPage schema, key pages have inline FAQ sections",
3736
+ affectedPages: affected,
3737
+ pageCount: affected?.length
3738
+ }];
3739
+ },
3740
+ original_data: (c, pages) => {
3741
+ if (c.score >= 10) return [];
3742
+ const impact = impactFromScore(c.score);
3743
+ const effort = effortForCriterion("original_data", c.score);
3744
+ const affected = getAffectedPages("original_data", pages);
3745
+ return [{
3746
+ id: "fix-original-data",
3747
+ criterion: c.criterion_label,
3748
+ criterionId: c.criterion,
3749
+ title: "Add original data and case studies",
3750
+ description: "Publish proprietary data, statistics, case studies, or research that AI engines cannot find elsewhere.",
3751
+ impact,
3752
+ effort,
3753
+ impactScore: 0,
3754
+ category: "content",
3755
+ steps: [
3756
+ "Identify internal data assets (customer metrics, case study results, survey data)",
3757
+ "Create data-driven content with specific numbers and percentages",
3758
+ "Publish case studies with measurable outcomes",
3759
+ "Add comparison tables with proprietary benchmarks"
3760
+ ],
3761
+ successCriteria: "At least 3 pages contain original data points not found elsewhere online",
3762
+ affectedPages: affected,
3763
+ pageCount: affected?.length
3764
+ }];
3765
+ },
3766
+ internal_linking: (c, pages, linkGraph) => {
3767
+ if (c.score >= 10) return [];
3768
+ const impact = impactFromScore(c.score);
3769
+ const effort = effortForCriterion("internal_linking", c.score);
3770
+ const fixes = [];
3771
+ if (linkGraph) {
3772
+ const orphans = [];
3773
+ linkGraph.nodes.forEach((node) => {
3774
+ if (node.isOrphan) orphans.push(node.url);
3775
+ });
3776
+ if (orphans.length > 0) {
3777
+ fixes.push({
3778
+ id: "fix-internal-linking-orphans",
3779
+ criterion: c.criterion_label,
3780
+ criterionId: c.criterion,
3781
+ title: "Link orphan pages into site navigation",
3782
+ description: `${orphans.length} pages have no incoming internal links. These are invisible to AI crawlers that follow links.`,
3783
+ impact: orphans.length > 5 ? "critical" : "high",
3784
+ effort: orphans.length > 10 ? "medium" : "low",
3785
+ impactScore: 0,
3786
+ category: "structure",
3787
+ steps: [
3788
+ `Identify the ${orphans.length} orphan pages with zero incoming links`,
3789
+ "Add contextual links from related content pages",
3790
+ "Include orphan pages in navigation menus or footer links",
3791
+ 'Add "Related Content" sections on relevant pages'
3792
+ ],
3793
+ successCriteria: "All content pages have at least 1 incoming internal link",
3794
+ affectedPages: orphans.slice(0, 20),
3795
+ pageCount: orphans.length
3796
+ });
3797
+ }
3798
+ if (linkGraph.stats.maxDepth > 3) {
3799
+ fixes.push({
3800
+ id: "fix-internal-linking-depth",
3801
+ criterion: c.criterion_label,
3802
+ criterionId: c.criterion,
3803
+ title: "Reduce page depth for deep content",
3804
+ description: `Max depth is ${linkGraph.stats.maxDepth} clicks from homepage. AI crawlers rarely follow links beyond 3 levels.`,
3805
+ impact: "medium",
3806
+ effort: "medium",
3807
+ impactScore: 0,
3808
+ category: "structure",
3809
+ steps: [
3810
+ "Identify pages more than 3 clicks from the homepage",
3811
+ "Add direct links from high-level pages to deep content",
3812
+ "Consider flattening URL structure for key pages",
3813
+ "Add hub pages that aggregate related deep content"
3814
+ ],
3815
+ successCriteria: "All important content pages reachable within 3 clicks from homepage"
3816
+ });
3817
+ }
3818
+ if (linkGraph.clusters.length === 0) {
3819
+ fixes.push({
3820
+ id: "fix-internal-linking-clusters",
3821
+ criterion: c.criterion_label,
3822
+ criterionId: c.criterion,
3823
+ title: "Create topic clusters with pillar pages",
3824
+ description: "No topic clusters detected. Organizing content into pillar-spoke clusters strengthens topical authority for AI engines.",
3825
+ impact: "high",
3826
+ effort: "high",
3827
+ impactScore: 0,
3828
+ category: "structure",
3829
+ steps: [
3830
+ "Identify 3-5 core topic areas for your business",
3831
+ "Create comprehensive pillar pages (3000+ words) for each topic",
3832
+ "Write 5-7 supporting articles per pillar linking back to pillar",
3833
+ "Interlink supporting articles within each cluster"
3834
+ ],
3835
+ successCriteria: "At least 2 topic clusters with pillar page and 5+ spoke pages"
3836
+ });
3837
+ }
3838
+ } else {
3839
+ fixes.push({
3840
+ id: "fix-internal-linking-generic",
3841
+ criterion: c.criterion_label,
3842
+ criterionId: c.criterion,
3843
+ title: "Improve internal linking architecture",
3844
+ description: "Strengthen internal linking with descriptive anchor text between related pages.",
3845
+ impact,
3846
+ effort,
3847
+ impactScore: 0,
3848
+ category: "structure",
3849
+ steps: [
3850
+ "Audit current internal link structure",
3851
+ "Add contextual links between related content pages",
3852
+ "Ensure every key page is reachable within 3 clicks from homepage",
3853
+ 'Use descriptive anchor text instead of "click here" or "read more"'
3854
+ ],
3855
+ successCriteria: "Key pages have 3+ incoming internal links with descriptive anchors"
3856
+ });
3857
+ }
3858
+ return fixes;
3859
+ },
3860
+ semantic_html: (c, pages) => {
3861
+ if (c.score >= 10) return [];
3862
+ const impact = impactFromScore(c.score);
3863
+ const effort = effortForCriterion("semantic_html", c.score);
3864
+ const affected = getAffectedPages("semantic_html", pages);
3865
+ return [{
3866
+ id: "fix-semantic-html",
3867
+ criterion: c.criterion_label,
3868
+ criterionId: c.criterion,
3869
+ title: "Implement semantic HTML5 elements",
3870
+ description: "Use semantic HTML5 elements (main, article, nav, header, footer, section) to give AI parsers clear content structure.",
3871
+ impact,
3872
+ effort,
3873
+ impactScore: 0,
3874
+ category: "structure",
3875
+ steps: [
3876
+ "Wrap main content in <main> element",
3877
+ "Use <article> for self-contained content blocks",
3878
+ "Add <nav> for navigation and <aside> for sidebars",
3879
+ "Add lang attribute to <html> and ARIA labels for accessibility"
3880
+ ],
3881
+ successCriteria: "Pages use semantic HTML5 elements with lang attribute",
3882
+ affectedPages: affected,
3883
+ pageCount: affected?.length
3884
+ }];
3885
+ },
3886
+ content_freshness: (c, pages) => {
3887
+ if (c.score >= 10) return [];
3888
+ const impact = impactFromScore(c.score);
3889
+ const effort = effortForCriterion("content_freshness", c.score);
3890
+ const affected = getAffectedPages("content_freshness", pages);
3891
+ return [{
3892
+ id: "fix-content-freshness",
3893
+ criterion: c.criterion_label,
3894
+ criterionId: c.criterion,
3895
+ title: "Add content freshness signals",
3896
+ description: "Include dateModified schema, visible dates, and recent content updates to signal freshness to AI engines.",
3897
+ impact,
3898
+ effort,
3899
+ impactScore: 0,
3900
+ category: "content",
3901
+ steps: [
3902
+ "Add datePublished and dateModified to Article schema",
3903
+ 'Display visible "Last updated" dates on content pages',
3904
+ "Update stale content with current information",
3905
+ "Add <time> elements with datetime attributes for all dates"
3906
+ ],
3907
+ successCriteria: "Content pages show visible dates and have dateModified in schema",
3908
+ affectedPages: affected,
3909
+ pageCount: affected?.length
3910
+ }];
3911
+ },
3912
+ sitemap_completeness: (c) => {
3913
+ if (c.score >= 10) return [];
3914
+ const impact = impactFromScore(c.score);
3915
+ const effort = effortForCriterion("sitemap_completeness", c.score);
3916
+ return [{
3917
+ id: "fix-sitemap",
3918
+ criterion: c.criterion_label,
3919
+ criterionId: c.criterion,
3920
+ title: "Create complete sitemap.xml",
3921
+ description: "Generate a comprehensive sitemap with lastmod dates for all important pages.",
3922
+ impact,
3923
+ effort,
3924
+ impactScore: 0,
3925
+ category: "discovery",
3926
+ steps: [
3927
+ "Generate sitemap.xml listing all content pages",
3928
+ "Include <lastmod> dates for each URL",
3929
+ "Set <changefreq> and <priority> appropriately",
3930
+ "Reference sitemap in robots.txt"
3931
+ ],
3932
+ successCriteria: "sitemap.xml returns 200 with all content pages and lastmod dates"
3933
+ }];
3934
+ },
3935
+ rss_feed: (c) => {
3936
+ if (c.score >= 10) return [];
3937
+ const impact = impactFromScore(c.score);
3938
+ const effort = effortForCriterion("rss_feed", c.score);
3939
+ return [{
3940
+ id: "fix-rss-feed",
3941
+ criterion: c.criterion_label,
3942
+ criterionId: c.criterion,
3943
+ title: "Deploy RSS/Atom feed",
3944
+ description: "Add an RSS or Atom feed for your blog/news content to signal active publishing to AI engines.",
3945
+ impact,
3946
+ effort,
3947
+ impactScore: 0,
3948
+ category: "discovery",
3949
+ steps: [
3950
+ "Create RSS 2.0 or Atom feed for blog/news content",
3951
+ "Include title, description, pubDate, and full content for each item",
3952
+ 'Add <link rel="alternate" type="application/rss+xml"> to page head',
3953
+ "Auto-generate feed on each new publish"
3954
+ ],
3955
+ codeExample: `<?xml version="1.0" encoding="UTF-8"?>
3956
+ <rss version="2.0">
3957
+ <channel>
3958
+ <title>Your Site Blog</title>
3959
+ <link>https://example.com/blog</link>
3960
+ <description>Latest articles</description>
3961
+ <item>
3962
+ <title>Article Title</title>
3963
+ <link>https://example.com/blog/article</link>
3964
+ <pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
3965
+ <description>Article summary</description>
3966
+ </item>
3967
+ </channel>
3968
+ </rss>`,
3969
+ successCriteria: "RSS feed returns valid XML with recent content items"
3970
+ }];
3971
+ },
3972
+ table_list_extractability: (c, pages) => {
3973
+ if (c.score >= 10) return [];
3974
+ const impact = impactFromScore(c.score);
3975
+ const effort = effortForCriterion("table_list_extractability", c.score);
3976
+ const affected = getAffectedPages("table_list_extractability", pages);
3977
+ return [{
3978
+ id: "fix-tables-lists",
3979
+ criterion: c.criterion_label,
3980
+ criterionId: c.criterion,
3981
+ title: "Add structured tables and lists",
3982
+ description: "Use HTML tables for comparison data and lists for features, steps, and specifications.",
3983
+ impact,
3984
+ effort,
3985
+ impactScore: 0,
3986
+ category: "content",
3987
+ steps: [
3988
+ "Identify data suitable for table format (comparisons, pricing, specs)",
3989
+ "Convert bullet points to proper <ul>/<ol> lists",
3990
+ "Add comparison tables with <th> headers",
3991
+ "Ensure tables have descriptive captions"
3992
+ ],
3993
+ successCriteria: "Key pages contain at least one HTML table or structured list",
3994
+ affectedPages: affected,
3995
+ pageCount: affected?.length
3996
+ }];
3997
+ },
3998
+ definition_patterns: (c, pages) => {
3999
+ if (c.score >= 10) return [];
4000
+ const impact = impactFromScore(c.score);
4001
+ const effort = effortForCriterion("definition_patterns", c.score);
4002
+ const affected = getAffectedPages("definition_patterns", pages);
4003
+ return [{
4004
+ id: "fix-definitions",
4005
+ criterion: c.criterion_label,
4006
+ criterionId: c.criterion,
4007
+ title: "Add definition-style content",
4008
+ description: 'Include clear definition patterns for key terms and concepts that AI engines can cite for "what is" queries.',
4009
+ impact,
4010
+ effort,
4011
+ impactScore: 0,
4012
+ category: "content",
4013
+ steps: [
4014
+ "Identify key industry terms your audience searches for",
4015
+ 'Write clear definitions using "X is..." or "X refers to..." patterns',
4016
+ "Place definitions near the top of relevant pages",
4017
+ "Consider a glossary page for comprehensive term coverage"
4018
+ ],
4019
+ successCriteria: "Key pages contain definition patterns for relevant terms",
4020
+ affectedPages: affected,
4021
+ pageCount: affected?.length
4022
+ }];
4023
+ },
4024
+ direct_answer_density: (c, pages) => {
4025
+ if (c.score >= 10) return [];
4026
+ const impact = impactFromScore(c.score);
4027
+ const effort = effortForCriterion("direct_answer_density", c.score);
4028
+ const affected = getAffectedPages("direct_answer_density", pages);
4029
+ return [{
4030
+ id: "fix-direct-answers",
4031
+ criterion: c.criterion_label,
4032
+ criterionId: c.criterion,
4033
+ title: "Add direct answer paragraphs",
4034
+ description: "Write concise, standalone answer paragraphs after question headings for AI engine citations.",
4035
+ impact,
4036
+ effort,
4037
+ impactScore: 0,
4038
+ category: "content",
4039
+ steps: [
4040
+ "Identify question-format headings on each page",
4041
+ "Write a 2-3 sentence direct answer immediately after each heading",
4042
+ "Ensure answers are self-contained (don't require context from other sections)",
4043
+ "Use bold for key facts within answer paragraphs"
4044
+ ],
4045
+ successCriteria: "Question headings are followed by direct, concise answer paragraphs",
4046
+ affectedPages: affected,
4047
+ pageCount: affected?.length
4048
+ }];
4049
+ },
4050
+ content_licensing: (c) => {
4051
+ if (c.score >= 10) return [];
4052
+ const impact = impactFromScore(c.score);
4053
+ const effort = effortForCriterion("content_licensing", c.score);
4054
+ return [{
4055
+ id: "fix-content-licensing",
4056
+ criterion: c.criterion_label,
4057
+ criterionId: c.criterion,
4058
+ title: "Add ai.txt and content licensing",
4059
+ description: "Create an /ai.txt file specifying AI usage permissions and add license schema to structured data.",
4060
+ impact,
4061
+ effort,
4062
+ impactScore: 0,
4063
+ category: "trust",
4064
+ steps: [
4065
+ "Create ai.txt at domain root with usage permissions",
4066
+ "Specify allowed AI uses (training, citation, summarization)",
4067
+ "Add license information to schema markup",
4068
+ "Consider a content licensing page linked from footer"
4069
+ ],
4070
+ codeExample: `# ai.txt - AI Usage Policy for example.com
4071
+
4072
+ User-Agent: *
4073
+ Allow: /blog/
4074
+ Allow: /docs/
4075
+
4076
+ # Permissions
4077
+ Training: yes
4078
+ Citation: yes with attribution
4079
+ Summarization: yes`,
4080
+ successCriteria: "/ai.txt returns 200 with clear AI usage permissions"
4081
+ }];
4082
+ },
4083
+ author_schema_depth: (c) => {
4084
+ if (c.score >= 10) return [];
4085
+ const impact = impactFromScore(c.score);
4086
+ const effort = effortForCriterion("author_schema_depth", c.score);
4087
+ return [{
4088
+ id: "fix-author-schema",
4089
+ criterion: c.criterion_label,
4090
+ criterionId: c.criterion,
4091
+ title: "Enhance author and expert schema",
4092
+ description: "Add Person schema for content authors with credentials and sameAs links for E-E-A-T signals.",
4093
+ impact,
4094
+ effort,
4095
+ impactScore: 0,
4096
+ category: "trust",
4097
+ steps: [
4098
+ "Create author profile pages for content creators",
4099
+ "Add Person schema with name, jobTitle, credentials, sameAs",
4100
+ "Link articles to author profiles via schema author property",
4101
+ "Include author bio and expertise on article pages"
4102
+ ],
4103
+ successCriteria: "Articles have Person schema for authors with credentials"
4104
+ }];
4105
+ },
4106
+ fact_density: (c, pages) => {
4107
+ if (c.score >= 10) return [];
4108
+ const impact = impactFromScore(c.score);
4109
+ const effort = effortForCriterion("fact_density", c.score);
4110
+ const affected = getAffectedPages("fact_density", pages);
4111
+ return [{
4112
+ id: "fix-fact-density",
4113
+ criterion: c.criterion_label,
4114
+ criterionId: c.criterion,
4115
+ title: "Increase fact and data density",
4116
+ description: "Add specific numbers, percentages, statistics, and data points that AI engines can cite.",
4117
+ impact,
4118
+ effort,
4119
+ impactScore: 0,
4120
+ category: "content",
4121
+ steps: [
4122
+ "Review content for vague claims and replace with specific data",
4123
+ "Add statistics, percentages, and measurable outcomes",
4124
+ "Include source citations for data points",
4125
+ "Add data tables or comparison charts where appropriate"
4126
+ ],
4127
+ successCriteria: "Key pages contain at least 3 specific data points per 500 words",
4128
+ affectedPages: affected,
4129
+ pageCount: affected?.length
4130
+ }];
4131
+ },
4132
+ canonical_url: (c, pages) => {
4133
+ if (c.score >= 10) return [];
4134
+ const impact = impactFromScore(c.score);
4135
+ const effort = effortForCriterion("canonical_url", c.score);
4136
+ const affected = getAffectedPages("canonical_url", pages);
4137
+ return [{
4138
+ id: "fix-canonical-url",
4139
+ criterion: c.criterion_label,
4140
+ criterionId: c.criterion,
4141
+ title: "Fix canonical URL strategy",
4142
+ description: 'Add rel="canonical" tags to all pages to prevent duplicate content confusion.',
4143
+ impact,
4144
+ effort,
4145
+ impactScore: 0,
4146
+ category: "structure",
4147
+ steps: [
4148
+ 'Add <link rel="canonical"> to every page pointing to preferred URL',
4149
+ "Ensure canonical URLs use consistent scheme (https) and format",
4150
+ "Handle www vs non-www with proper redirects",
4151
+ "Set canonical for paginated content to the main page"
4152
+ ],
4153
+ codeExample: `<link rel="canonical" href="https://example.com/page" />`,
4154
+ successCriteria: 'All pages have rel="canonical" pointing to the correct URL',
4155
+ affectedPages: affected,
4156
+ pageCount: affected?.length
4157
+ }];
4158
+ },
4159
+ content_velocity: (c) => {
4160
+ if (c.score >= 10) return [];
4161
+ const impact = impactFromScore(c.score);
4162
+ const effort = effortForCriterion("content_velocity", c.score);
4163
+ return [{
4164
+ id: "fix-content-velocity",
4165
+ criterion: c.criterion_label,
4166
+ criterionId: c.criterion,
4167
+ title: "Increase publishing frequency",
4168
+ description: "Establish a regular content publishing cadence to signal active, current information to AI engines.",
4169
+ impact,
4170
+ effort,
4171
+ impactScore: 0,
4172
+ category: "content",
4173
+ steps: [
4174
+ "Set a publishing schedule (weekly or bi-weekly minimum)",
4175
+ "Create a content calendar covering key topics",
4176
+ "Update sitemap and RSS feed with each new publish",
4177
+ "Refresh existing evergreen content with current data"
4178
+ ],
4179
+ successCriteria: "At least 2 new or updated content pages per month with dated entries"
4180
+ }];
4181
+ },
4182
+ schema_coverage: (c) => {
4183
+ if (c.score >= 10) return [];
4184
+ const impact = impactFromScore(c.score);
4185
+ const effort = effortForCriterion("schema_coverage", c.score);
4186
+ return [{
4187
+ id: "fix-schema-coverage",
4188
+ criterion: c.criterion_label,
4189
+ criterionId: c.criterion,
4190
+ title: "Extend schema to inner pages",
4191
+ description: "Add page-specific structured data beyond the homepage to articles, services, and product pages.",
4192
+ impact,
4193
+ effort,
4194
+ impactScore: 0,
4195
+ category: "trust",
4196
+ steps: [
4197
+ "Add Article schema to blog/news pages",
4198
+ "Add Service/Product schema to service/product pages",
4199
+ "Add BreadcrumbList schema to all inner pages",
4200
+ "Validate each page type with Rich Results Test"
4201
+ ],
4202
+ successCriteria: "At least 50% of content pages have page-specific schema",
4203
+ dependsOn: ["fix-schema-markup"]
4204
+ }];
4205
+ },
4206
+ speakable_schema: (c) => {
4207
+ if (c.score >= 10) return [];
4208
+ const impact = impactFromScore(c.score);
4209
+ const effort = effortForCriterion("speakable_schema", c.score);
4210
+ return [{
4211
+ id: "fix-speakable-schema",
4212
+ criterion: c.criterion_label,
4213
+ criterionId: c.criterion,
4214
+ title: "Add SpeakableSpecification schema",
4215
+ description: "Add Speakable schema to tell voice assistants which content sections are best for spoken answers.",
4216
+ impact,
4217
+ effort,
4218
+ impactScore: 0,
4219
+ category: "trust",
4220
+ steps: [
4221
+ "Identify key paragraphs suitable for voice readout",
4222
+ "Add SpeakableSpecification with CSS selectors to Article schema",
4223
+ "Point speakable selectors to headline and summary paragraphs",
4224
+ "Test with Google structured data testing tool"
4225
+ ],
4226
+ codeExample: `"speakable": {
4227
+ "@type": "SpeakableSpecification",
4228
+ "cssSelector": [
4229
+ ".article-headline",
4230
+ ".article-summary"
4231
+ ]
4232
+ }`,
4233
+ successCriteria: "Article pages include SpeakableSpecification in schema",
4234
+ dependsOn: ["fix-schema-markup"]
4235
+ }];
4236
+ },
4237
+ query_answer_alignment: (c, pages) => {
4238
+ if (c.score >= 10) return [];
4239
+ const impact = impactFromScore(c.score);
4240
+ const effort = effortForCriterion("query_answer_alignment", c.score);
4241
+ const affected = getAffectedPages("query_answer_alignment", pages);
4242
+ return [{
4243
+ id: "fix-query-answer-alignment",
4244
+ criterion: c.criterion_label,
4245
+ criterionId: c.criterion,
4246
+ title: "Improve query-answer alignment",
4247
+ description: "Ensure question headings are followed by direct, concise answers in the first paragraph.",
4248
+ impact,
4249
+ effort,
4250
+ impactScore: 0,
4251
+ category: "content",
4252
+ steps: [
4253
+ "Audit question-format headings and their following paragraphs",
4254
+ "Add direct answers in the first 1-2 sentences after each question heading",
4255
+ "Remove filler text between question and answer",
4256
+ "Ensure answers are self-contained and citable"
4257
+ ],
4258
+ successCriteria: "Question headings have direct answer paragraphs within 50 words",
4259
+ affectedPages: affected,
4260
+ pageCount: affected?.length
4261
+ }];
4262
+ },
4263
+ content_cannibalization: (c, pages, linkGraph) => {
4264
+ if (c.score >= 10) return [];
4265
+ const impact = impactFromScore(c.score);
4266
+ const effort = effortForCriterion("content_cannibalization", c.score);
4267
+ const fixes = [];
4268
+ if (linkGraph && linkGraph.clusters.length > 0) {
4269
+ const lowCohesion = linkGraph.clusters.filter((cl) => cl.cohesion < 50);
4270
+ if (lowCohesion.length > 0) {
4271
+ const affected = lowCohesion.flatMap((cl) => [cl.pillarUrl, ...cl.spokes]);
4272
+ fixes.push({
4273
+ id: "fix-content-cannibalization-overlap",
4274
+ criterion: c.criterion_label,
4275
+ criterionId: c.criterion,
4276
+ title: "Consolidate overlapping content",
4277
+ description: `${lowCohesion.length} content clusters have low cohesion, suggesting pages compete for the same topics.`,
4278
+ impact,
4279
+ effort,
4280
+ impactScore: 0,
4281
+ category: "content",
4282
+ steps: [
4283
+ "Identify pages targeting the same keywords or topics",
4284
+ "Merge overlapping pages into single authoritative pages",
4285
+ "Set up 301 redirects from merged pages to consolidated page",
4286
+ "Differentiate remaining similar pages with distinct angles"
4287
+ ],
4288
+ successCriteria: "No two pages target the same primary keyword or topic",
4289
+ affectedPages: affected.slice(0, 20),
4290
+ pageCount: affected.length
4291
+ });
4292
+ }
4293
+ }
4294
+ if (fixes.length === 0) {
4295
+ const affected = getAffectedPages("content_cannibalization", pages);
4296
+ fixes.push({
4297
+ id: "fix-content-cannibalization",
4298
+ criterion: c.criterion_label,
4299
+ criterionId: c.criterion,
4300
+ title: "Resolve content cannibalization",
4301
+ description: "Multiple pages may be targeting the same topics, diluting AI engine citations.",
4302
+ impact,
4303
+ effort,
4304
+ impactScore: 0,
4305
+ category: "content",
4306
+ steps: [
4307
+ "Audit pages for overlapping topic coverage",
4308
+ "Consolidate similar pages into comprehensive single pages",
4309
+ "Differentiate remaining pages with distinct angles and keywords",
4310
+ "Add canonical tags to prevent duplicate content issues"
4311
+ ],
4312
+ successCriteria: "Each topic is covered by a single authoritative page",
4313
+ affectedPages: affected,
4314
+ pageCount: affected?.length
4315
+ });
4316
+ }
4317
+ return fixes;
4318
+ },
4319
+ visible_date_signal: (c, pages) => {
4320
+ if (c.score >= 10) return [];
4321
+ const impact = impactFromScore(c.score);
4322
+ const effort = effortForCriterion("visible_date_signal", c.score);
4323
+ const affected = getAffectedPages("visible_date_signal", pages);
4324
+ return [{
4325
+ id: "fix-visible-dates",
4326
+ criterion: c.criterion_label,
4327
+ criterionId: c.criterion,
4328
+ title: "Add visible date signals",
4329
+ description: "Add visible publication and modification dates using <time> elements for AI engine freshness assessment.",
4330
+ impact,
4331
+ effort,
4332
+ impactScore: 0,
4333
+ category: "content",
4334
+ steps: [
4335
+ 'Add visible "Published" and "Last updated" dates to content pages',
4336
+ "Use <time> elements with datetime attributes",
4337
+ "Ensure dates match dateModified in schema markup",
4338
+ "Update dates when content is refreshed"
4339
+ ],
4340
+ codeExample: `<time datetime="2024-01-15">January 15, 2024</time>`,
4341
+ successCriteria: "Content pages show visible dates with <time> elements",
4342
+ affectedPages: affected,
4343
+ pageCount: affected?.length
4344
+ }];
4345
+ }
4346
+ };
4347
+ function generateFixPlan(domain, overallScore, criteria, pagesReviewed, linkGraph) {
4348
+ const allFixes = [];
4349
+ for (const criterion of criteria) {
4350
+ const generator = FIX_GENERATORS[criterion.criterion];
4351
+ if (!generator) continue;
4352
+ const fixes = generator(criterion, pagesReviewed, linkGraph);
4353
+ for (const fix of fixes) {
4354
+ const weight = CRITERION_WEIGHTS2[criterion.criterion] ?? 0.05;
4355
+ fix.impactScore = Math.round((10 - criterion.score) * weight * 100);
4356
+ allFixes.push(fix);
4357
+ }
4358
+ }
4359
+ const phases = PHASE_CONFIG.map((config) => {
4360
+ const phaseFixes = allFixes.filter((fix) => config.criteria.includes(fix.criterionId)).sort((a, b) => b.impactScore - a.impactScore);
4361
+ return {
4362
+ phase: config.phase,
4363
+ title: config.title,
4364
+ description: config.description,
4365
+ fixes: phaseFixes,
4366
+ estimatedImpact: 0
4367
+ // calculated after projected score
4368
+ };
4369
+ });
4370
+ for (const phase of phases) {
4371
+ for (const fix of phase.fixes) {
4372
+ if (!fix.dependsOn) continue;
4373
+ for (const depId of fix.dependsOn) {
4374
+ const depPhase = phases.find((p) => p.fixes.some((f) => f.id === depId));
4375
+ if (depPhase && depPhase.phase > phase.phase) {
4376
+ phase.fixes = phase.fixes.filter((f) => f.id !== fix.id);
4377
+ depPhase.fixes.push(fix);
4378
+ depPhase.fixes.sort((a, b) => b.impactScore - a.impactScore);
4379
+ break;
4380
+ }
4381
+ }
4382
+ }
4383
+ }
4384
+ const totalWeight = Object.values(CRITERION_WEIGHTS2).reduce((s, w) => s + w, 0);
4385
+ const bestDeltaPerCriterion = /* @__PURE__ */ new Map();
4386
+ for (const fix of allFixes) {
4387
+ const criterion = criteria.find((c) => c.criterion === fix.criterionId);
4388
+ if (!criterion) continue;
4389
+ const weight = CRITERION_WEIGHTS2[fix.criterionId] ?? 0.05;
4390
+ let targetScore;
4391
+ switch (fix.effort) {
4392
+ case "trivial":
4393
+ case "low":
4394
+ targetScore = 8;
4395
+ break;
4396
+ case "medium":
4397
+ targetScore = 7;
4398
+ break;
4399
+ case "high":
4400
+ targetScore = 6;
4401
+ break;
4402
+ }
4403
+ const improvement = Math.max(0, targetScore - criterion.score);
4404
+ const delta = improvement * weight / totalWeight * 100;
4405
+ const existing = bestDeltaPerCriterion.get(fix.criterionId) ?? 0;
4406
+ if (delta > existing) bestDeltaPerCriterion.set(fix.criterionId, delta);
4407
+ }
4408
+ const scoreDelta = Array.from(bestDeltaPerCriterion.values()).reduce((s, d) => s + d, 0);
4409
+ const projectedScore = Math.min(100, Math.round(overallScore + scoreDelta));
4410
+ for (const phase of phases) {
4411
+ let phaseImpact = 0;
4412
+ const seenCriteria = /* @__PURE__ */ new Set();
4413
+ for (const fix of phase.fixes) {
4414
+ if (seenCriteria.has(fix.criterionId)) continue;
4415
+ seenCriteria.add(fix.criterionId);
4416
+ const criterion = criteria.find((c) => c.criterion === fix.criterionId);
4417
+ if (!criterion) continue;
4418
+ const weight = CRITERION_WEIGHTS2[fix.criterionId] ?? 0.05;
4419
+ let targetScore;
4420
+ switch (fix.effort) {
4421
+ case "trivial":
4422
+ case "low":
4423
+ targetScore = 8;
4424
+ break;
4425
+ case "medium":
4426
+ targetScore = 7;
4427
+ break;
4428
+ case "high":
4429
+ targetScore = 6;
4430
+ break;
4431
+ }
4432
+ const improvement = Math.max(0, targetScore - criterion.score);
4433
+ phaseImpact += improvement * weight / totalWeight * 100;
4434
+ }
4435
+ phase.estimatedImpact = Math.round(phaseImpact);
4436
+ }
4437
+ const quickWins = allFixes.filter(
4438
+ (f) => (f.effort === "trivial" || f.effort === "low") && (f.impact === "critical" || f.impact === "high")
4439
+ );
4440
+ const summary = {
4441
+ criticalCount: allFixes.filter((f) => f.impact === "critical").length,
4442
+ highCount: allFixes.filter((f) => f.impact === "high").length,
4443
+ mediumCount: allFixes.filter((f) => f.impact === "medium").length,
4444
+ lowCount: allFixes.filter((f) => f.impact === "low").length,
4445
+ quickWinCount: quickWins.length,
4446
+ topOpportunity: allFixes.length > 0 ? allFixes.sort((a, b) => b.impactScore - a.impactScore)[0].title : "None",
4447
+ estimatedTotalEffort: formatEffort(allFixes.reduce((s, f) => s + effortToHours(f.effort), 0))
4448
+ };
4449
+ return {
4450
+ domain,
4451
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4452
+ overallScore,
4453
+ projectedScore,
4454
+ totalFixes: allFixes.length,
4455
+ phases,
4456
+ quickWins,
4457
+ summary
4458
+ };
4459
+ }
4460
+ function formatEffort(hours) {
4461
+ if (hours < 1) return "<1h";
4462
+ return `~${Math.round(hours)}h`;
4463
+ }
4464
+
2809
4465
  // src/html-report.ts
2810
4466
  function scoreColor(score) {
2811
4467
  if (score <= 40) return "#F44336";
@@ -2915,17 +4571,22 @@ function generateHtmlReport(result) {
2915
4571
  <td>${escapeHtml(opp.description)}</td>
2916
4572
  </tr>`;
2917
4573
  }).join("\n");
2918
- const pagesRows = (result.pagesReviewed || []).map((page) => {
4574
+ const pagesReviewed = result.pagesReviewed || [];
4575
+ const pagesRows = pagesReviewed.map((page) => {
2919
4576
  const issueCount = page.issues.length;
2920
4577
  const strengthCount = page.strengths.length;
4578
+ const aeoDisplay = page.aeoScore != null ? `<span style="font-weight:600;color:${scoreColor(page.aeoScore)}">${page.aeoScore}</span>` : "-";
2921
4579
  return `<tr>
2922
4580
  <td>${escapeHtml(page.url)}</td>
2923
4581
  <td>${escapeHtml(page.category)}</td>
2924
4582
  <td>${page.wordCount}</td>
4583
+ <td>${aeoDisplay}</td>
2925
4584
  <td>${issueCount}</td>
2926
4585
  <td>${strengthCount}</td>
2927
4586
  </tr>`;
2928
4587
  }).join("\n");
4588
+ const scoredPages = pagesReviewed.filter((p) => p.aeoScore != null);
4589
+ const avgPageScore = scoredPages.length > 0 ? Math.round(scoredPages.reduce((sum, p) => sum + p.aeoScore, 0) / scoredPages.length) : null;
2929
4590
  const now = (/* @__PURE__ */ new Date()).toISOString();
2930
4591
  return `<!DOCTYPE html>
2931
4592
  <html lang="en">
@@ -2961,10 +4622,11 @@ function generateHtmlReport(result) {
2961
4622
  </table>
2962
4623
  ` : ""}
2963
4624
 
2964
- ${(result.pagesReviewed || []).length > 0 ? `
2965
- <h2 class="section-title">Pages Reviewed (${(result.pagesReviewed || []).length})</h2>
4625
+ ${pagesReviewed.length > 0 ? `
4626
+ <h2 class="section-title">Pages Reviewed (${pagesReviewed.length})</h2>
4627
+ ${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
4628
  <table>
2967
- <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>Issues</th><th>Strengths</th></tr></thead>
4629
+ <thead><tr><th>URL</th><th>Category</th><th>Words</th><th>AEO Score</th><th>Issues</th><th>Strengths</th></tr></thead>
2968
4630
  <tbody>${pagesRows}</tbody>
2969
4631
  </table>
2970
4632
  ` : ""}
@@ -3135,21 +4797,28 @@ export {
3135
4797
  audit,
3136
4798
  auditSiteFromData,
3137
4799
  buildDetailedFindings,
4800
+ buildLinkGraph,
3138
4801
  buildScorecard,
4802
+ calculateDepths,
3139
4803
  calculateOverallScore,
3140
4804
  classifyRendering,
3141
4805
  compare,
3142
4806
  crawlFullSite,
4807
+ detectClusters,
4808
+ detectHubs,
3143
4809
  detectParkedDomain,
4810
+ detectPillars,
3144
4811
  extractAllUrlsFromSitemap,
3145
4812
  extractContentPagesFromSitemap,
3146
4813
  extractInternalLinks,
4814
+ extractLinksWithAnchors,
3147
4815
  extractNavLinks,
3148
4816
  extractRawDataSummary,
3149
4817
  fetchMultiPageData,
3150
4818
  fetchWithHeadless,
3151
4819
  generateBottomLine,
3152
4820
  generateComparisonHtmlReport,
4821
+ generateFixPlan,
3153
4822
  generateHtmlReport,
3154
4823
  generateOpportunities,
3155
4824
  generatePitchNumbers,
@@ -3157,6 +4826,9 @@ export {
3157
4826
  inferCategory,
3158
4827
  isSpaShell,
3159
4828
  prefetchSiteData,
3160
- scoreToStatus
4829
+ scoreAllPages,
4830
+ scorePage,
4831
+ scoreToStatus,
4832
+ serializeLinkGraph
3161
4833
  };
3162
4834
  //# sourceMappingURL=index.js.map