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/README.md +185 -2
- package/dist/browser.d.ts +524 -0
- package/dist/browser.js +4519 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.js +369 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1691 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -19
- package/dist/index.d.ts +181 -19
- package/dist/index.js +1680 -8
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
${
|
|
2965
|
-
<h2 class="section-title">Pages Reviewed (${
|
|
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
|
-
|
|
4829
|
+
scoreAllPages,
|
|
4830
|
+
scorePage,
|
|
4831
|
+
scoreToStatus,
|
|
4832
|
+
serializeLinkGraph
|
|
3161
4833
|
};
|
|
3162
4834
|
//# sourceMappingURL=index.js.map
|