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