aiseo-audit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,2605 @@
1
+ // src/cli.ts
2
+ import { Command } from "commander";
3
+
4
+ // src/modules/analyzer/constants.ts
5
+ var DOMAIN_SIGNAL_TIMEOUT_CAP = 5e3;
6
+ var VERSION = true ? "1.0.0" : "0.0.0";
7
+
8
+ // src/modules/fetcher/constants.ts
9
+ var MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
10
+ var DEFAULT_HEADERS = {
11
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
12
+ "Accept-Language": "en-US,en;q=0.9",
13
+ "Accept-Encoding": "gzip, deflate, br",
14
+ "Cache-Control": "no-cache"
15
+ };
16
+
17
+ // src/utils/http.ts
18
+ async function httpGet(options) {
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), options.timeout);
21
+ try {
22
+ const response = await fetch(options.url, {
23
+ method: "GET",
24
+ headers: {
25
+ "User-Agent": options.userAgent,
26
+ ...DEFAULT_HEADERS
27
+ },
28
+ signal: controller.signal,
29
+ redirect: "follow"
30
+ });
31
+ const contentLength = response.headers.get("content-length");
32
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
33
+ throw new Error(
34
+ `Response exceeds maximum size of ${MAX_RESPONSE_SIZE} bytes`
35
+ );
36
+ }
37
+ const data = await response.text();
38
+ if (data.length > MAX_RESPONSE_SIZE) {
39
+ throw new Error(
40
+ `Response exceeds maximum size of ${MAX_RESPONSE_SIZE} bytes`
41
+ );
42
+ }
43
+ const headers = {};
44
+ response.headers.forEach((value, key) => {
45
+ headers[key] = value;
46
+ });
47
+ return {
48
+ status: response.status,
49
+ data,
50
+ headers,
51
+ finalUrl: response.url
52
+ };
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ async function httpHead(options) {
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => controller.abort(), options.timeout);
60
+ try {
61
+ const response = await fetch(options.url, {
62
+ method: "HEAD",
63
+ headers: {
64
+ "User-Agent": options.userAgent,
65
+ ...DEFAULT_HEADERS
66
+ },
67
+ signal: controller.signal,
68
+ redirect: "follow"
69
+ });
70
+ const headers = {};
71
+ response.headers.forEach((value, key) => {
72
+ headers[key] = value;
73
+ });
74
+ return {
75
+ status: response.status,
76
+ data: "",
77
+ headers,
78
+ finalUrl: response.url
79
+ };
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+
85
+ // src/utils/url.ts
86
+ function normalizeUrl(input) {
87
+ let url = input.trim();
88
+ if (!/^https?:\/\//i.test(url)) {
89
+ url = `https://${url}`;
90
+ }
91
+ const parsed = new URL(url);
92
+ return parsed.toString().replace(/\/+$/, "");
93
+ }
94
+ function isValidUrl(input) {
95
+ try {
96
+ const url = normalizeUrl(input);
97
+ new URL(url);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ function getDomain(url) {
104
+ try {
105
+ return new URL(url).hostname;
106
+ } catch {
107
+ return url;
108
+ }
109
+ }
110
+
111
+ // src/modules/audits/constants.ts
112
+ var CATEGORY_DISPLAY_NAMES = {
113
+ contentExtractability: "Content Extractability",
114
+ contentStructure: "Content Structure for Reuse",
115
+ answerability: "Answerability",
116
+ entityClarity: "Entity Clarity",
117
+ groundingSignals: "Grounding Signals",
118
+ authorityContext: "Authority Context",
119
+ readabilityForCompression: "Readability for Compression"
120
+ };
121
+
122
+ // src/modules/audits/support/patterns.ts
123
+ var DEFINITION_PATTERNS = [
124
+ /\bis\s+defined\s+as\b/gi,
125
+ /\brefers?\s+to\b/gi,
126
+ /\bmeans?\s+that\b/gi,
127
+ /\bis\s+a\s+type\s+of\b/gi,
128
+ /\bcan\s+be\s+described\s+as\b/gi,
129
+ /\balso\s+known\s+as\b/gi
130
+ ];
131
+ var CITATION_PATTERNS = [
132
+ /\[\d+\]/g,
133
+ /\([\w\s]+,?\s*\d{4}\)/g,
134
+ /according\s+to/gi,
135
+ /research\s+(?:shows|indicates|suggests)/gi,
136
+ /studies?\s+(?:show|indicate|suggest|found)/gi,
137
+ /data\s+from/gi,
138
+ /as\s+reported\s+by/gi
139
+ ];
140
+ var ATTRIBUTION_PATTERNS = [
141
+ /according\s+to/gi,
142
+ /\bsaid\b/gi,
143
+ /\bstated\b/gi,
144
+ /\breported\b/gi,
145
+ /\bcited\s+by\b/gi
146
+ ];
147
+ var NUMERIC_CLAIM_PATTERNS = [
148
+ /\d+(?:\.\d+)?\s*%/g,
149
+ /\d+(?:\.\d+)?\s*(?:million|billion|thousand|trillion)/gi,
150
+ /\$[\d,.]+/g,
151
+ /increased\s+by/gi,
152
+ /decreased\s+by/gi,
153
+ /grew\s+by/gi
154
+ ];
155
+ var STEP_PATTERNS = [
156
+ /step\s+\d+/gi,
157
+ /^\s*\d+\.\s+\w/gm,
158
+ /\bfirst(?:ly)?,?\s/gi,
159
+ /\bsecond(?:ly)?,?\s/gi,
160
+ /\bfinally,?\s/gi,
161
+ /\bhow\s+to\b/gi
162
+ ];
163
+ var SUMMARY_MARKERS = [
164
+ /\bin\s+summary\b/gi,
165
+ /\bin\s+conclusion\b/gi,
166
+ /\bto\s+summarize\b/gi,
167
+ /\bkey\s+takeaways?\b/gi,
168
+ /\bbottom\s+line\b/gi,
169
+ /\btl;?dr\b/gi
170
+ ];
171
+ var QUESTION_PATTERNS = [
172
+ /what\s+is/gi,
173
+ /what\s+are/gi,
174
+ /how\s+to/gi,
175
+ /how\s+do/gi,
176
+ /why\s+is/gi,
177
+ /why\s+do/gi,
178
+ /when\s+to/gi,
179
+ /where\s+to/gi,
180
+ /which\s+is/gi,
181
+ /who\s+is/gi
182
+ ];
183
+ var DIRECT_ANSWER_PATTERNS = [
184
+ /^The\s+\w+\s+is\b/gm,
185
+ /^It\s+is\b/gm,
186
+ /^This\s+is\b/gm,
187
+ /^They\s+are\b/gm,
188
+ /\bsimply\s+put\b/gi,
189
+ /\bin\s+short\b/gi
190
+ ];
191
+ var TRANSITION_WORDS = [
192
+ "however",
193
+ "therefore",
194
+ "moreover",
195
+ "furthermore",
196
+ "consequently",
197
+ "additionally",
198
+ "in contrast",
199
+ "similarly",
200
+ "as a result",
201
+ "for example",
202
+ "for instance",
203
+ "on the other hand",
204
+ "nevertheless",
205
+ "meanwhile",
206
+ "likewise",
207
+ "in addition",
208
+ "specifically",
209
+ "in particular",
210
+ "notably",
211
+ "importantly"
212
+ ];
213
+ var AUTHOR_SELECTORS = [
214
+ '[rel="author"]',
215
+ ".author",
216
+ ".byline",
217
+ '[itemprop="author"]',
218
+ ".post-author",
219
+ ".entry-author",
220
+ 'meta[name="author"]'
221
+ ];
222
+ var DATE_SELECTORS = [
223
+ "time[datetime]",
224
+ '[itemprop="datePublished"]',
225
+ '[itemprop="dateModified"]',
226
+ ".published",
227
+ ".post-date",
228
+ ".entry-date",
229
+ 'meta[property="article:published_time"]',
230
+ 'meta[property="article:modified_time"]'
231
+ ];
232
+ var QUESTION_HEADING_PATTERN = /^(?:what|how|why|when|where|which|who|can|do|does|is|are|should|will)\b/i;
233
+ var QUOTED_ATTRIBUTION_PATTERNS = [
234
+ /"[^"]{10,}"\s*[-\u2013\u2014]\s*[A-Z][a-z]+/g,
235
+ /"[^"]{10,}",?\s+said\s+[A-Z]/g,
236
+ /"[^"]{10,}",?\s+according\s+to\s+[A-Z]/g,
237
+ /according\s+to\s+[A-Z][a-z]+[^,]*,\s*"[^"]{10,}"/g,
238
+ /\u201c[^\u201d]{10,}\u201d\s*[-\u2013\u2014]\s*[A-Z][a-z]+/g,
239
+ /\u201c[^\u201d]{10,}\u201d,?\s+said\s+[A-Z]/g
240
+ ];
241
+ var AI_CRAWLERS = [
242
+ "GPTBot",
243
+ "ChatGPT-User",
244
+ "ClaudeBot",
245
+ "PerplexityBot",
246
+ "Google-Extended"
247
+ ];
248
+ var MODIFIED_DATE_SELECTORS = [
249
+ '[itemprop="dateModified"]',
250
+ 'meta[property="article:modified_time"]'
251
+ ];
252
+ var PUBLISH_DATE_SELECTORS = [
253
+ "time[datetime]",
254
+ '[itemprop="datePublished"]',
255
+ 'meta[property="article:published_time"]'
256
+ ];
257
+
258
+ // src/modules/audits/support/dom.ts
259
+ function detectAnswerCapsules($) {
260
+ let total = 0;
261
+ let withCapsule = 0;
262
+ $("h2").each((_, el) => {
263
+ const headingText = $(el).text().trim();
264
+ const isQuestion = headingText.includes("?") || QUESTION_HEADING_PATTERN.test(headingText);
265
+ if (!isQuestion) return;
266
+ total++;
267
+ const nextP = $(el).nextAll("p").first();
268
+ if (!nextP.length) return;
269
+ const pText = nextP.text().trim();
270
+ const firstSentence = pText.split(/[.!?]/)[0] || "";
271
+ if (firstSentence.length > 0 && firstSentence.length <= 200) {
272
+ withCapsule++;
273
+ }
274
+ });
275
+ return { total, withCapsule };
276
+ }
277
+ function measureSectionLengths($) {
278
+ const headings = $("h1, h2, h3, h4, h5, h6");
279
+ if (headings.length === 0)
280
+ return { sectionCount: 0, avgWordsPerSection: 0, sections: [] };
281
+ const sections = [];
282
+ headings.each((_, el) => {
283
+ let words = 0;
284
+ let sibling = $(el).next();
285
+ while (sibling.length && !sibling.is("h1, h2, h3, h4, h5, h6")) {
286
+ const text = sibling.text().trim();
287
+ words += text.split(/\s+/).filter((w) => w.length > 0).length;
288
+ sibling = sibling.next();
289
+ }
290
+ if (words > 0) sections.push(words);
291
+ });
292
+ const avg = sections.length > 0 ? Math.round(sections.reduce((a, b) => a + b, 0) / sections.length) : 0;
293
+ return { sectionCount: sections.length, avgWordsPerSection: avg, sections };
294
+ }
295
+ function parseJsonLdObjects($) {
296
+ const objects = [];
297
+ $('script[type="application/ld+json"]').each((_, el) => {
298
+ try {
299
+ const data = JSON.parse($(el).html() || "{}");
300
+ if (Array.isArray(data)) objects.push(...data);
301
+ else objects.push(data);
302
+ } catch {
303
+ }
304
+ });
305
+ return objects;
306
+ }
307
+
308
+ // src/modules/audits/support/nlp.ts
309
+ import compromise from "compromise";
310
+
311
+ // src/utils/strings.ts
312
+ function countWords(text) {
313
+ return text.split(/\s+/).filter((w) => w.length > 0).length;
314
+ }
315
+ function countSentences(text) {
316
+ return text.split(/[.!?]+/).filter((s) => s.trim().length > 5).length;
317
+ }
318
+ function countSyllables(word) {
319
+ word = word.toLowerCase().replace(/[^a-z]/g, "");
320
+ if (word.length <= 3) return 1;
321
+ word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "");
322
+ word = word.replace(/^y/, "");
323
+ const matches = word.match(/[aeiouy]{1,2}/g);
324
+ return matches ? Math.max(matches.length, 1) : 1;
325
+ }
326
+
327
+ // src/modules/audits/support/nlp.ts
328
+ function extractEntities(text) {
329
+ const doc = compromise(text);
330
+ const people = [...new Set(doc.people().out("array"))].slice(
331
+ 0,
332
+ 10
333
+ );
334
+ const organizations = [
335
+ ...new Set(doc.organizations().out("array"))
336
+ ].slice(0, 10);
337
+ const places = [...new Set(doc.places().out("array"))].slice(
338
+ 0,
339
+ 10
340
+ );
341
+ const topics = [...new Set(doc.topics().out("array"))].slice(
342
+ 0,
343
+ 15
344
+ );
345
+ return { people, organizations, places, topics };
346
+ }
347
+ function computeFleschReadingEase(text) {
348
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
349
+ const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 5);
350
+ const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
351
+ if (words.length === 0 || sentences.length === 0) return 0;
352
+ const avgSentenceLength2 = words.length / sentences.length;
353
+ const avgSyllablesPerWord = totalSyllables / words.length;
354
+ return 206.835 - 1.015 * avgSentenceLength2 - 84.6 * avgSyllablesPerWord;
355
+ }
356
+ function countComplexWords(text) {
357
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
358
+ return words.filter((w) => countSyllables(w) >= 4).length;
359
+ }
360
+ function countPatternMatches(text, patterns) {
361
+ let count = 0;
362
+ for (const pattern of patterns) {
363
+ const re = new RegExp(pattern.source, pattern.flags);
364
+ const matches = text.match(re);
365
+ if (matches) count += matches.length;
366
+ }
367
+ return count;
368
+ }
369
+ function countTransitionWords(text, words) {
370
+ const lower = text.toLowerCase();
371
+ return words.filter((w) => lower.includes(w)).length;
372
+ }
373
+ function avgSentenceLength(text) {
374
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
375
+ const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 5);
376
+ if (sentences.length === 0) return 0;
377
+ return Math.round(words.length / sentences.length);
378
+ }
379
+
380
+ // src/modules/audits/support/scoring.ts
381
+ function thresholdScore(value, brackets) {
382
+ for (const [threshold, score] of brackets) {
383
+ if (value >= threshold) return score;
384
+ }
385
+ return 0;
386
+ }
387
+ function statusFromScore(score, maxScore) {
388
+ const pct = maxScore > 0 ? score / maxScore : 0;
389
+ if (pct >= 0.7) return "good";
390
+ if (pct >= 0.3) return "needs_improvement";
391
+ return "critical";
392
+ }
393
+ function makeFactor(name, score, maxScore, value, statusOverride) {
394
+ return {
395
+ name,
396
+ score: Math.round(Math.min(score, maxScore)),
397
+ maxScore,
398
+ value,
399
+ status: statusOverride ?? statusFromScore(score, maxScore)
400
+ };
401
+ }
402
+ function sumFactors(factors) {
403
+ return factors.reduce((sum, f) => sum + f.score, 0);
404
+ }
405
+ function maxFactors(factors) {
406
+ return factors.reduce((sum, f) => sum + f.maxScore, 0);
407
+ }
408
+
409
+ // src/modules/audits/categories/answerability.ts
410
+ function auditAnswerability(page) {
411
+ const text = page.cleanText;
412
+ const $ = page.$;
413
+ const factors = [];
414
+ const defCount = countPatternMatches(text, DEFINITION_PATTERNS);
415
+ const defScore = thresholdScore(defCount, [
416
+ [6, 10],
417
+ [3, 7],
418
+ [1, 4],
419
+ [0, 0]
420
+ ]);
421
+ factors.push(
422
+ makeFactor(
423
+ "Definition Patterns",
424
+ defScore,
425
+ 10,
426
+ `${defCount} definition patterns`
427
+ )
428
+ );
429
+ const directCount = countPatternMatches(text, DIRECT_ANSWER_PATTERNS);
430
+ const directScore = thresholdScore(directCount, [
431
+ [5, 11],
432
+ [2, 8],
433
+ [1, 4],
434
+ [0, 0]
435
+ ]);
436
+ factors.push(
437
+ makeFactor(
438
+ "Direct Answer Statements",
439
+ directScore,
440
+ 11,
441
+ `${directCount} direct statements`
442
+ )
443
+ );
444
+ const capsules = detectAnswerCapsules(page.$);
445
+ const capsuleRatio = capsules.total > 0 ? capsules.withCapsule / capsules.total : 0;
446
+ const capsuleScore = capsules.total === 0 ? 0 : capsuleRatio >= 0.7 ? 13 : capsuleRatio >= 0.4 ? 9 : capsuleRatio > 0 ? 5 : 2;
447
+ factors.push(
448
+ makeFactor(
449
+ "Answer Capsules",
450
+ capsuleScore,
451
+ 13,
452
+ capsules.total > 0 ? `${capsules.withCapsule}/${capsules.total} question headings have answer capsules` : "No question-framed H2s found",
453
+ capsules.total === 0 ? "neutral" : void 0
454
+ )
455
+ );
456
+ const stepCount = countPatternMatches(text, STEP_PATTERNS);
457
+ const hasOl = $("ol").length > 0;
458
+ const stepTotal = stepCount + (hasOl ? 2 : 0);
459
+ const stepScore = thresholdScore(stepTotal, [
460
+ [5, 10],
461
+ [2, 7],
462
+ [1, 3],
463
+ [0, 0]
464
+ ]);
465
+ factors.push(
466
+ makeFactor(
467
+ "Step-by-Step Content",
468
+ stepScore,
469
+ 10,
470
+ `${stepCount} step indicators${hasOl ? ", ordered lists found" : ""}`
471
+ )
472
+ );
473
+ const questionMatches = text.match(/[^.!?]*\?/g) || [];
474
+ const queryMatches = countPatternMatches(text, QUESTION_PATTERNS);
475
+ const qaScore = thresholdScore(questionMatches.length + queryMatches, [
476
+ [10, 11],
477
+ [5, 8],
478
+ [2, 5],
479
+ [1, 2],
480
+ [0, 0]
481
+ ]);
482
+ factors.push(
483
+ makeFactor(
484
+ "Q/A Patterns",
485
+ qaScore,
486
+ 11,
487
+ `${questionMatches.length} questions, ${queryMatches} query patterns`
488
+ )
489
+ );
490
+ const summaryCount = countPatternMatches(text, SUMMARY_MARKERS);
491
+ const summaryScore = summaryCount >= 2 ? 9 : summaryCount > 0 ? 5 : 0;
492
+ factors.push(
493
+ makeFactor(
494
+ "Summary/Conclusion",
495
+ summaryScore,
496
+ 9,
497
+ summaryCount > 0 ? `${summaryCount} summary markers` : "No summary markers"
498
+ )
499
+ );
500
+ return {
501
+ category: {
502
+ name: CATEGORY_DISPLAY_NAMES.answerability,
503
+ key: "answerability",
504
+ score: sumFactors(factors),
505
+ maxScore: maxFactors(factors),
506
+ factors
507
+ },
508
+ rawData: {
509
+ answerCapsules: capsules,
510
+ questionsFound: questionMatches.slice(0, 5)
511
+ }
512
+ };
513
+ }
514
+
515
+ // src/modules/audits/support/entity.ts
516
+ function resolveEntityName($) {
517
+ const ogSiteName = $('meta[property="og:site_name"]').attr("content")?.trim();
518
+ if (ogSiteName) return ogSiteName;
519
+ const jsonLdScripts = $('script[type="application/ld+json"]');
520
+ let orgName = null;
521
+ jsonLdScripts.each((_, el) => {
522
+ try {
523
+ const data = JSON.parse($(el).html() || "{}");
524
+ if (data["@type"] === "Organization" && data.name) {
525
+ orgName = String(data.name).trim();
526
+ }
527
+ if (data.publisher?.name) {
528
+ orgName = orgName || String(data.publisher.name).trim();
529
+ }
530
+ } catch {
531
+ }
532
+ });
533
+ return orgName || null;
534
+ }
535
+ function measureEntityConsistency($, pageTitle, entityName) {
536
+ if (!entityName) return { score: 0, surfacesFound: 0, surfacesChecked: 0 };
537
+ const nameLower = entityName.toLowerCase();
538
+ const surfacesChecked = 4;
539
+ let surfacesFound = 0;
540
+ if (pageTitle.toLowerCase().includes(nameLower)) surfacesFound++;
541
+ const ogTitle = $('meta[property="og:title"]').attr("content") || "";
542
+ if (ogTitle.toLowerCase().includes(nameLower)) surfacesFound++;
543
+ const footerText = $("footer").text().toLowerCase();
544
+ if (footerText.includes(nameLower)) surfacesFound++;
545
+ const copyrightText = $('[class*="copyright"], [class*="legal"]').text().toLowerCase();
546
+ const headerText = $("header").text().toLowerCase();
547
+ if (copyrightText.includes(nameLower) || headerText.includes(nameLower))
548
+ surfacesFound++;
549
+ const score = surfacesFound >= 4 ? 10 : surfacesFound >= 3 ? 7 : surfacesFound >= 2 ? 4 : surfacesFound >= 1 ? 2 : 0;
550
+ return { score, surfacesFound, surfacesChecked };
551
+ }
552
+
553
+ // src/modules/audits/support/freshness.ts
554
+ function evaluateFreshness($) {
555
+ let modifiedDate = null;
556
+ let publishDate = null;
557
+ for (const sel of MODIFIED_DATE_SELECTORS) {
558
+ const el = $(sel).first();
559
+ if (el.length) {
560
+ modifiedDate = el.attr("datetime") || el.attr("content") || el.text().trim();
561
+ break;
562
+ }
563
+ }
564
+ for (const sel of PUBLISH_DATE_SELECTORS) {
565
+ const el = $(sel).first();
566
+ if (el.length) {
567
+ publishDate = el.attr("datetime") || el.attr("content") || el.text().trim();
568
+ break;
569
+ }
570
+ }
571
+ const mostRecent = modifiedDate || publishDate;
572
+ let ageInMonths = null;
573
+ if (mostRecent) {
574
+ const parsed = new Date(mostRecent);
575
+ if (!isNaN(parsed.getTime())) {
576
+ const now = /* @__PURE__ */ new Date();
577
+ ageInMonths = (now.getFullYear() - parsed.getFullYear()) * 12 + (now.getMonth() - parsed.getMonth());
578
+ }
579
+ }
580
+ return {
581
+ publishDate,
582
+ modifiedDate,
583
+ ageInMonths,
584
+ hasModifiedDate: !!modifiedDate
585
+ };
586
+ }
587
+
588
+ // src/modules/audits/support/schema-analysis.ts
589
+ var SCHEMA_REQUIRED_PROPERTIES = {
590
+ Article: ["headline", "author", "datePublished"],
591
+ NewsArticle: ["headline", "author", "datePublished"],
592
+ BlogPosting: ["headline", "author", "datePublished"],
593
+ FAQPage: ["mainEntity"],
594
+ HowTo: ["name", "step"],
595
+ Organization: ["name", "url"],
596
+ LocalBusiness: ["name", "address"],
597
+ Product: ["name"],
598
+ WebPage: ["name"]
599
+ };
600
+ function evaluateSchemaCompleteness(schemas) {
601
+ const details = [];
602
+ for (const schema of schemas) {
603
+ const type = String(schema["@type"] || "");
604
+ const requiredProps = SCHEMA_REQUIRED_PROPERTIES[type];
605
+ if (!requiredProps) continue;
606
+ const present = requiredProps.filter((prop) => schema[prop] != null);
607
+ const missing = requiredProps.filter((prop) => schema[prop] == null);
608
+ details.push({ type, present, missing });
609
+ }
610
+ const avgCompleteness = details.length > 0 ? details.reduce(
611
+ (sum, d) => sum + d.present.length / (d.present.length + d.missing.length),
612
+ 0
613
+ ) / details.length : 0;
614
+ return { totalTypes: details.length, avgCompleteness, details };
615
+ }
616
+
617
+ // src/modules/audits/categories/authority-context.ts
618
+ function auditAuthorityContext(page) {
619
+ const $ = page.$;
620
+ const factors = [];
621
+ const rawData = {};
622
+ let authorFound = false;
623
+ let authorName = "";
624
+ for (const selector of AUTHOR_SELECTORS) {
625
+ const elem = $(selector).first();
626
+ if (elem.length) {
627
+ authorFound = true;
628
+ authorName = elem.text().trim() || elem.attr("content") || "Found";
629
+ break;
630
+ }
631
+ }
632
+ factors.push(
633
+ makeFactor(
634
+ "Author Attribution",
635
+ authorFound ? 10 : 0,
636
+ 10,
637
+ authorFound ? authorName : "Not found"
638
+ )
639
+ );
640
+ const hasOrgSchema = page.html.includes('"@type":"Organization"') || page.html.includes('"@type": "Organization"');
641
+ const ogSiteName = $('meta[property="og:site_name"]').attr("content") || "";
642
+ const orgFound = hasOrgSchema || ogSiteName.length > 0;
643
+ factors.push(
644
+ makeFactor(
645
+ "Organization Identity",
646
+ orgFound ? 10 : 0,
647
+ 10,
648
+ orgFound ? ogSiteName || "Schema found" : "Not found"
649
+ )
650
+ );
651
+ const aboutLink = $('a[href*="about"], a[href*="team"], a[href*="company"]').length > 0;
652
+ const contactLink = $('a[href*="contact"]').length > 0;
653
+ const contactScore = aboutLink && contactLink ? 10 : aboutLink || contactLink ? 5 : 0;
654
+ factors.push(
655
+ makeFactor(
656
+ "Contact/About Links",
657
+ contactScore,
658
+ 10,
659
+ `${aboutLink ? "About" : ""}${aboutLink && contactLink ? " + " : ""}${contactLink ? "Contact" : ""}${!aboutLink && !contactLink ? "Not found" : ""}`
660
+ )
661
+ );
662
+ let dateFound = false;
663
+ let dateValue = "";
664
+ for (const selector of DATE_SELECTORS) {
665
+ const elem = $(selector).first();
666
+ if (elem.length) {
667
+ dateFound = true;
668
+ dateValue = elem.attr("datetime") || elem.attr("content") || elem.text().trim();
669
+ break;
670
+ }
671
+ }
672
+ factors.push(
673
+ makeFactor(
674
+ "Publication Date",
675
+ dateFound ? 8 : 0,
676
+ 8,
677
+ dateFound ? dateValue : "Not found"
678
+ )
679
+ );
680
+ const freshness = evaluateFreshness(page.$);
681
+ let freshScore = 0;
682
+ if (freshness.ageInMonths !== null) {
683
+ if (freshness.ageInMonths <= 6) freshScore = 12;
684
+ else if (freshness.ageInMonths <= 12) freshScore = 9;
685
+ else if (freshness.ageInMonths <= 24) freshScore = 5;
686
+ else freshScore = 2;
687
+ if (freshness.hasModifiedDate && freshScore < 12)
688
+ freshScore = Math.min(freshScore + 2, 12);
689
+ }
690
+ factors.push(
691
+ makeFactor(
692
+ "Content Freshness",
693
+ freshScore,
694
+ 12,
695
+ freshness.ageInMonths !== null ? `${freshness.ageInMonths} months old${freshness.hasModifiedDate ? ", modified date present" : ""}` : "No parseable date found"
696
+ )
697
+ );
698
+ rawData.freshness = freshness;
699
+ const jsonLdScripts = $('script[type="application/ld+json"]');
700
+ const structuredDataTypes = [];
701
+ jsonLdScripts.each((_, el) => {
702
+ try {
703
+ const data = JSON.parse($(el).html() || "{}");
704
+ if (data["@type"]) structuredDataTypes.push(data["@type"]);
705
+ } catch {
706
+ }
707
+ });
708
+ const ogTags = ["og:title", "og:description", "og:image", "og:type"];
709
+ const foundOgTags = ogTags.filter(
710
+ (tag) => $(`meta[property="${tag}"]`).length > 0
711
+ );
712
+ const canonical = $('link[rel="canonical"]').attr("href");
713
+ let structuredScore = 0;
714
+ if (structuredDataTypes.length > 0) structuredScore += 4;
715
+ if (foundOgTags.length >= 3) structuredScore += 4;
716
+ else if (foundOgTags.length > 0) structuredScore += 2;
717
+ if (canonical) structuredScore += 4;
718
+ rawData.structuredDataTypes = structuredDataTypes;
719
+ factors.push(
720
+ makeFactor(
721
+ "Structured Data",
722
+ structuredScore,
723
+ 12,
724
+ `${structuredDataTypes.length > 0 ? structuredDataTypes.join(", ") : "No JSON-LD"}, ${foundOgTags.length}/4 OG tags${canonical ? ", canonical" : ""}`
725
+ )
726
+ );
727
+ const schemaObjects = parseJsonLdObjects(page.$);
728
+ const completeness = evaluateSchemaCompleteness(schemaObjects);
729
+ const schemaCompleteScore = completeness.totalTypes === 0 ? 0 : completeness.avgCompleteness >= 0.8 ? 10 : completeness.avgCompleteness >= 0.5 ? 7 : completeness.avgCompleteness > 0 ? 4 : 0;
730
+ factors.push(
731
+ makeFactor(
732
+ "Schema Completeness",
733
+ schemaCompleteScore,
734
+ 10,
735
+ completeness.totalTypes > 0 ? `${completeness.totalTypes} schema types, ${Math.round(completeness.avgCompleteness * 100)}% complete` : "No recognized JSON-LD schemas found",
736
+ completeness.totalTypes === 0 ? "neutral" : void 0
737
+ )
738
+ );
739
+ rawData.schemaCompleteness = completeness;
740
+ const entityName = resolveEntityName(page.$);
741
+ const consistency = measureEntityConsistency(page.$, page.title, entityName);
742
+ factors.push(
743
+ makeFactor(
744
+ "Entity Consistency",
745
+ consistency.score,
746
+ 10,
747
+ entityName ? `"${entityName}" found in ${consistency.surfacesFound}/${consistency.surfacesChecked} surfaces` : "No identifiable entity name",
748
+ !entityName ? "neutral" : void 0
749
+ )
750
+ );
751
+ rawData.entityConsistency = {
752
+ entityName: entityName || null,
753
+ surfacesFound: consistency.surfacesFound,
754
+ surfacesChecked: consistency.surfacesChecked
755
+ };
756
+ return {
757
+ category: {
758
+ name: CATEGORY_DISPLAY_NAMES.authorityContext,
759
+ key: "authorityContext",
760
+ score: sumFactors(factors),
761
+ maxScore: maxFactors(factors),
762
+ factors
763
+ },
764
+ rawData
765
+ };
766
+ }
767
+
768
+ // src/modules/audits/support/robots.ts
769
+ function checkCrawlerAccess(robotsTxt) {
770
+ if (!robotsTxt)
771
+ return { allowed: [], blocked: [], unknown: [...AI_CRAWLERS] };
772
+ const lines = robotsTxt.split("\n").map((l) => l.trim());
773
+ const allowed = [];
774
+ const blocked = [];
775
+ const unknown = [];
776
+ for (const crawler of AI_CRAWLERS) {
777
+ const crawlerLower = crawler.toLowerCase();
778
+ let currentAgent = "";
779
+ let isBlocked = false;
780
+ let found = false;
781
+ for (const line of lines) {
782
+ const lower = line.toLowerCase();
783
+ if (lower.startsWith("user-agent:")) {
784
+ currentAgent = lower.split(":")[1]?.trim() || "";
785
+ } else if (currentAgent === crawlerLower || currentAgent === "*") {
786
+ if (lower.startsWith("disallow:")) {
787
+ const path = lower.split(":")[1]?.trim();
788
+ if (path === "/") {
789
+ if (currentAgent === crawlerLower) {
790
+ isBlocked = true;
791
+ found = true;
792
+ } else if (currentAgent === "*" && !found) {
793
+ isBlocked = true;
794
+ }
795
+ }
796
+ } else if (lower.startsWith("allow:")) {
797
+ if (currentAgent === crawlerLower) {
798
+ found = true;
799
+ isBlocked = false;
800
+ }
801
+ }
802
+ }
803
+ }
804
+ if (found) {
805
+ if (isBlocked) blocked.push(crawler);
806
+ else allowed.push(crawler);
807
+ } else if (isBlocked) {
808
+ blocked.push(crawler);
809
+ } else {
810
+ unknown.push(crawler);
811
+ }
812
+ }
813
+ return { allowed, blocked, unknown };
814
+ }
815
+
816
+ // src/modules/audits/categories/content-extractability.ts
817
+ function auditContentExtractability(page, fetchResult, domainSignals) {
818
+ const factors = [];
819
+ const rawData = {};
820
+ const fetchScore = fetchResult.statusCode === 200 ? 12 : fetchResult.statusCode < 400 ? 8 : 0;
821
+ factors.push(
822
+ makeFactor(
823
+ "Fetch Success",
824
+ fetchScore,
825
+ 12,
826
+ `HTTP ${fetchResult.statusCode} in ${fetchResult.fetchTimeMs}ms`
827
+ )
828
+ );
829
+ const extractRatio = page.stats.rawByteLength > 0 ? page.stats.cleanTextLength / page.stats.rawByteLength : 0;
830
+ const extractScore = extractRatio >= 0.05 && extractRatio <= 0.15 ? 12 : extractRatio >= 0.01 ? 8 : extractRatio > 0.15 ? 10 : 2;
831
+ factors.push(
832
+ makeFactor(
833
+ "Text Extraction Quality",
834
+ extractScore,
835
+ 12,
836
+ `${(extractRatio * 100).toFixed(1)}% content ratio`
837
+ )
838
+ );
839
+ const bpRatio = page.stats.boilerplateRatio;
840
+ const bpScore = thresholdScore(1 - bpRatio, [
841
+ [0.7, 12],
842
+ [0.5, 9],
843
+ [0.3, 6],
844
+ [0, 2]
845
+ ]);
846
+ factors.push(
847
+ makeFactor(
848
+ "Boilerplate Ratio",
849
+ bpScore,
850
+ 12,
851
+ `${(bpRatio * 100).toFixed(0)}% boilerplate`
852
+ )
853
+ );
854
+ const wc = page.stats.wordCount;
855
+ const wcScore = wc >= 300 && wc <= 3e3 ? 12 : wc >= 100 ? 8 : wc > 3e3 ? 10 : 2;
856
+ factors.push(makeFactor("Word Count Adequacy", wcScore, 12, `${wc} words`));
857
+ if (domainSignals) {
858
+ const access2 = checkCrawlerAccess(domainSignals.robotsTxt);
859
+ const blockedCount = access2.blocked.length;
860
+ const crawlerScore = blockedCount === 0 ? 10 : blockedCount <= 2 ? 6 : blockedCount <= 4 ? 3 : 0;
861
+ factors.push(
862
+ makeFactor(
863
+ "AI Crawler Access",
864
+ crawlerScore,
865
+ 10,
866
+ blockedCount === 0 ? `All major AI crawlers allowed` : `${access2.blocked.join(", ")} blocked in robots.txt`
867
+ )
868
+ );
869
+ rawData.crawlerAccess = access2;
870
+ const hasLlms = domainSignals.llmsTxtExists;
871
+ const hasLlmsFull = domainSignals.llmsFullTxtExists;
872
+ const llmsScore = hasLlms && hasLlmsFull ? 6 : hasLlms || hasLlmsFull ? 4 : 0;
873
+ factors.push(
874
+ makeFactor(
875
+ "LLMs.txt Presence",
876
+ llmsScore,
877
+ 6,
878
+ hasLlms && hasLlmsFull ? "llms.txt + llms-full.txt found" : hasLlms ? "llms.txt found" : hasLlmsFull ? "llms-full.txt found" : "Not found",
879
+ !hasLlms && !hasLlmsFull ? "neutral" : void 0
880
+ )
881
+ );
882
+ }
883
+ const imageCount = page.stats.imageCount;
884
+ const imagesWithAlt = page.stats.imagesWithAlt;
885
+ const figcaptionCount = page.$("figure figcaption").length;
886
+ const altRatio = imageCount > 0 ? imagesWithAlt / imageCount : 0;
887
+ let imageAccessibilityScore = 0;
888
+ if (imageCount > 0) {
889
+ if (altRatio >= 0.9) imageAccessibilityScore += 5;
890
+ else if (altRatio >= 0.5) imageAccessibilityScore += 3;
891
+ else imageAccessibilityScore += 1;
892
+ if (figcaptionCount > 0) imageAccessibilityScore += 3;
893
+ }
894
+ factors.push(
895
+ makeFactor(
896
+ "Image Accessibility",
897
+ imageAccessibilityScore,
898
+ 8,
899
+ imageCount > 0 ? `${imagesWithAlt}/${imageCount} images have alt text${figcaptionCount > 0 ? `, ${figcaptionCount} figcaptions` : ""}` : "No images found",
900
+ imageCount === 0 ? "neutral" : void 0
901
+ )
902
+ );
903
+ rawData.imageAccessibility = { imageCount, imagesWithAlt, figcaptionCount };
904
+ return {
905
+ category: {
906
+ name: CATEGORY_DISPLAY_NAMES.contentExtractability,
907
+ key: "contentExtractability",
908
+ score: sumFactors(factors),
909
+ maxScore: maxFactors(factors),
910
+ factors
911
+ },
912
+ rawData
913
+ };
914
+ }
915
+
916
+ // src/modules/audits/categories/content-structure.ts
917
+ function auditContentStructure(page) {
918
+ const $ = page.$;
919
+ const factors = [];
920
+ const h1 = page.stats.h1Count;
921
+ const h2 = page.stats.h2Count;
922
+ const h3 = page.stats.h3Count;
923
+ let headingScore = 0;
924
+ if (h1 === 1) headingScore += 4;
925
+ else if (h1 > 0) headingScore += 2;
926
+ if (h2 >= 2) headingScore += 4;
927
+ else if (h2 > 0) headingScore += 2;
928
+ if (h3 > 0) headingScore += 3;
929
+ factors.push(
930
+ makeFactor(
931
+ "Heading Hierarchy",
932
+ headingScore,
933
+ 11,
934
+ `${h1} H1, ${h2} H2, ${h3} H3`
935
+ )
936
+ );
937
+ const listItems = page.stats.listItemCount;
938
+ const listScore = thresholdScore(listItems, [
939
+ [10, 11],
940
+ [5, 8],
941
+ [1, 4],
942
+ [0, 0]
943
+ ]);
944
+ factors.push(
945
+ makeFactor("Lists Presence", listScore, 11, `${listItems} list items`)
946
+ );
947
+ const tables = page.stats.tableCount;
948
+ const tableScore = tables >= 2 ? 8 : tables >= 1 ? 5 : 0;
949
+ factors.push(
950
+ makeFactor(
951
+ "Tables Presence",
952
+ tableScore,
953
+ 8,
954
+ `${tables} table(s)`,
955
+ tables === 0 ? "neutral" : void 0
956
+ )
957
+ );
958
+ const pCount = page.stats.paragraphCount;
959
+ const avgParagraphWords = pCount > 0 ? Math.round(page.stats.wordCount / pCount) : 0;
960
+ const paragraphScore = avgParagraphWords >= 30 && avgParagraphWords <= 150 ? 11 : avgParagraphWords > 0 && avgParagraphWords < 200 ? 7 : 2;
961
+ factors.push(
962
+ makeFactor(
963
+ "Paragraph Structure",
964
+ paragraphScore,
965
+ 11,
966
+ `${pCount} paragraphs, avg ${avgParagraphWords} words`
967
+ )
968
+ );
969
+ const hasBold = $("strong, b").length > 0;
970
+ const headingRatio = pCount > 0 ? page.stats.headingCount / pCount : 0;
971
+ let scanScore = 0;
972
+ if (hasBold) scanScore += 4;
973
+ if (avgParagraphWords <= 150) scanScore += 4;
974
+ if (headingRatio >= 0.1) scanScore += 3;
975
+ factors.push(
976
+ makeFactor(
977
+ "Scannability",
978
+ scanScore,
979
+ 11,
980
+ `${hasBold ? "Bold text found" : "No bold text"}, ${headingRatio.toFixed(2)} heading ratio`
981
+ )
982
+ );
983
+ const sectionData = measureSectionLengths(page.$);
984
+ let sectionScore = 0;
985
+ if (sectionData.sectionCount === 0) {
986
+ sectionScore = 0;
987
+ } else if (sectionData.avgWordsPerSection >= 120 && sectionData.avgWordsPerSection <= 180) {
988
+ sectionScore = 12;
989
+ } else if (sectionData.avgWordsPerSection >= 80 && sectionData.avgWordsPerSection <= 250) {
990
+ sectionScore = 8;
991
+ } else if (sectionData.avgWordsPerSection > 0) {
992
+ sectionScore = 4;
993
+ }
994
+ factors.push(
995
+ makeFactor(
996
+ "Section Length",
997
+ sectionScore,
998
+ 12,
999
+ sectionData.sectionCount > 0 ? `${sectionData.sectionCount} sections, avg ${sectionData.avgWordsPerSection} words` : "No headed sections found",
1000
+ sectionData.sectionCount === 0 ? "neutral" : void 0
1001
+ )
1002
+ );
1003
+ return {
1004
+ category: {
1005
+ name: CATEGORY_DISPLAY_NAMES.contentStructure,
1006
+ key: "contentStructure",
1007
+ score: sumFactors(factors),
1008
+ maxScore: maxFactors(factors),
1009
+ factors
1010
+ },
1011
+ rawData: {
1012
+ sectionLengths: sectionData
1013
+ }
1014
+ };
1015
+ }
1016
+
1017
+ // src/modules/audits/categories/entity-clarity.ts
1018
+ function auditEntityClarity(page) {
1019
+ const text = page.cleanText;
1020
+ const factors = [];
1021
+ const entities = extractEntities(text);
1022
+ const totalEntities = entities.people.length + entities.organizations.length + entities.places.length + entities.topics.length;
1023
+ const richScore = thresholdScore(totalEntities, [
1024
+ [9, 20],
1025
+ [4, 14],
1026
+ [1, 7],
1027
+ [0, 0]
1028
+ ]);
1029
+ factors.push(
1030
+ makeFactor(
1031
+ "Entity Richness",
1032
+ richScore,
1033
+ 20,
1034
+ `${totalEntities} entities (${entities.people.length} people, ${entities.organizations.length} orgs, ${entities.places.length} places)`,
1035
+ totalEntities === 0 ? "neutral" : void 0
1036
+ )
1037
+ );
1038
+ const titleWords = page.title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
1039
+ const h1Text = page.$("h1").first().text().toLowerCase();
1040
+ const h1Words = h1Text.split(/\s+/).filter((w) => w.length > 3);
1041
+ const keyWords = [.../* @__PURE__ */ new Set([...titleWords, ...h1Words])];
1042
+ const topicLower = entities.topics.map((t) => t.toLowerCase());
1043
+ let topicOverlap = 0;
1044
+ for (const kw of keyWords) {
1045
+ if (topicLower.some((t) => t.includes(kw)) || text.toLowerCase().split(kw).length > 3) {
1046
+ topicOverlap++;
1047
+ }
1048
+ }
1049
+ const consistencyRatio = keyWords.length > 0 ? topicOverlap / keyWords.length : 0;
1050
+ const consistencyScore = consistencyRatio >= 0.5 ? 25 : consistencyRatio > 0 ? 15 : 5;
1051
+ factors.push(
1052
+ makeFactor(
1053
+ "Topic Consistency",
1054
+ consistencyScore,
1055
+ 25,
1056
+ `${topicOverlap}/${keyWords.length} title keywords align with content topics`
1057
+ )
1058
+ );
1059
+ const wordCount = countWords(text);
1060
+ const densityPer100 = wordCount > 0 ? totalEntities / wordCount * 100 : 0;
1061
+ const densityScore = densityPer100 >= 2 && densityPer100 <= 8 ? 15 : densityPer100 >= 1 ? 10 : densityPer100 > 8 ? 10 : 3;
1062
+ factors.push(
1063
+ makeFactor(
1064
+ "Entity Density",
1065
+ densityScore,
1066
+ 15,
1067
+ `${densityPer100.toFixed(1)} entities per 100 words`
1068
+ )
1069
+ );
1070
+ return {
1071
+ category: {
1072
+ name: CATEGORY_DISPLAY_NAMES.entityClarity,
1073
+ key: "entityClarity",
1074
+ score: sumFactors(factors),
1075
+ maxScore: maxFactors(factors),
1076
+ factors
1077
+ },
1078
+ rawData: {
1079
+ entities
1080
+ }
1081
+ };
1082
+ }
1083
+
1084
+ // src/modules/audits/categories/grounding-signals.ts
1085
+ function auditGroundingSignals(page) {
1086
+ const $ = page.$;
1087
+ const text = page.cleanText;
1088
+ const factors = [];
1089
+ const externalLinks = page.externalLinks;
1090
+ const extScore = thresholdScore(externalLinks.length, [
1091
+ [6, 13],
1092
+ [3, 10],
1093
+ [1, 6],
1094
+ [0, 0]
1095
+ ]);
1096
+ factors.push(
1097
+ makeFactor(
1098
+ "External References",
1099
+ extScore,
1100
+ 13,
1101
+ `${externalLinks.length} external links`
1102
+ )
1103
+ );
1104
+ const citationCount = countPatternMatches(text, CITATION_PATTERNS);
1105
+ const blockquotes = $("blockquote, cite, q").length;
1106
+ const totalCitations = citationCount + blockquotes;
1107
+ const citScore = thresholdScore(totalCitations, [
1108
+ [6, 13],
1109
+ [3, 9],
1110
+ [1, 5],
1111
+ [0, 0]
1112
+ ]);
1113
+ factors.push(
1114
+ makeFactor(
1115
+ "Citation Patterns",
1116
+ citScore,
1117
+ 13,
1118
+ `${citationCount} citation indicators, ${blockquotes} quote elements`
1119
+ )
1120
+ );
1121
+ const numericCount = countPatternMatches(text, NUMERIC_CLAIM_PATTERNS);
1122
+ const numScore = thresholdScore(numericCount, [
1123
+ [9, 13],
1124
+ [4, 9],
1125
+ [1, 5],
1126
+ [0, 0]
1127
+ ]);
1128
+ factors.push(
1129
+ makeFactor(
1130
+ "Numeric Claims",
1131
+ numScore,
1132
+ 13,
1133
+ `${numericCount} statistical references`
1134
+ )
1135
+ );
1136
+ const attrCount = countPatternMatches(text, ATTRIBUTION_PATTERNS);
1137
+ const attrScore = thresholdScore(attrCount, [
1138
+ [5, 11],
1139
+ [2, 8],
1140
+ [1, 4],
1141
+ [0, 0]
1142
+ ]);
1143
+ factors.push(
1144
+ makeFactor(
1145
+ "Attribution Indicators",
1146
+ attrScore,
1147
+ 11,
1148
+ `${attrCount} attribution patterns`
1149
+ )
1150
+ );
1151
+ const quotedAttrPatterns = countPatternMatches(
1152
+ text,
1153
+ QUOTED_ATTRIBUTION_PATTERNS
1154
+ );
1155
+ const blockquotesWithCite = $("blockquote").filter(
1156
+ (_, el) => $(el).find("cite, footer, figcaption").length > 0
1157
+ ).length;
1158
+ const totalQuotedAttr = quotedAttrPatterns + blockquotesWithCite;
1159
+ const quotedAttrScore = thresholdScore(totalQuotedAttr, [
1160
+ [4, 10],
1161
+ [2, 7],
1162
+ [1, 4],
1163
+ [0, 0]
1164
+ ]);
1165
+ factors.push(
1166
+ makeFactor(
1167
+ "Quoted Attribution",
1168
+ quotedAttrScore,
1169
+ 10,
1170
+ `${totalQuotedAttr} attributed quotes`,
1171
+ totalQuotedAttr === 0 ? "neutral" : void 0
1172
+ )
1173
+ );
1174
+ return {
1175
+ category: {
1176
+ name: CATEGORY_DISPLAY_NAMES.groundingSignals,
1177
+ key: "groundingSignals",
1178
+ score: sumFactors(factors),
1179
+ maxScore: maxFactors(factors),
1180
+ factors
1181
+ },
1182
+ rawData: {
1183
+ externalLinks: externalLinks.slice(0, 10)
1184
+ }
1185
+ };
1186
+ }
1187
+
1188
+ // src/modules/audits/categories/readability.ts
1189
+ function auditReadabilityForCompression(page) {
1190
+ const text = page.cleanText;
1191
+ const factors = [];
1192
+ const avgSentLen = avgSentenceLength(text);
1193
+ const sentScore = avgSentLen >= 12 && avgSentLen <= 22 ? 15 : avgSentLen >= 8 && avgSentLen < 30 ? 10 : avgSentLen > 0 ? 5 : 0;
1194
+ factors.push(
1195
+ makeFactor(
1196
+ "Sentence Length",
1197
+ sentScore,
1198
+ 15,
1199
+ `Avg ${avgSentLen} words/sentence`
1200
+ )
1201
+ );
1202
+ const fre = computeFleschReadingEase(text);
1203
+ const freScore = fre >= 60 && fre <= 70 ? 15 : fre > 70 ? 13 : fre >= 50 ? 10 : fre >= 30 ? 6 : 3;
1204
+ factors.push(
1205
+ makeFactor(
1206
+ "Readability",
1207
+ freScore,
1208
+ 15,
1209
+ `Flesch Reading Ease: ${fre.toFixed(1)}`
1210
+ )
1211
+ );
1212
+ const totalWords = countWords(text);
1213
+ const complex = countComplexWords(text);
1214
+ const jargonRatio = totalWords > 0 ? complex / totalWords : 0;
1215
+ const jargonScore = jargonRatio <= 0.02 ? 15 : jargonRatio <= 0.05 ? 12 : jargonRatio <= 0.1 ? 8 : 3;
1216
+ factors.push(
1217
+ makeFactor(
1218
+ "Jargon Density",
1219
+ jargonScore,
1220
+ 15,
1221
+ `${(jargonRatio * 100).toFixed(1)}% complex words`
1222
+ )
1223
+ );
1224
+ const transCount = countTransitionWords(text, TRANSITION_WORDS);
1225
+ const transScore = thresholdScore(transCount, [
1226
+ [10, 15],
1227
+ [5, 11],
1228
+ [2, 7],
1229
+ [1, 3],
1230
+ [0, 0]
1231
+ ]);
1232
+ factors.push(
1233
+ makeFactor(
1234
+ "Transition Usage",
1235
+ transScore,
1236
+ 15,
1237
+ `${transCount} transition types found`
1238
+ )
1239
+ );
1240
+ return {
1241
+ category: {
1242
+ name: CATEGORY_DISPLAY_NAMES.readabilityForCompression,
1243
+ key: "readabilityForCompression",
1244
+ score: sumFactors(factors),
1245
+ maxScore: maxFactors(factors),
1246
+ factors
1247
+ },
1248
+ rawData: {
1249
+ avgSentenceLength: avgSentLen,
1250
+ readabilityScore: fre
1251
+ }
1252
+ };
1253
+ }
1254
+
1255
+ // src/modules/audits/service.ts
1256
+ function runAudits(page, fetchResult, domainSignals) {
1257
+ const extractability = auditContentExtractability(
1258
+ page,
1259
+ fetchResult,
1260
+ domainSignals
1261
+ );
1262
+ const structure = auditContentStructure(page);
1263
+ const answerability = auditAnswerability(page);
1264
+ const entityClarity = auditEntityClarity(page);
1265
+ const groundingSignals = auditGroundingSignals(page);
1266
+ const authorityContext = auditAuthorityContext(page);
1267
+ const readability = auditReadabilityForCompression(page);
1268
+ return {
1269
+ categories: {
1270
+ contentExtractability: extractability.category,
1271
+ contentStructure: structure.category,
1272
+ answerability: answerability.category,
1273
+ entityClarity: entityClarity.category,
1274
+ groundingSignals: groundingSignals.category,
1275
+ authorityContext: authorityContext.category,
1276
+ readabilityForCompression: readability.category
1277
+ },
1278
+ rawData: {
1279
+ title: page.title,
1280
+ metaDescription: page.metaDescription,
1281
+ wordCount: page.stats.wordCount,
1282
+ ...extractability.rawData,
1283
+ ...structure.rawData,
1284
+ ...answerability.rawData,
1285
+ ...entityClarity.rawData,
1286
+ ...groundingSignals.rawData,
1287
+ ...authorityContext.rawData,
1288
+ ...readability.rawData
1289
+ }
1290
+ };
1291
+ }
1292
+
1293
+ // src/modules/extractor/service.ts
1294
+ import * as cheerio from "cheerio";
1295
+
1296
+ // src/modules/extractor/support/boilerplate.ts
1297
+ var REMOVE_SELECTORS = [
1298
+ "script",
1299
+ "style",
1300
+ "noscript",
1301
+ "svg",
1302
+ "iframe",
1303
+ "nav",
1304
+ "header",
1305
+ "footer",
1306
+ "aside",
1307
+ '[role="navigation"]',
1308
+ '[role="banner"]',
1309
+ '[role="contentinfo"]',
1310
+ ".sidebar",
1311
+ "#sidebar",
1312
+ ".cookie-banner",
1313
+ "#cookie-consent",
1314
+ ".cookie-notice",
1315
+ ".nav",
1316
+ ".navbar",
1317
+ ".footer",
1318
+ ".header",
1319
+ ".menu",
1320
+ ".ad",
1321
+ ".ads",
1322
+ ".advertisement",
1323
+ '[class*="cookie"]',
1324
+ '[class*="consent"]',
1325
+ '[class*="popup"]',
1326
+ '[class*="modal"]'
1327
+ ];
1328
+ function removeBoilerplate($) {
1329
+ for (const selector of REMOVE_SELECTORS) {
1330
+ $(selector).remove();
1331
+ }
1332
+ }
1333
+
1334
+ // src/modules/extractor/support/text.ts
1335
+ function normalizeWhitespace(text) {
1336
+ return text.replace(/\s+/g, " ").trim();
1337
+ }
1338
+ function extractCleanText($) {
1339
+ return normalizeWhitespace($("body").text());
1340
+ }
1341
+
1342
+ // src/modules/extractor/service.ts
1343
+ function extractPage(html, url) {
1344
+ const $ = cheerio.load(html);
1345
+ const title = $("title").text().trim() || $('meta[property="og:title"]').attr("content")?.trim() || "";
1346
+ const metaDescription = $('meta[name="description"]').attr("content")?.trim() || $('meta[property="og:description"]').attr("content")?.trim() || "";
1347
+ const rawText = $("body").text().replace(/\s+/g, " ").trim();
1348
+ const rawByteLength = Buffer.byteLength(html, "utf-8");
1349
+ const h1Count = $("h1").length;
1350
+ const h2Count = $("h2").length;
1351
+ const h3Count = $("h3").length;
1352
+ const headingCount = h1Count + h2Count + h3Count + $("h4, h5, h6").length;
1353
+ const linkCount = $("a[href]").length;
1354
+ const imageCount = $("img").length;
1355
+ const listCount = $("ul, ol").length;
1356
+ const listItemCount = $("li").length;
1357
+ const tableCount = $("table").length;
1358
+ const paragraphCount = $("p").length;
1359
+ let imagesWithAlt = 0;
1360
+ $("img").each((_, el) => {
1361
+ if ($(el).attr("alt")) imagesWithAlt++;
1362
+ });
1363
+ const pageDomain = getDomain(url);
1364
+ const externalLinks = [];
1365
+ $('a[href^="http"]').each((_, el) => {
1366
+ const href = $(el).attr("href");
1367
+ if (href) {
1368
+ try {
1369
+ if (getDomain(href) !== pageDomain) {
1370
+ externalLinks.push({
1371
+ url: href,
1372
+ text: $(el).text().trim().substring(0, 50)
1373
+ });
1374
+ }
1375
+ } catch {
1376
+ }
1377
+ }
1378
+ });
1379
+ const externalLinkCount = externalLinks.length;
1380
+ const $clean = cheerio.load(html);
1381
+ removeBoilerplate($clean);
1382
+ const cleanText = extractCleanText($clean);
1383
+ const cleanTextLength = cleanText.length;
1384
+ const boilerplateRatio = rawText.length > 0 ? Math.max(0, Math.min(1, 1 - cleanTextLength / rawText.length)) : 0;
1385
+ const stats = {
1386
+ wordCount: countWords(cleanText),
1387
+ sentenceCount: countSentences(cleanText),
1388
+ paragraphCount,
1389
+ headingCount,
1390
+ h1Count,
1391
+ h2Count,
1392
+ h3Count,
1393
+ linkCount,
1394
+ externalLinkCount,
1395
+ imageCount,
1396
+ imagesWithAlt,
1397
+ listCount,
1398
+ listItemCount,
1399
+ tableCount,
1400
+ boilerplateRatio,
1401
+ rawByteLength,
1402
+ cleanTextLength
1403
+ };
1404
+ return {
1405
+ url,
1406
+ html,
1407
+ cleanText,
1408
+ title,
1409
+ metaDescription,
1410
+ stats,
1411
+ $,
1412
+ externalLinks
1413
+ };
1414
+ }
1415
+
1416
+ // src/modules/fetcher/schema.ts
1417
+ import { z } from "zod";
1418
+ var FetchOptionsSchema = z.object({
1419
+ url: z.url(),
1420
+ timeout: z.number().positive().default(45e3),
1421
+ userAgent: z.string().default(`AISEOAudit/${VERSION}`)
1422
+ });
1423
+ var FetchResultSchema = z.object({
1424
+ url: z.string(),
1425
+ finalUrl: z.string(),
1426
+ statusCode: z.number(),
1427
+ contentType: z.string(),
1428
+ html: z.string(),
1429
+ byteLength: z.number(),
1430
+ fetchTimeMs: z.number(),
1431
+ redirected: z.boolean()
1432
+ });
1433
+
1434
+ // src/modules/fetcher/service.ts
1435
+ async function fetchUrl(options) {
1436
+ const opts = FetchOptionsSchema.parse(options);
1437
+ const start = Date.now();
1438
+ const response = await httpGet({
1439
+ url: opts.url,
1440
+ timeout: opts.timeout,
1441
+ userAgent: opts.userAgent
1442
+ });
1443
+ const fetchTimeMs = Date.now() - start;
1444
+ const html = response.data;
1445
+ const finalUrl = response.finalUrl || opts.url;
1446
+ const contentType = response.headers["content-type"] || "unknown";
1447
+ return {
1448
+ url: opts.url,
1449
+ finalUrl,
1450
+ statusCode: response.status,
1451
+ contentType,
1452
+ html,
1453
+ byteLength: Buffer.byteLength(html, "utf-8"),
1454
+ fetchTimeMs,
1455
+ redirected: finalUrl !== opts.url
1456
+ };
1457
+ }
1458
+
1459
+ // src/modules/recommendations/constants.ts
1460
+ function static_(text) {
1461
+ return () => text;
1462
+ }
1463
+ var RECOMMENDATION_BUILDERS = {
1464
+ "Fetch Success": static_(
1465
+ "Ensure the page returns HTTP 200 without excessive redirect chains. AI engines cannot extract content from pages that fail to load."
1466
+ ),
1467
+ "Text Extraction Quality": static_(
1468
+ "Improve the ratio of meaningful text content to markup. Pages with very low text density are harder for AI engines to extract useful content from."
1469
+ ),
1470
+ "Boilerplate Ratio": static_(
1471
+ "Reduce boilerplate content (navigation, footers, sidebars) relative to main content. Use semantic HTML elements like <main> and <article> to help engines isolate your content."
1472
+ ),
1473
+ "Word Count Adequacy": (rawData) => {
1474
+ const count = rawData.wordCount;
1475
+ if (count < 100) {
1476
+ return `Your page has ${count} words, which is too thin for AI engines to reference. The ideal range is 300-3000 words.`;
1477
+ }
1478
+ if (count < 300) {
1479
+ return `Your page has ${count} words. AI engines prefer 300-3000 words for comprehensive coverage. Consider expanding your content.`;
1480
+ }
1481
+ return `Your page has ${count} words, which exceeds the ideal 300-3000 word range. Consider splitting into multiple focused pages.`;
1482
+ },
1483
+ "AI Crawler Access": (rawData) => {
1484
+ const access2 = rawData.crawlerAccess;
1485
+ if (!access2 || access2.blocked.length === 0) {
1486
+ return "Ensure AI crawlers like GPTBot, ClaudeBot, and PerplexityBot are allowed in your robots.txt.";
1487
+ }
1488
+ const blocked = access2.blocked.join(", ");
1489
+ if (access2.allowed.length > 0) {
1490
+ const allowed = access2.allowed.join(", ");
1491
+ return `Your robots.txt is blocking ${blocked}. ${allowed} ${access2.allowed.length === 1 ? "is" : "are"} allowed. Unblock all AI crawlers so your content can be discovered and cited.`;
1492
+ }
1493
+ return `Your robots.txt is blocking ${blocked}. Blocking these crawlers means your content cannot be discovered or cited by AI engines.`;
1494
+ },
1495
+ "LLMs.txt Presence": static_(
1496
+ "Consider adding llms.txt and llms-full.txt files at your domain root. This emerging standard provides AI systems with a structured overview of your site, helping them understand and reference your content more effectively."
1497
+ ),
1498
+ "Image Accessibility": (rawData) => {
1499
+ const images = rawData.imageAccessibility;
1500
+ if (!images || images.imageCount === 0) {
1501
+ return "Add descriptive alt text to all images and use <figure> with <figcaption> for semantic image context.";
1502
+ }
1503
+ const pct = Math.round(images.imagesWithAlt / images.imageCount * 100);
1504
+ const missing = images.imageCount - images.imagesWithAlt;
1505
+ let text = `${images.imagesWithAlt} of your ${images.imageCount} images have alt text (${pct}%). `;
1506
+ if (missing > 0) {
1507
+ text += `Add alt text to the remaining ${missing} image${missing === 1 ? "" : "s"}. `;
1508
+ }
1509
+ if (images.figcaptionCount === 0) {
1510
+ text += "Consider using <figure> with <figcaption> for images that need descriptive context.";
1511
+ }
1512
+ return text;
1513
+ },
1514
+ "Heading Hierarchy": static_(
1515
+ "Use a clear H1 > H2 > H3 heading hierarchy. Headings serve as structural anchors that AI engines use to segment and reuse content."
1516
+ ),
1517
+ "Lists Presence": static_(
1518
+ "Add bulleted or numbered lists to organize information. Lists are easily extracted and reused by AI engines."
1519
+ ),
1520
+ "Tables Presence": static_(
1521
+ "Consider adding data tables for comparative or structured data. Tables are highly parseable by AI engines."
1522
+ ),
1523
+ "Paragraph Structure": static_(
1524
+ "Keep paragraphs between 30-150 words for optimal readability and extractability."
1525
+ ),
1526
+ Scannability: static_(
1527
+ "Use bold text, short paragraphs, and frequent headings to improve scannability for both humans and AI."
1528
+ ),
1529
+ "Section Length": (rawData) => {
1530
+ const sections = rawData.sectionLengths;
1531
+ if (!sections || sections.sectionCount === 0) {
1532
+ return "Add headings to create distinct sections. Each headed section should be a self-contained unit that an AI engine could extract and reuse.";
1533
+ }
1534
+ const avg = Math.round(sections.avgWordsPerSection);
1535
+ if (avg < 120) {
1536
+ return `Your sections average ${avg} words. The citation sweet spot is 120-180 words. Consider expanding sections with more detail rather than splitting into many short fragments.`;
1537
+ }
1538
+ return `Your sections average ${avg} words. The citation sweet spot is 120-180 words. Consider adding more subheadings to break up long sections into self-contained units.`;
1539
+ },
1540
+ "Definition Patterns": static_(
1541
+ 'Define key terms and concepts clearly (e.g., "X is defined as..." or "X refers to..."). Clear definitions are directly reusable by AI engines.'
1542
+ ),
1543
+ "Direct Answer Statements": static_(
1544
+ "Start key sentences with direct statements that could serve as standalone answers."
1545
+ ),
1546
+ "Answer Capsules": (rawData) => {
1547
+ const capsules = rawData.answerCapsules;
1548
+ if (!capsules || capsules.total === 0) {
1549
+ return 'Frame your H2 headings as questions (e.g., "What is X?") and place a concise answer (under 200 characters) in the first sentence of the following paragraph. 72% of AI-cited content uses this pattern.';
1550
+ }
1551
+ const missing = capsules.total - capsules.withCapsule;
1552
+ return `${capsules.withCapsule} of your ${capsules.total} question-framed H2s have a concise answer capsule. Add a short, direct answer (under 200 characters) as the first sentence after the remaining ${missing}. 72% of AI-cited content uses this pattern.`;
1553
+ },
1554
+ "Step-by-Step Content": static_(
1555
+ "Break down processes into clear, numbered steps. Step-by-step content is highly reusable by AI engines."
1556
+ ),
1557
+ "Q/A Patterns": (rawData) => {
1558
+ const questions = rawData.questionsFound;
1559
+ if (!questions || questions.length === 0) {
1560
+ return 'Include and answer common questions your audience might have. Structure content to directly answer "what is", "how to" style queries.';
1561
+ }
1562
+ return `Found ${questions.length} question${questions.length === 1 ? "" : "s"} in your content. Add more question-and-answer patterns to cover the queries your audience asks AI engines.`;
1563
+ },
1564
+ "Summary/Conclusion": static_(
1565
+ "Add a conclusion section with key takeaways or a summary. This helps AI engines quickly extract the main points."
1566
+ ),
1567
+ "Entity Richness": (rawData) => {
1568
+ const entities = rawData.entities;
1569
+ if (!entities) {
1570
+ return "Reference relevant experts, organizations, and places in your field. Named entities help AI engines understand context.";
1571
+ }
1572
+ const total = entities.people.length + entities.organizations.length + entities.places.length + entities.topics.length;
1573
+ const parts = [];
1574
+ if (entities.people.length > 0)
1575
+ parts.push(`${entities.people.length} people`);
1576
+ if (entities.organizations.length > 0)
1577
+ parts.push(`${entities.organizations.length} organizations`);
1578
+ if (entities.places.length > 0)
1579
+ parts.push(`${entities.places.length} places`);
1580
+ if (entities.topics.length > 0)
1581
+ parts.push(`${entities.topics.length} topics`);
1582
+ if (total === 0) {
1583
+ return "No named entities were detected. Reference specific people, organizations, and places to help AI engines understand what your content is about.";
1584
+ }
1585
+ return `Found ${total} unique entities (${parts.join(", ")}). AI engines perform best with 9+ distinct entities. Add more specific names, organizations, and places relevant to your topic.`;
1586
+ },
1587
+ "Topic Consistency": static_(
1588
+ "Align your main topics with your title and headings. Topic consistency helps AI engines understand what your page is about."
1589
+ ),
1590
+ "Entity Density": static_(
1591
+ "Ensure a balanced density of named entities (2-8 per 100 words). Too few makes content vague; too many makes it hard to parse."
1592
+ ),
1593
+ "External References": (rawData) => {
1594
+ const links = rawData.externalLinks;
1595
+ if (!links || links.length === 0) {
1596
+ return "Add links to reputable external sources to ground your claims. AI engines use external references to verify and attribute information.";
1597
+ }
1598
+ return `Found ${links.length} external link${links.length === 1 ? "" : "s"}. AI engines prefer content with 6+ external references. Add more links to authoritative sources that support your claims.`;
1599
+ },
1600
+ "Citation Patterns": static_(
1601
+ 'Use formal citation patterns (e.g., [1], "according to") when referencing sources.'
1602
+ ),
1603
+ "Numeric Claims": static_(
1604
+ "Include relevant statistics and data points to support your content with verifiable claims."
1605
+ ),
1606
+ "Attribution Indicators": static_(
1607
+ 'Attribute claims to specific sources or experts. Phrases like "according to" help AI engines trace information.'
1608
+ ),
1609
+ "Quoted Attribution": static_(
1610
+ 'Add expert quotes with clear attribution. Use patterns like "Quote text" - Expert Name or "Quote text," said Expert Name. Research shows quotation addition increased AI visibility by 30-40%.'
1611
+ ),
1612
+ "Author Attribution": static_(
1613
+ "Add visible author information with a byline to establish who created the content."
1614
+ ),
1615
+ "Organization Identity": static_(
1616
+ "Add Organization structured data or og:site_name to help engines identify the source."
1617
+ ),
1618
+ "Contact/About Links": static_(
1619
+ "Link to About and Contact pages to establish credibility and enable source verification."
1620
+ ),
1621
+ "Publication Date": static_(
1622
+ "Include publication and last-updated dates using proper HTML5 time elements or schema markup."
1623
+ ),
1624
+ "Content Freshness": (rawData) => {
1625
+ const freshness = rawData.freshness;
1626
+ if (!freshness || freshness.ageInMonths === null) {
1627
+ return "Add a publication or modified date to your content. 65% of AI crawler hits target content less than 1 year old. Without a parseable date, AI engines may deprioritize your content.";
1628
+ }
1629
+ const months = Math.round(freshness.ageInMonths);
1630
+ if (months > 24) {
1631
+ return `Your content was last updated ${months} months ago. AI engines strongly prefer content less than 12 months old. Consider updating with current information and refreshing the modified date.`;
1632
+ }
1633
+ if (months > 12) {
1634
+ return `Your content was last updated ${months} months ago. 65% of AI crawler hits target content less than 1 year old. Refresh your content and update the modified date.`;
1635
+ }
1636
+ if (!freshness.hasModifiedDate) {
1637
+ return `Your content has a publish date but no modified date. Adding a dateModified signal shows active maintenance and gives a freshness boost with AI engines.`;
1638
+ }
1639
+ return "Update your content to include a recent publication or modified date. Content freshness acts as a hard gate for AI engine citations.";
1640
+ },
1641
+ "Structured Data": (rawData) => {
1642
+ const types = rawData.structuredDataTypes;
1643
+ if (!types || types.length === 0) {
1644
+ return "Add JSON-LD structured data and Open Graph tags to provide machine-readable context. Start with the schema type that matches your page (Article, Organization, Product, FAQPage, etc.).";
1645
+ }
1646
+ return `Found ${types.join(", ")} schema${types.length === 1 ? "" : "s"}. Ensure you also have Open Graph tags (og:title, og:description, og:image) and a canonical URL for complete structured data coverage.`;
1647
+ },
1648
+ "Schema Completeness": (rawData) => {
1649
+ const schema = rawData.schemaCompleteness;
1650
+ if (!schema || schema.details.length === 0) {
1651
+ return "Add JSON-LD schema with all recommended properties. Complete schemas help AI engines attribute and trust your content.";
1652
+ }
1653
+ const incomplete = schema.details.filter((d) => d.missing.length > 0);
1654
+ if (incomplete.length === 0) {
1655
+ return "Ensure your JSON-LD schema types include all recommended properties for maximum AI engine trust.";
1656
+ }
1657
+ const summaries = incomplete.map(
1658
+ (d) => `${d.type} is missing ${d.missing.join(", ")}`
1659
+ );
1660
+ return `Your ${summaries.join("; ")}. Adding these properties helps AI engines attribute and trust your content.`;
1661
+ },
1662
+ "Entity Consistency": (rawData) => {
1663
+ const ec = rawData.entityConsistency;
1664
+ if (!ec || !ec.entityName) {
1665
+ return "Add a consistent brand or organization name across your page title, OG tags, JSON-LD schema, and footer. Consistent entity signals help AI engines confidently attribute content to your brand.";
1666
+ }
1667
+ return `"${ec.entityName}" was found on ${ec.surfacesFound} of ${ec.surfacesChecked} page surfaces. Ensure it appears consistently in the page title, OG tags, schema, and footer for strong brand attribution.`;
1668
+ },
1669
+ "Sentence Length": (rawData) => {
1670
+ const avg = rawData.avgSentenceLength;
1671
+ if (avg === void 0) {
1672
+ return "Aim for an average sentence length of 12-22 words for optimal readability and compressibility.";
1673
+ }
1674
+ const rounded = Math.round(avg);
1675
+ if (rounded > 22) {
1676
+ return `Your average sentence is ${rounded} words. The ideal range for AI compression is 12-22 words. Break long sentences into shorter, more direct statements.`;
1677
+ }
1678
+ if (rounded < 12) {
1679
+ return `Your average sentence is ${rounded} words. While short sentences are readable, combining some into 12-22 word sentences provides better context for AI summarization.`;
1680
+ }
1681
+ return `Your average sentence length is ${rounded} words. Fine-tune toward the 12-22 word sweet spot for optimal AI compression.`;
1682
+ },
1683
+ Readability: (rawData) => {
1684
+ const score = rawData.readabilityScore;
1685
+ if (score === void 0) {
1686
+ return "Simplify language where possible. A Flesch Reading Ease score of 60-70 is ideal for broad AI reusability.";
1687
+ }
1688
+ const rounded = Math.round(score);
1689
+ if (rounded < 30) {
1690
+ return `Your Flesch Reading Ease score is ${rounded} (very difficult). A score of 60-70 is ideal. Shorten sentences, use simpler vocabulary, and break up complex ideas.`;
1691
+ }
1692
+ if (rounded < 50) {
1693
+ return `Your Flesch Reading Ease score is ${rounded} (difficult). A score of 60-70 is ideal for broad AI reusability. Simplify where possible without losing meaning.`;
1694
+ }
1695
+ if (rounded < 60) {
1696
+ return `Your Flesch Reading Ease score is ${rounded} (fairly difficult). You're close to the ideal 60-70 range. Minor simplification would improve AI compressibility.`;
1697
+ }
1698
+ return `Your Flesch Reading Ease score is ${rounded}. A score of 60-70 is ideal for broad AI reusability.`;
1699
+ },
1700
+ "Jargon Density": static_(
1701
+ "Define technical terms or replace with simpler alternatives. High jargon density reduces AI reusability."
1702
+ ),
1703
+ "Transition Usage": static_(
1704
+ "Use transition words (however, therefore, additionally) to improve content flow and logical structure."
1705
+ )
1706
+ };
1707
+
1708
+ // src/modules/recommendations/service.ts
1709
+ function generateRecommendations(auditResult) {
1710
+ const recommendations = [];
1711
+ for (const category of Object.values(auditResult.categories)) {
1712
+ for (const factor of category.factors) {
1713
+ const pct = factor.maxScore > 0 ? factor.score / factor.maxScore : 1;
1714
+ if (pct >= 0.7) continue;
1715
+ const priority = pct < 0.3 ? "high" : pct < 0.5 ? "medium" : "low";
1716
+ const builder = RECOMMENDATION_BUILDERS[factor.name];
1717
+ const recText = builder ? builder(auditResult.rawData) : `Review and improve "${factor.name}" based on best practices for AI search readiness.`;
1718
+ recommendations.push({
1719
+ category: category.name,
1720
+ factor: factor.name,
1721
+ currentValue: factor.value,
1722
+ priority,
1723
+ recommendation: recText
1724
+ });
1725
+ }
1726
+ }
1727
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
1728
+ recommendations.sort((a, b) => {
1729
+ const pd = priorityOrder[a.priority] - priorityOrder[b.priority];
1730
+ if (pd !== 0) return pd;
1731
+ return a.factor.localeCompare(b.factor);
1732
+ });
1733
+ return recommendations;
1734
+ }
1735
+
1736
+ // src/modules/scoring/constants.ts
1737
+ var GRADE_THRESHOLDS = [
1738
+ [95, "A+"],
1739
+ [90, "A"],
1740
+ [85, "A-"],
1741
+ [80, "B+"],
1742
+ [75, "B"],
1743
+ [70, "B-"],
1744
+ [65, "C+"],
1745
+ [60, "C"],
1746
+ [55, "C-"],
1747
+ [45, "D"],
1748
+ [0, "F"]
1749
+ ];
1750
+
1751
+ // src/modules/scoring/service.ts
1752
+ function computeScore(categories, weights) {
1753
+ const weightMap = {
1754
+ contentExtractability: weights.contentExtractability,
1755
+ contentStructure: weights.contentStructure,
1756
+ answerability: weights.answerability,
1757
+ entityClarity: weights.entityClarity,
1758
+ groundingSignals: weights.groundingSignals,
1759
+ authorityContext: weights.authorityContext,
1760
+ readabilityForCompression: weights.readabilityForCompression
1761
+ };
1762
+ const totalWeight = Object.values(weightMap).reduce((sum, w) => sum + w, 0);
1763
+ let totalPoints = 0;
1764
+ let maxPoints = 0;
1765
+ let weightedScore = 0;
1766
+ for (const [key, category] of Object.entries(categories)) {
1767
+ totalPoints += category.score;
1768
+ maxPoints += category.maxScore;
1769
+ const w = weightMap[key] ?? 1;
1770
+ const normalizedWeight = totalWeight > 0 ? w / totalWeight : 1 / 7;
1771
+ const categoryPct = category.maxScore > 0 ? category.score / category.maxScore * 100 : 0;
1772
+ weightedScore += categoryPct * normalizedWeight;
1773
+ }
1774
+ const overallScore = Math.round(weightedScore);
1775
+ const grade = computeGrade(overallScore);
1776
+ return { overallScore, grade, totalPoints, maxPoints };
1777
+ }
1778
+ function computeGrade(score) {
1779
+ for (const [threshold, grade] of GRADE_THRESHOLDS) {
1780
+ if (score >= threshold) return grade;
1781
+ }
1782
+ return "F";
1783
+ }
1784
+
1785
+ // src/modules/analyzer/service.ts
1786
+ async function fetchDomainSignals(domain, timeout, userAgent) {
1787
+ const baseUrl = `https://${domain}`;
1788
+ const cappedTimeout = Math.min(timeout, DOMAIN_SIGNAL_TIMEOUT_CAP);
1789
+ const [robotsRes, llmsRes, llmsFullRes] = await Promise.allSettled([
1790
+ httpGet({
1791
+ url: `${baseUrl}/robots.txt`,
1792
+ timeout: cappedTimeout,
1793
+ userAgent
1794
+ }),
1795
+ httpHead({
1796
+ url: `${baseUrl}/llms.txt`,
1797
+ timeout: cappedTimeout,
1798
+ userAgent
1799
+ }),
1800
+ httpHead({
1801
+ url: `${baseUrl}/llms-full.txt`,
1802
+ timeout: cappedTimeout,
1803
+ userAgent
1804
+ })
1805
+ ]);
1806
+ return {
1807
+ robotsTxt: robotsRes.status === "fulfilled" && robotsRes.value.status === 200 ? robotsRes.value.data : null,
1808
+ llmsTxtExists: llmsRes.status === "fulfilled" && llmsRes.value.status === 200,
1809
+ llmsFullTxtExists: llmsFullRes.status === "fulfilled" && llmsFullRes.value.status === 200
1810
+ };
1811
+ }
1812
+ async function analyzeUrl(options, config) {
1813
+ const startTime = Date.now();
1814
+ const url = normalizeUrl(options.url);
1815
+ const timeout = options.timeout ?? config.timeout;
1816
+ const userAgent = options.userAgent ?? config.userAgent;
1817
+ const fetchResult = await fetchUrl({ url, timeout, userAgent });
1818
+ const domain = getDomain(fetchResult.finalUrl || url);
1819
+ const domainSignals = await fetchDomainSignals(domain, timeout, userAgent);
1820
+ const page = extractPage(fetchResult.html, url);
1821
+ const auditResult = runAudits(page, fetchResult, domainSignals);
1822
+ const scoring = computeScore(auditResult.categories, config.weights);
1823
+ const recommendations = generateRecommendations(auditResult);
1824
+ const analysisDurationMs = Date.now() - startTime;
1825
+ return {
1826
+ url,
1827
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
1828
+ overallScore: scoring.overallScore,
1829
+ grade: scoring.grade,
1830
+ totalPoints: scoring.totalPoints,
1831
+ maxPoints: scoring.maxPoints,
1832
+ categories: auditResult.categories,
1833
+ recommendations,
1834
+ rawData: auditResult.rawData,
1835
+ meta: {
1836
+ version: VERSION,
1837
+ analysisDurationMs
1838
+ }
1839
+ };
1840
+ }
1841
+
1842
+ // src/modules/config/service.ts
1843
+ import { readFile } from "fs/promises";
1844
+ import { dirname, join, resolve } from "path";
1845
+
1846
+ // src/utils/fs.ts
1847
+ import { access, writeFile as fsWriteFile } from "fs/promises";
1848
+ async function fileExists(path) {
1849
+ try {
1850
+ await access(path);
1851
+ return true;
1852
+ } catch {
1853
+ return false;
1854
+ }
1855
+ }
1856
+ async function writeOutputFile(path, content) {
1857
+ await fsWriteFile(path, content, "utf-8");
1858
+ }
1859
+
1860
+ // src/modules/config/constants.ts
1861
+ var CONFIG_FILENAMES = [
1862
+ "aiseo.config.json",
1863
+ ".aiseo.config.json",
1864
+ "aiseo-audit.config.json"
1865
+ ];
1866
+
1867
+ // src/modules/config/schema.ts
1868
+ import { z as z2 } from "zod";
1869
+ var DEFAULT_USER_AGENT = `AISEOAudit/${VERSION}`;
1870
+ var DEFAULT_WEIGHTS = {
1871
+ contentExtractability: 1,
1872
+ contentStructure: 1,
1873
+ answerability: 1,
1874
+ entityClarity: 1,
1875
+ groundingSignals: 1,
1876
+ authorityContext: 1,
1877
+ readabilityForCompression: 1
1878
+ };
1879
+ var CategoryWeightSchema = z2.object({
1880
+ contentExtractability: z2.number().min(0).default(1),
1881
+ contentStructure: z2.number().min(0).default(1),
1882
+ answerability: z2.number().min(0).default(1),
1883
+ entityClarity: z2.number().min(0).default(1),
1884
+ groundingSignals: z2.number().min(0).default(1),
1885
+ authorityContext: z2.number().min(0).default(1),
1886
+ readabilityForCompression: z2.number().min(0).default(1)
1887
+ }).default(DEFAULT_WEIGHTS);
1888
+ var AiseoConfigSchema = z2.object({
1889
+ timeout: z2.number().positive().default(45e3),
1890
+ userAgent: z2.string().default(DEFAULT_USER_AGENT),
1891
+ format: z2.enum(["pretty", "json", "md", "html"]).default("pretty"),
1892
+ failUnder: z2.number().min(0).max(100).optional(),
1893
+ weights: CategoryWeightSchema
1894
+ }).default({
1895
+ timeout: 45e3,
1896
+ userAgent: DEFAULT_USER_AGENT,
1897
+ format: "pretty",
1898
+ weights: DEFAULT_WEIGHTS
1899
+ });
1900
+
1901
+ // src/modules/config/service.ts
1902
+ async function findConfigFile(startDir) {
1903
+ let dir = resolve(startDir);
1904
+ while (true) {
1905
+ for (const filename of CONFIG_FILENAMES) {
1906
+ const candidate = join(dir, filename);
1907
+ if (await fileExists(candidate)) return candidate;
1908
+ }
1909
+ const parent = dirname(dir);
1910
+ if (parent === dir) return null;
1911
+ dir = parent;
1912
+ }
1913
+ }
1914
+ async function loadConfig(configPath) {
1915
+ if (configPath) {
1916
+ const content = await readFile(resolve(configPath), "utf-8");
1917
+ return AiseoConfigSchema.parse(JSON.parse(content));
1918
+ }
1919
+ const found = await findConfigFile(process.cwd());
1920
+ if (found) {
1921
+ const content = await readFile(found, "utf-8");
1922
+ return AiseoConfigSchema.parse(JSON.parse(content));
1923
+ }
1924
+ return AiseoConfigSchema.parse({});
1925
+ }
1926
+
1927
+ // src/modules/report/support/html.ts
1928
+ function scoreColorHex(pct) {
1929
+ if (pct >= 90) return "#00cc66";
1930
+ if (pct >= 50) return "#ffaa33";
1931
+ return "#ff3333";
1932
+ }
1933
+ function scoreTextColorHex(pct) {
1934
+ if (pct >= 90) return "#008800";
1935
+ if (pct >= 50) return "#ffaa33";
1936
+ return "#cc0000";
1937
+ }
1938
+ function scoreClass(pct) {
1939
+ if (pct >= 90) return "pass";
1940
+ if (pct >= 50) return "average";
1941
+ return "fail";
1942
+ }
1943
+ function statusIcon(status) {
1944
+ if (status === "good") return "&#10003;";
1945
+ if (status === "neutral") return "&#8212;";
1946
+ if (status === "needs_improvement") return "&#9650;";
1947
+ return "&#10007;";
1948
+ }
1949
+ function statusClass(status) {
1950
+ if (status === "good") return "good";
1951
+ if (status === "neutral") return "neutral";
1952
+ if (status === "needs_improvement") return "warn";
1953
+ return "fail";
1954
+ }
1955
+ function escapeHtml(text) {
1956
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1957
+ }
1958
+ function buildGaugeSvg(score, size = "small") {
1959
+ const pct = Math.max(0, Math.min(100, score));
1960
+ const arcColor = scoreColorHex(pct);
1961
+ const textColor = scoreTextColorHex(pct);
1962
+ const radius = 56;
1963
+ const circumference = 2 * Math.PI * radius;
1964
+ const offset = circumference * (1 - pct / 100);
1965
+ const dim = size === "large" ? 120 : 64;
1966
+ const fontSize = size === "large" ? 40 : 22;
1967
+ const strokeWidth = size === "large" ? 8 : 7;
1968
+ return `<svg class="gauge" viewBox="0 0 120 120" width="${dim}" height="${dim}">
1969
+ <circle cx="60" cy="60" r="${radius}" fill="none" stroke="#e0e0e0" stroke-width="${strokeWidth}"/>
1970
+ <circle cx="60" cy="60" r="${radius}" fill="none" stroke="${arcColor}" stroke-width="${strokeWidth}"
1971
+ stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
1972
+ stroke-linecap="round" transform="rotate(-90 60 60)"/>
1973
+ <text x="60" y="${60 + fontSize * 0.35}" text-anchor="middle" font-size="${fontSize}" font-weight="700" fill="${textColor}">${score}</text>
1974
+ </svg>`;
1975
+ }
1976
+ function buildMultiSegmentGauge(overallScore, grade, totalPoints, maxPoints, categories) {
1977
+ const radius = 56;
1978
+ const circumference = 2 * Math.PI * radius;
1979
+ const strokeWidth = 10;
1980
+ const gapDeg = 1;
1981
+ const totalGapDeg = gapDeg * categories.length;
1982
+ const availableDeg = 360 - totalGapDeg;
1983
+ const textColor = scoreTextColorHex(overallScore);
1984
+ const segments = [];
1985
+ let currentAngle = -90;
1986
+ for (const cat of categories) {
1987
+ const segmentDeg = maxPoints > 0 ? cat.maxScore / maxPoints * availableDeg : 0;
1988
+ const segmentCirc = segmentDeg / 360 * circumference;
1989
+ const fillRatio = cat.maxScore > 0 ? cat.score / cat.maxScore : 0;
1990
+ const filledCirc = segmentCirc * fillRatio;
1991
+ const catPct = cat.maxScore > 0 ? Math.round(fillRatio * 100) : 0;
1992
+ const color = scoreColorHex(catPct);
1993
+ segments.push(
1994
+ `<circle cx="60" cy="60" r="${radius}" fill="none" stroke="#e8e8e8" stroke-width="${strokeWidth}"
1995
+ stroke-dasharray="${segmentCirc} ${circumference - segmentCirc}"
1996
+ transform="rotate(${currentAngle} 60 60)"/>`
1997
+ );
1998
+ if (filledCirc > 0) {
1999
+ segments.push(
2000
+ `<circle cx="60" cy="60" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}"
2001
+ stroke-dasharray="${filledCirc} ${circumference - filledCirc}"
2002
+ transform="rotate(${currentAngle} 60 60)"/>`
2003
+ );
2004
+ }
2005
+ currentAngle += segmentDeg + gapDeg;
2006
+ }
2007
+ return `<div class="overall-gauge-wrap">
2008
+ <svg class="gauge" viewBox="0 0 120 120" width="160" height="160">
2009
+ ${segments.join("\n ")}
2010
+ <text x="60" y="54" text-anchor="middle" font-size="36" font-weight="700" fill="${textColor}">${overallScore}</text>
2011
+ <text x="60" y="70" text-anchor="middle" font-size="12" font-weight="600" fill="${textColor}">${escapeHtml(grade)}</text>
2012
+ <text x="60" y="84" text-anchor="middle" font-size="9" fill="#999">${totalPoints}/${maxPoints} pts</text>
2013
+ </svg>
2014
+ <div class="score-scale">
2015
+ <span class="scale-fail">0-49</span>
2016
+ <span class="scale-average">50-89</span>
2017
+ <span class="scale-pass">90-100</span>
2018
+ </div>
2019
+ </div>`;
2020
+ }
2021
+ function buildCategoryGauge(category) {
2022
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
2023
+ return `<a class="gauge-item" href="#cat-${escapeHtml(category.name.replace(/\s+/g, "-").toLowerCase())}">
2024
+ ${buildGaugeSvg(pct, "small")}
2025
+ <span class="gauge-label">${escapeHtml(category.name)}</span>
2026
+ </a>`;
2027
+ }
2028
+ function buildCategorySection(category) {
2029
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
2030
+ const cls = scoreClass(pct);
2031
+ const id = category.name.replace(/\s+/g, "-").toLowerCase();
2032
+ const factorRows = category.factors.map(
2033
+ (f) => `
2034
+ <div class="audit-row">
2035
+ <span class="audit-icon ${statusClass(f.status)}">${statusIcon(f.status)}</span>
2036
+ <span class="audit-name">${escapeHtml(f.name)}</span>
2037
+ <span class="audit-detail">${escapeHtml(f.value)}</span>
2038
+ <span class="audit-score">${f.score}/${f.maxScore}</span>
2039
+ </div>`
2040
+ ).join("");
2041
+ return `
2042
+ <div class="category" id="cat-${id}">
2043
+ <div class="category-header">
2044
+ <div class="category-title ${cls}">${escapeHtml(category.name)}</div>
2045
+ <div class="category-score ${cls}">${pct}%</div>
2046
+ </div>
2047
+ <div class="audits">${factorRows}</div>
2048
+ </div>`;
2049
+ }
2050
+ function buildRecommendationRow(rec) {
2051
+ const cls = rec.priority === "high" ? "priority-high" : rec.priority === "medium" ? "priority-med" : "priority-low";
2052
+ const label = rec.priority === "high" ? "HIGH" : rec.priority === "medium" ? "MED" : "LOW";
2053
+ return `
2054
+ <div class="rec-row ${cls}">
2055
+ <span class="rec-tag">${label}</span>
2056
+ <span class="rec-factor">${escapeHtml(rec.factor)}</span>
2057
+ <span class="rec-text">${escapeHtml(rec.recommendation)}</span>
2058
+ </div>`;
2059
+ }
2060
+ function buildRecommendationsByCategory(recommendations, categories) {
2061
+ const categoryNames = Object.values(categories).map((c) => c.name);
2062
+ const grouped = /* @__PURE__ */ new Map();
2063
+ for (const name of categoryNames) {
2064
+ const recs = recommendations.filter((r) => r.category === name);
2065
+ if (recs.length > 0) grouped.set(name, recs);
2066
+ }
2067
+ if (grouped.size === 0) return "";
2068
+ let html = `<div class="recs-section">
2069
+ <div class="recs-title">Recommendations</div>`;
2070
+ for (const [categoryName, recs] of grouped) {
2071
+ html += `<div class="rec-group">
2072
+ <div class="rec-group-name">${escapeHtml(categoryName)}</div>`;
2073
+ html += recs.map(buildRecommendationRow).join("");
2074
+ html += `</div>`;
2075
+ }
2076
+ html += `</div>`;
2077
+ return html;
2078
+ }
2079
+ function renderHtml(result) {
2080
+ const categoryEntries = Object.entries(result.categories);
2081
+ const categories = categoryEntries.map(([, c]) => c);
2082
+ const categoriesWithKeys = categoryEntries.map(([key, c]) => ({
2083
+ key,
2084
+ name: c.name,
2085
+ score: c.score,
2086
+ maxScore: c.maxScore
2087
+ }));
2088
+ const gauges = categories.map(buildCategoryGauge).join("");
2089
+ const sections = categories.map(buildCategorySection).join("");
2090
+ const recsHtml = buildRecommendationsByCategory(
2091
+ result.recommendations,
2092
+ result.categories
2093
+ );
2094
+ const overallGauge = buildMultiSegmentGauge(
2095
+ result.overallScore,
2096
+ result.grade,
2097
+ result.totalPoints,
2098
+ result.maxPoints,
2099
+ categoriesWithKeys
2100
+ );
2101
+ return `<!DOCTYPE html>
2102
+ <html lang="en">
2103
+ <head>
2104
+ <meta charset="utf-8">
2105
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2106
+ <title>AI SEO Audit - ${escapeHtml(result.url)}</title>
2107
+ <style>
2108
+ :root {
2109
+ --pass: #00cc66;
2110
+ --pass-text: #008800;
2111
+ --average: #ffaa33;
2112
+ --average-text: #ffaa33;
2113
+ --fail: #ff3333;
2114
+ --fail-text: #cc0000;
2115
+ --bg: #fff;
2116
+ --surface: #fff;
2117
+ --text: #212121;
2118
+ --text-secondary: #757575;
2119
+ --border: #e0e0e0;
2120
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
2121
+ }
2122
+
2123
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2124
+
2125
+ body {
2126
+ font-family: var(--font);
2127
+ background: var(--bg);
2128
+ color: var(--text);
2129
+ line-height: 1.6;
2130
+ -webkit-font-smoothing: antialiased;
2131
+ }
2132
+
2133
+ /* Topbar */
2134
+ .topbar {
2135
+ display: flex;
2136
+ align-items: center;
2137
+ height: 40px;
2138
+ padding: 0 16px;
2139
+ background: var(--surface);
2140
+ border-bottom: 1px solid var(--border);
2141
+ font-size: 13px;
2142
+ }
2143
+ .topbar-title {
2144
+ font-weight: 600;
2145
+ margin-right: 12px;
2146
+ white-space: nowrap;
2147
+ }
2148
+ .topbar-url {
2149
+ color: var(--text-secondary);
2150
+ overflow: hidden;
2151
+ text-overflow: ellipsis;
2152
+ white-space: nowrap;
2153
+ }
2154
+
2155
+ /* Container */
2156
+ .report {
2157
+ max-width: 960px;
2158
+ margin: 0 auto;
2159
+ padding: 0 32px;
2160
+ }
2161
+
2162
+ /* Category gauges row */
2163
+ .gauges-row {
2164
+ display: flex;
2165
+ flex-wrap: wrap;
2166
+ justify-content: center;
2167
+ align-items: flex-start;
2168
+ gap: 12px;
2169
+ padding: 24px 0;
2170
+ border-bottom: 1px solid var(--border);
2171
+ }
2172
+ .gauge-item {
2173
+ display: flex;
2174
+ flex-direction: column;
2175
+ align-items: center;
2176
+ width: 110px;
2177
+ padding: 10px 6px 8px;
2178
+ text-decoration: none;
2179
+ color: var(--text);
2180
+ border-radius: 8px;
2181
+ transition: background 0.15s;
2182
+ }
2183
+ .gauge-item:hover {
2184
+ background: #f5f5f5;
2185
+ }
2186
+ .gauge-item .gauge { display: block; }
2187
+ .gauge-label {
2188
+ margin-top: 8px;
2189
+ font-size: 11px;
2190
+ font-weight: 500;
2191
+ text-align: center;
2192
+ color: var(--text-secondary);
2193
+ line-height: 1.25;
2194
+ }
2195
+
2196
+ /* Overall score */
2197
+ .overall {
2198
+ display: flex;
2199
+ align-items: center;
2200
+ flex-direction: column;
2201
+ padding: 24px 0 20px;
2202
+ border-bottom: 1px solid var(--border);
2203
+ }
2204
+ .overall-gauge-wrap {
2205
+ display: flex;
2206
+ flex-direction: column;
2207
+ align-items: center;
2208
+ }
2209
+ .overall-gauge-wrap .gauge { display: block; }
2210
+
2211
+ /* Score scale legend */
2212
+ .score-scale {
2213
+ display: flex;
2214
+ justify-content: center;
2215
+ gap: 16px;
2216
+ margin-top: 12px;
2217
+ font-size: 11px;
2218
+ color: var(--text-secondary);
2219
+ }
2220
+ .score-scale span::before {
2221
+ content: "";
2222
+ display: inline-block;
2223
+ width: 8px;
2224
+ height: 8px;
2225
+ border-radius: 50%;
2226
+ margin-right: 4px;
2227
+ vertical-align: middle;
2228
+ }
2229
+ .scale-fail::before { background: var(--fail); }
2230
+ .scale-average::before { background: var(--average); }
2231
+ .scale-pass::before { background: var(--pass); }
2232
+
2233
+ /* Categories */
2234
+ .category {
2235
+ padding: 24px 0 16px;
2236
+ border-bottom: 1px solid var(--border);
2237
+ }
2238
+ .category:last-child { border-bottom: none; }
2239
+ .category-header {
2240
+ display: flex;
2241
+ justify-content: space-between;
2242
+ align-items: baseline;
2243
+ margin-bottom: 12px;
2244
+ }
2245
+ .category-title {
2246
+ font-size: 18px;
2247
+ font-weight: 600;
2248
+ }
2249
+ .category-title.pass { color: var(--pass-text); }
2250
+ .category-title.average { color: var(--average-text); }
2251
+ .category-title.fail { color: var(--fail-text); }
2252
+ .category-score {
2253
+ font-size: 14px;
2254
+ font-weight: 700;
2255
+ }
2256
+ .category-score.pass { color: var(--pass-text); }
2257
+ .category-score.average { color: var(--average-text); }
2258
+ .category-score.fail { color: var(--fail-text); }
2259
+
2260
+ /* Audit rows */
2261
+ .audit-row {
2262
+ display: flex;
2263
+ align-items: baseline;
2264
+ padding: 8px 0;
2265
+ border-top: 1px solid #f0f0f0;
2266
+ font-size: 13px;
2267
+ gap: 8px;
2268
+ }
2269
+ .audit-icon {
2270
+ width: 18px;
2271
+ flex-shrink: 0;
2272
+ text-align: center;
2273
+ font-size: 12px;
2274
+ }
2275
+ .audit-icon.good { color: var(--pass); }
2276
+ .audit-icon.warn { color: var(--average); }
2277
+ .audit-icon.fail { color: var(--fail); }
2278
+ .audit-icon.neutral { color: var(--text-secondary); }
2279
+ .audit-name {
2280
+ font-weight: 500;
2281
+ min-width: 180px;
2282
+ flex-shrink: 0;
2283
+ }
2284
+ .audit-detail {
2285
+ flex: 1;
2286
+ color: var(--text-secondary);
2287
+ }
2288
+ .audit-score {
2289
+ color: var(--text-secondary);
2290
+ white-space: nowrap;
2291
+ text-align: right;
2292
+ min-width: 44px;
2293
+ }
2294
+
2295
+ /* Recommendations */
2296
+ .recs-section {
2297
+ padding: 24px 0 16px;
2298
+ border-top: 1px solid var(--border);
2299
+ }
2300
+ .recs-title {
2301
+ font-size: 20px;
2302
+ font-weight: 600;
2303
+ margin-bottom: 20px;
2304
+ }
2305
+ .rec-group {
2306
+ margin-bottom: 20px;
2307
+ }
2308
+ .rec-group-name {
2309
+ font-size: 13px;
2310
+ font-weight: 600;
2311
+ text-transform: uppercase;
2312
+ letter-spacing: 0.4px;
2313
+ color: var(--text-secondary);
2314
+ padding-bottom: 6px;
2315
+ border-bottom: 1px solid var(--border);
2316
+ margin-bottom: 2px;
2317
+ }
2318
+ .rec-row {
2319
+ display: flex;
2320
+ gap: 10px;
2321
+ align-items: baseline;
2322
+ padding: 8px 0;
2323
+ border-bottom: 1px solid #f5f5f5;
2324
+ font-size: 13px;
2325
+ }
2326
+ .rec-tag {
2327
+ font-size: 9px;
2328
+ font-weight: 700;
2329
+ padding: 2px 5px;
2330
+ border-radius: 3px;
2331
+ white-space: nowrap;
2332
+ flex-shrink: 0;
2333
+ letter-spacing: 0.3px;
2334
+ }
2335
+ .priority-high .rec-tag { background: #fce8e6; color: var(--fail-text); }
2336
+ .priority-med .rec-tag { background: #fef7e0; color: var(--average-text); }
2337
+ .priority-low .rec-tag { background: #f1f3f4; color: var(--text-secondary); }
2338
+ .rec-factor {
2339
+ font-weight: 600;
2340
+ white-space: nowrap;
2341
+ flex-shrink: 0;
2342
+ }
2343
+ .rec-text { color: var(--text-secondary); }
2344
+
2345
+ /* Footer */
2346
+ .footer {
2347
+ padding: 16px 0;
2348
+ border-top: 1px solid var(--border);
2349
+ font-size: 11px;
2350
+ color: var(--text-secondary);
2351
+ display: flex;
2352
+ justify-content: space-between;
2353
+ flex-wrap: wrap;
2354
+ gap: 8px;
2355
+ }
2356
+
2357
+ @media (max-width: 600px) {
2358
+ .report { padding: 0 16px; }
2359
+ .gauges-row { gap: 4px; }
2360
+ .gauge-item { width: 80px; padding: 8px 4px; }
2361
+ .audit-row { flex-wrap: wrap; }
2362
+ .audit-name { min-width: 140px; }
2363
+ .rec-row { flex-wrap: wrap; }
2364
+ }
2365
+ </style>
2366
+ </head>
2367
+ <body>
2368
+
2369
+ <div class="topbar">
2370
+ <span class="topbar-title">AI SEO Audit</span>
2371
+ <span class="topbar-url">${escapeHtml(result.url)}</span>
2372
+ </div>
2373
+
2374
+ <div class="report">
2375
+ <div class="gauges-row">
2376
+ ${gauges}
2377
+ </div>
2378
+
2379
+ <div class="overall">
2380
+ ${overallGauge}
2381
+ </div>
2382
+
2383
+ ${sections}
2384
+
2385
+ ${recsHtml}
2386
+
2387
+ <div class="footer">
2388
+ <span>Generated by aiseo-audit v${escapeHtml(result.meta.version)}</span>
2389
+ <span>${escapeHtml(result.analyzedAt)} &middot; ${result.meta.analysisDurationMs}ms</span>
2390
+ </div>
2391
+ </div>
2392
+
2393
+ </body>
2394
+ </html>`;
2395
+ }
2396
+
2397
+ // src/modules/report/support/json.ts
2398
+ function renderJson(result) {
2399
+ return JSON.stringify(result, null, 2);
2400
+ }
2401
+
2402
+ // src/modules/report/support/markdown.ts
2403
+ function renderMarkdown(result) {
2404
+ const lines = [];
2405
+ lines.push(`# AI SEO Audit`);
2406
+ lines.push("");
2407
+ lines.push(`**URL:** ${result.url}`);
2408
+ lines.push("");
2409
+ lines.push("| Category | Score | Percentage |");
2410
+ lines.push("|----------|-------|------------|");
2411
+ for (const category of Object.values(result.categories)) {
2412
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
2413
+ lines.push(
2414
+ `| ${category.name} | ${category.score}/${category.maxScore} | ${pct}% |`
2415
+ );
2416
+ }
2417
+ lines.push("");
2418
+ lines.push(
2419
+ `## Overall: ${result.overallScore}/100 (${result.grade}) - ${result.totalPoints}/${result.maxPoints} pts`
2420
+ );
2421
+ lines.push("");
2422
+ for (const category of Object.values(result.categories)) {
2423
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
2424
+ lines.push(`### ${category.name} (${pct}%)`);
2425
+ lines.push("");
2426
+ lines.push("| Factor | Score | Status | Details |");
2427
+ lines.push("|--------|-------|--------|---------|");
2428
+ for (const factor of category.factors) {
2429
+ const statusIcon2 = factor.status === "good" ? "pass" : factor.status === "neutral" ? "-" : factor.status === "needs_improvement" ? "warn" : "fail";
2430
+ lines.push(
2431
+ `| ${factor.name} | ${factor.score}/${factor.maxScore} | ${statusIcon2} | ${factor.value} |`
2432
+ );
2433
+ }
2434
+ lines.push("");
2435
+ }
2436
+ if (result.recommendations.length > 0) {
2437
+ lines.push("## Recommendations");
2438
+ lines.push("");
2439
+ const categoryNames = Object.values(result.categories).map((c) => c.name);
2440
+ for (const categoryName of categoryNames) {
2441
+ const categoryRecs = result.recommendations.filter(
2442
+ (r) => r.category === categoryName
2443
+ );
2444
+ if (categoryRecs.length === 0) continue;
2445
+ lines.push(`### ${categoryName}`);
2446
+ lines.push("");
2447
+ for (const rec of categoryRecs) {
2448
+ const tag = rec.priority === "high" ? "**HIGH**" : rec.priority === "medium" ? "*MED*" : "LOW";
2449
+ lines.push(`- [${tag}] **${rec.factor}**: ${rec.recommendation}`);
2450
+ }
2451
+ lines.push("");
2452
+ }
2453
+ }
2454
+ lines.push("---");
2455
+ lines.push(
2456
+ `*Generated by aiseo-audit v${result.meta.version} | ${result.analyzedAt} | ${result.meta.analysisDurationMs}ms*`
2457
+ );
2458
+ return lines.join("\n");
2459
+ }
2460
+
2461
+ // src/modules/report/support/pretty.ts
2462
+ import chalk from "chalk";
2463
+ function scoreColor(score, max) {
2464
+ const pct = max > 0 ? score / max * 100 : 0;
2465
+ if (pct >= 70) return chalk.green;
2466
+ if (pct >= 40) return chalk.yellow;
2467
+ return chalk.red;
2468
+ }
2469
+ function gradeColor(grade) {
2470
+ if (grade.startsWith("A")) return chalk.green;
2471
+ if (grade.startsWith("B")) return chalk.yellow;
2472
+ return chalk.red;
2473
+ }
2474
+ function pad(str, len) {
2475
+ return str + " ".repeat(Math.max(0, len - str.length));
2476
+ }
2477
+ function dots(len) {
2478
+ return chalk.dim(".".repeat(len));
2479
+ }
2480
+ function renderPretty(result) {
2481
+ const lines = [];
2482
+ const width = 60;
2483
+ const divider = chalk.dim("=".repeat(width));
2484
+ const thinDivider = chalk.dim("-".repeat(width));
2485
+ lines.push("");
2486
+ lines.push(divider);
2487
+ lines.push(chalk.bold(" AI SEO Audit Report"));
2488
+ lines.push(chalk.dim(` ${result.url}`));
2489
+ lines.push(divider);
2490
+ lines.push("");
2491
+ const sc = scoreColor(result.overallScore, 100);
2492
+ const gc = gradeColor(result.grade);
2493
+ lines.push(
2494
+ ` Overall Score: ${sc(`${result.overallScore}/100`)} Grade: ${gc(result.grade)}`
2495
+ );
2496
+ lines.push(chalk.dim(` Points: ${result.totalPoints}/${result.maxPoints}`));
2497
+ lines.push("");
2498
+ lines.push(thinDivider);
2499
+ for (const category of Object.values(result.categories)) {
2500
+ const catColor = scoreColor(category.score, category.maxScore);
2501
+ const catPct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
2502
+ const catName = pad(category.name, 38);
2503
+ const catDots = dots(Math.max(2, 40 - category.name.length));
2504
+ lines.push("");
2505
+ lines.push(
2506
+ ` ${chalk.bold(catName)} ${catDots} ${catColor(`${category.score}/${category.maxScore}`)} ${chalk.dim(`(${catPct}%)`)}`
2507
+ );
2508
+ for (const factor of category.factors) {
2509
+ const fColor = scoreColor(factor.score, factor.maxScore);
2510
+ const fName = pad(` ${factor.name}`, 40);
2511
+ const fDots = dots(Math.max(2, 42 - factor.name.length));
2512
+ lines.push(
2513
+ ` ${chalk.dim(fName)} ${fDots} ${fColor(`${factor.score}/${factor.maxScore}`)} ${chalk.dim(factor.value)}`
2514
+ );
2515
+ }
2516
+ }
2517
+ lines.push("");
2518
+ lines.push(thinDivider);
2519
+ if (result.recommendations.length > 0) {
2520
+ lines.push("");
2521
+ lines.push(chalk.bold(" Recommendations:"));
2522
+ lines.push("");
2523
+ const top = result.recommendations.slice(0, 8);
2524
+ for (let i = 0; i < top.length; i++) {
2525
+ const rec = top[i];
2526
+ const tag = rec.priority === "high" ? chalk.red(`[HIGH]`) : rec.priority === "medium" ? chalk.yellow(`[MED] `) : chalk.dim(`[LOW] `);
2527
+ lines.push(` ${i + 1}. ${tag} ${chalk.bold(rec.factor)}`);
2528
+ lines.push(` ${chalk.dim(rec.recommendation)}`);
2529
+ lines.push("");
2530
+ }
2531
+ if (result.recommendations.length > 8) {
2532
+ lines.push(
2533
+ chalk.dim(
2534
+ ` ... and ${result.recommendations.length - 8} more recommendations. Use --out <path> and print this to a file to see the full list.`
2535
+ )
2536
+ );
2537
+ }
2538
+ }
2539
+ lines.push(divider);
2540
+ lines.push(chalk.dim(` Analyzed at: ${result.analyzedAt}`));
2541
+ lines.push(chalk.dim(` Duration: ${result.meta.analysisDurationMs}ms`));
2542
+ lines.push(divider);
2543
+ lines.push("");
2544
+ return lines.join("\n");
2545
+ }
2546
+
2547
+ // src/modules/report/service.ts
2548
+ function renderReport(result, options) {
2549
+ switch (options.format) {
2550
+ case "json":
2551
+ return renderJson(result);
2552
+ case "md":
2553
+ return renderMarkdown(result);
2554
+ case "html":
2555
+ return renderHtml(result);
2556
+ case "pretty":
2557
+ default:
2558
+ return renderPretty(result);
2559
+ }
2560
+ }
2561
+
2562
+ // src/cli.ts
2563
+ var program = new Command();
2564
+ program.name("aiseo-audit").description("Audit web pages for AI search readiness").version(VERSION).argument("<url>", "URL to audit").option("--json", "Output as JSON").option("--md", "Output as Markdown").option("--html", "Output as HTML").option("--out <path>", "Write rendered output to a file").option(
2565
+ "--fail-under <score>",
2566
+ "Exit with code 1 if score is below threshold",
2567
+ parseFloat
2568
+ ).option("--timeout <ms>", "Request timeout in milliseconds", parseInt).option("--user-agent <ua>", "Custom User-Agent string").option("--config <path>", "Path to aiseo.config.json config file").action(
2569
+ async (url, opts) => {
2570
+ try {
2571
+ if (!isValidUrl(url)) {
2572
+ console.error(`Error: Invalid URL "${url}"`);
2573
+ process.exit(2);
2574
+ }
2575
+ const config = await loadConfig(opts.config);
2576
+ const format = opts.json ? "json" : opts.md ? "md" : opts.html ? "html" : config.format;
2577
+ const timeout = opts.timeout ?? config.timeout;
2578
+ const userAgent = opts.userAgent ?? config.userAgent;
2579
+ const result = await analyzeUrl({ url, timeout, userAgent }, config);
2580
+ const output = renderReport(result, { format });
2581
+ if (opts.out) {
2582
+ await writeOutputFile(opts.out, output);
2583
+ console.error(`Results written to ${opts.out}`);
2584
+ } else {
2585
+ console.log(output);
2586
+ }
2587
+ const failUnder = opts.failUnder ?? config.failUnder;
2588
+ if (failUnder !== void 0 && result.overallScore < failUnder) {
2589
+ console.error(
2590
+ `
2591
+ Score ${result.overallScore} is below threshold ${failUnder}`
2592
+ );
2593
+ process.exit(1);
2594
+ }
2595
+ } catch (error) {
2596
+ console.error(
2597
+ "Audit failed:",
2598
+ error instanceof Error ? error.message : String(error)
2599
+ );
2600
+ process.exit(2);
2601
+ }
2602
+ }
2603
+ );
2604
+ program.parse();
2605
+ //# sourceMappingURL=cli.mjs.map