aiseo-audit 1.4.7 → 1.4.8

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.
@@ -0,0 +1,4483 @@
1
+ // src/modules/analyzer/constants.ts
2
+ var DOMAIN_SIGNAL_TIMEOUT_CAP = 5e3;
3
+ var VERSION = true ? "1.4.8" : "0.0.0";
4
+
5
+ // src/modules/fetcher/constants.ts
6
+ var MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
7
+ var DEFAULT_HEADERS = {
8
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
9
+ "Accept-Language": "en-US,en;q=0.9",
10
+ "Accept-Encoding": "gzip, deflate, br",
11
+ "Cache-Control": "no-cache"
12
+ };
13
+
14
+ // src/utils/http.ts
15
+ var FetchError = class extends Error {
16
+ code;
17
+ url;
18
+ constructor(code, url, message) {
19
+ super(message);
20
+ this.name = "FetchError";
21
+ this.code = code;
22
+ this.url = url;
23
+ }
24
+ };
25
+ function classifyFetchError(err, url) {
26
+ if (err instanceof FetchError) return err;
27
+ const msg = err instanceof Error ? err.message : String(err);
28
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : "";
29
+ const combined = `${msg} ${cause}`.toLowerCase();
30
+ if (err instanceof DOMException || err instanceof Error && err.name === "AbortError" || combined.includes("abort")) {
31
+ return new FetchError(
32
+ "TIMEOUT",
33
+ url,
34
+ `Request timed out. The server at "${new URL(url).hostname}" did not respond in time.`
35
+ );
36
+ }
37
+ if (combined.includes("getaddrinfo") || combined.includes("enotfound")) {
38
+ const hostname = new URL(url).hostname;
39
+ return new FetchError(
40
+ "DNS_FAILURE",
41
+ url,
42
+ `DNS lookup failed for "${hostname}". Check that the domain exists and is spelled correctly.`
43
+ );
44
+ }
45
+ if (combined.includes("econnrefused")) {
46
+ return new FetchError(
47
+ "CONNECTION_REFUSED",
48
+ url,
49
+ `Connection refused by "${new URL(url).hostname}". The server may be down or not accepting connections.`
50
+ );
51
+ }
52
+ if (combined.includes("cert") || combined.includes("ssl") || combined.includes("tls") || combined.includes("unable to verify")) {
53
+ return new FetchError(
54
+ "TLS_ERROR",
55
+ url,
56
+ `TLS/SSL error connecting to "${new URL(url).hostname}". The site may have an invalid or expired certificate.`
57
+ );
58
+ }
59
+ return new FetchError(
60
+ "NETWORK_ERROR",
61
+ url,
62
+ `Network error fetching "${url}": ${msg}`
63
+ );
64
+ }
65
+ async function httpGet(options) {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), options.timeout);
68
+ try {
69
+ const response = await fetch(options.url, {
70
+ method: "GET",
71
+ headers: {
72
+ "User-Agent": options.userAgent,
73
+ ...DEFAULT_HEADERS
74
+ },
75
+ signal: controller.signal,
76
+ redirect: "follow"
77
+ });
78
+ const contentLength = response.headers.get("content-length");
79
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
80
+ throw new FetchError(
81
+ "TOO_LARGE",
82
+ options.url,
83
+ `Response from "${new URL(options.url).hostname}" exceeds the ${Math.round(MAX_RESPONSE_SIZE / 1024 / 1024)}MB size limit.`
84
+ );
85
+ }
86
+ const data = await response.text();
87
+ if (data.length > MAX_RESPONSE_SIZE) {
88
+ throw new FetchError(
89
+ "TOO_LARGE",
90
+ options.url,
91
+ `Response from "${new URL(options.url).hostname}" exceeds the ${Math.round(MAX_RESPONSE_SIZE / 1024 / 1024)}MB size limit.`
92
+ );
93
+ }
94
+ const headers = {};
95
+ response.headers.forEach((value, key) => {
96
+ headers[key] = value;
97
+ });
98
+ return {
99
+ status: response.status,
100
+ data,
101
+ headers,
102
+ finalUrl: response.url
103
+ };
104
+ } catch (err) {
105
+ throw classifyFetchError(err, options.url);
106
+ } finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+ async function httpHead(options) {
111
+ const controller = new AbortController();
112
+ const timer = setTimeout(() => controller.abort(), options.timeout);
113
+ try {
114
+ const response = await fetch(options.url, {
115
+ method: "HEAD",
116
+ headers: {
117
+ "User-Agent": options.userAgent,
118
+ ...DEFAULT_HEADERS
119
+ },
120
+ signal: controller.signal,
121
+ redirect: "follow"
122
+ });
123
+ const headers = {};
124
+ response.headers.forEach((value, key) => {
125
+ headers[key] = value;
126
+ });
127
+ return {
128
+ status: response.status,
129
+ data: "",
130
+ headers,
131
+ finalUrl: response.url
132
+ };
133
+ } catch (err) {
134
+ throw classifyFetchError(err, options.url);
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+
140
+ // src/utils/url.ts
141
+ function normalizeUrl(input) {
142
+ let url = input.trim();
143
+ if (!/^https?:\/\//i.test(url)) {
144
+ url = `https://${url}`;
145
+ }
146
+ const parsed = new URL(url);
147
+ return parsed.toString().replace(/\/+$/, "");
148
+ }
149
+ function isValidUrl(input) {
150
+ try {
151
+ const url = normalizeUrl(input);
152
+ new URL(url);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ function getDomain(url) {
159
+ try {
160
+ return new URL(url).hostname;
161
+ } catch {
162
+ return url;
163
+ }
164
+ }
165
+
166
+ // src/modules/nlp/service.ts
167
+ import compromise from "compromise";
168
+
169
+ // src/modules/nlp/constants.ts
170
+ var STOPWORDS = /* @__PURE__ */ new Set([
171
+ "a",
172
+ "an",
173
+ "the",
174
+ "and",
175
+ "or",
176
+ "but",
177
+ "in",
178
+ "on",
179
+ "at",
180
+ "to",
181
+ "for",
182
+ "of",
183
+ "with",
184
+ "by",
185
+ "from",
186
+ "as",
187
+ "is",
188
+ "was",
189
+ "are",
190
+ "were",
191
+ "been",
192
+ "be",
193
+ "have",
194
+ "has",
195
+ "had",
196
+ "do",
197
+ "does",
198
+ "did",
199
+ "will",
200
+ "would",
201
+ "could",
202
+ "should",
203
+ "may",
204
+ "might",
205
+ "shall",
206
+ "can",
207
+ "need",
208
+ "must",
209
+ "that",
210
+ "which",
211
+ "who",
212
+ "whom",
213
+ "this",
214
+ "these",
215
+ "those",
216
+ "it",
217
+ "its",
218
+ "he",
219
+ "she",
220
+ "they",
221
+ "we",
222
+ "you",
223
+ "i",
224
+ "me",
225
+ "him",
226
+ "her",
227
+ "us",
228
+ "them",
229
+ "my",
230
+ "your",
231
+ "his",
232
+ "our",
233
+ "their",
234
+ "what",
235
+ "when",
236
+ "where",
237
+ "how",
238
+ "why",
239
+ "all",
240
+ "each",
241
+ "every",
242
+ "both",
243
+ "few",
244
+ "more",
245
+ "most",
246
+ "other",
247
+ "some",
248
+ "such",
249
+ "no",
250
+ "nor",
251
+ "not",
252
+ "only",
253
+ "own",
254
+ "same",
255
+ "so",
256
+ "than",
257
+ "too",
258
+ "very",
259
+ "just",
260
+ "about",
261
+ "above",
262
+ "after",
263
+ "again",
264
+ "also",
265
+ "any",
266
+ "because",
267
+ "before",
268
+ "between",
269
+ "during",
270
+ "here",
271
+ "if",
272
+ "into",
273
+ "like",
274
+ "new",
275
+ "now",
276
+ "over",
277
+ "then",
278
+ "there",
279
+ "through",
280
+ "under",
281
+ "up",
282
+ "out",
283
+ "off",
284
+ "down",
285
+ "much",
286
+ "well",
287
+ "back",
288
+ "even",
289
+ "still",
290
+ "also",
291
+ "get",
292
+ "got",
293
+ "one",
294
+ "two",
295
+ "make",
296
+ "many",
297
+ "say",
298
+ "said",
299
+ "see",
300
+ "go",
301
+ "come",
302
+ "take",
303
+ "know",
304
+ "think",
305
+ "good",
306
+ "great",
307
+ "first",
308
+ "last",
309
+ "long",
310
+ "way",
311
+ "find",
312
+ "use",
313
+ "used",
314
+ "using",
315
+ "while",
316
+ "being",
317
+ "made",
318
+ "however",
319
+ "since",
320
+ "per",
321
+ "via",
322
+ "based",
323
+ "within",
324
+ "without",
325
+ "across",
326
+ "along",
327
+ "around",
328
+ "among",
329
+ "until",
330
+ "another",
331
+ "www",
332
+ "http",
333
+ "https",
334
+ "com"
335
+ ]);
336
+ var ACRONYM_STOPLIST = /* @__PURE__ */ new Set([
337
+ "I",
338
+ "A",
339
+ "OK",
340
+ "AM",
341
+ "PM",
342
+ "US",
343
+ "UK",
344
+ "EU",
345
+ "VS",
346
+ "EG",
347
+ "IE",
348
+ "ET",
349
+ "AL",
350
+ "HTML",
351
+ "CSS",
352
+ "JS",
353
+ "TS",
354
+ "URL",
355
+ "HTTP",
356
+ "HTTPS",
357
+ "API",
358
+ "SDK",
359
+ "CLI",
360
+ "GUI",
361
+ "PDF",
362
+ "CSV",
363
+ "JSON",
364
+ "XML",
365
+ "SQL",
366
+ "RSS",
367
+ "FTP",
368
+ "SSH",
369
+ "SSL",
370
+ "TLS",
371
+ "DNS",
372
+ "TCP",
373
+ "UDP",
374
+ "IP",
375
+ "RAM",
376
+ "ROM",
377
+ "CPU",
378
+ "GPU",
379
+ "SSD",
380
+ "HDD",
381
+ "USB",
382
+ "HDMI",
383
+ "FAQ",
384
+ "DIY",
385
+ "ASAP",
386
+ "FYI",
387
+ "TBD",
388
+ "TBA",
389
+ "ETA",
390
+ "ROI",
391
+ "KPI",
392
+ "CEO",
393
+ "CTO",
394
+ "CFO",
395
+ "COO",
396
+ "CIO",
397
+ "VP",
398
+ "SVP",
399
+ "EVP",
400
+ "HR",
401
+ "PR",
402
+ "QA",
403
+ "IT",
404
+ "RD",
405
+ "RND",
406
+ "LLC",
407
+ "INC",
408
+ "LTD",
409
+ "CORP",
410
+ "PLC",
411
+ "USD",
412
+ "EUR",
413
+ "GBP",
414
+ "JPY",
415
+ "CAD",
416
+ "ID",
417
+ "NO",
418
+ "RE",
419
+ "CC",
420
+ "BCC",
421
+ "GEO",
422
+ "SEO",
423
+ "SEM",
424
+ "PPC",
425
+ "CMS",
426
+ "CRM",
427
+ "ERP",
428
+ "SaaS",
429
+ "AI",
430
+ "ML",
431
+ "NLP",
432
+ "LLM",
433
+ "GPT",
434
+ "NER",
435
+ "TLDR",
436
+ "AKA",
437
+ "RSVP",
438
+ "PS"
439
+ ]);
440
+ var ORG_SUFFIXES = /\b(?:Inc|Corp|Corporation|LLC|Ltd|Limited|Co|Company|Group|Foundation|Institute|University|Association|Society|Agency|Authority|Bureau|Commission|Council|Department|Board|Trust|Fund|Partners|Ventures|Labs|Technologies|Solutions|Systems|Services|Consulting|Media|Network|Studios|Entertainment|Healthcare|Pharmaceuticals|Dynamics|Holdings|Capital|Enterprises|International)\b/i;
441
+ var PERSON_HONORIFICS = /\b(?:Mr|Mrs|Ms|Miss|Dr|Prof|Professor|Rev|Reverend|Sen|Senator|Rep|Representative|Gov|Governor|Pres|President|Gen|General|Col|Colonel|Sgt|Sergeant|Cpl|Corporal|Pvt|Private|Adm|Admiral|Capt|Captain|Lt|Lieutenant|Maj|Major|Sir|Dame|Lord|Lady|Hon|Honorable|Judge|Justice|Chancellor|Dean|Provost)\.\s*/;
442
+
443
+ // src/modules/nlp/support/entities.ts
444
+ function extractAcronymEntities(text) {
445
+ const matches = text.match(/\b[A-Z]{2,6}\b/g);
446
+ if (!matches) return [];
447
+ const seen = /* @__PURE__ */ new Set();
448
+ const results = [];
449
+ for (const m of matches) {
450
+ if (!ACRONYM_STOPLIST.has(m) && !seen.has(m)) {
451
+ seen.add(m);
452
+ results.push(m);
453
+ }
454
+ }
455
+ return results;
456
+ }
457
+ function extractTitleCaseEntities(text) {
458
+ const pattern = /\b([A-Z][a-z]+(?:\s+(?:of|the|and|for|de|van|von|al|el|la|le|del|der|den|das|di|du))?\s+(?:[A-Z][a-z]+)(?:\s+[A-Z][a-z]+){0,3})\b/g;
459
+ const sentences = text.split(/[.!?]\s+/);
460
+ const sentenceStarts = /* @__PURE__ */ new Set();
461
+ for (const s of sentences) {
462
+ const trimmed = s.trim();
463
+ const firstWord = trimmed.split(/\s+/)[0];
464
+ if (firstWord) sentenceStarts.add(firstWord);
465
+ }
466
+ const seen = /* @__PURE__ */ new Set();
467
+ const results = [];
468
+ let match;
469
+ while ((match = pattern.exec(text)) !== null) {
470
+ const entity = match[1];
471
+ const firstWord = entity.split(/\s+/)[0];
472
+ if (sentenceStarts.has(firstWord) && !text.includes(`. ${entity}`) && !text.includes(`, ${entity}`)) {
473
+ const escapedEntity = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
474
+ const appearances = text.match(new RegExp(escapedEntity, "g"));
475
+ if (!appearances || appearances.length < 2) continue;
476
+ }
477
+ if (!seen.has(entity) && entity.split(/\s+/).length >= 2) {
478
+ seen.add(entity);
479
+ results.push(entity);
480
+ }
481
+ }
482
+ return results;
483
+ }
484
+ function isOrganizationByPattern(entity) {
485
+ return ORG_SUFFIXES.test(entity);
486
+ }
487
+ function isPersonByHonorific(text, entity) {
488
+ const escapedEntity = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
489
+ const pattern = new RegExp(
490
+ `(?:${PERSON_HONORIFICS.source})\\s*${escapedEntity}`,
491
+ "i"
492
+ );
493
+ return pattern.test(text);
494
+ }
495
+ function smartDedup(entities) {
496
+ if (entities.length === 0) return [];
497
+ const sorted = [...entities].sort((a, b) => b.length - a.length);
498
+ const result = [];
499
+ const lowerSeen = /* @__PURE__ */ new Set();
500
+ for (const entity of sorted) {
501
+ const lower = entity.toLowerCase();
502
+ if (lowerSeen.has(lower)) continue;
503
+ let isSubstring = false;
504
+ for (const accepted of lowerSeen) {
505
+ if (accepted.includes(lower)) {
506
+ isSubstring = true;
507
+ break;
508
+ }
509
+ }
510
+ if (isSubstring) continue;
511
+ result.push(entity);
512
+ lowerSeen.add(lower);
513
+ }
514
+ return result;
515
+ }
516
+ function mergeEntityLists(compromiseList, supplementalList, limit) {
517
+ const combined = [...compromiseList, ...supplementalList];
518
+ return smartDedup(combined).slice(0, limit);
519
+ }
520
+
521
+ // src/modules/nlp/support/topics.ts
522
+ function extractTopicsByTfIdf(text, limit) {
523
+ const lower = text.toLowerCase();
524
+ const words = lower.replace(/[^a-z0-9\s'-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
525
+ if (words.length === 0) return [];
526
+ const freq = /* @__PURE__ */ new Map();
527
+ for (const w of words) {
528
+ freq.set(w, (freq.get(w) || 0) + 1);
529
+ }
530
+ for (let i = 0; i < words.length - 1; i++) {
531
+ const bigram = `${words[i]} ${words[i + 1]}`;
532
+ freq.set(bigram, (freq.get(bigram) || 0) + 1);
533
+ }
534
+ const candidates = [];
535
+ for (const [term, count] of freq) {
536
+ if (count >= 2) {
537
+ const isBigram = term.includes(" ");
538
+ const score = isBigram ? count * 1.5 : count;
539
+ candidates.push([term, score]);
540
+ }
541
+ }
542
+ candidates.sort((a, b) => b[1] - a[1]);
543
+ return candidates.slice(0, limit).map(([term]) => term);
544
+ }
545
+
546
+ // src/modules/nlp/support/patterns.ts
547
+ function countPatternMatches(text, patterns) {
548
+ let count = 0;
549
+ for (const pattern of patterns) {
550
+ const re = new RegExp(pattern.source, pattern.flags);
551
+ const matches = text.match(re);
552
+ if (matches) count += matches.length;
553
+ }
554
+ return count;
555
+ }
556
+ function countTransitionWords(text, words) {
557
+ const lower = text.toLowerCase();
558
+ return words.filter((w) => lower.includes(w)).length;
559
+ }
560
+
561
+ // src/utils/strings.ts
562
+ function countWords(text) {
563
+ return text.split(/\s+/).filter((w) => w.length > 0).length;
564
+ }
565
+ function countSentences(text) {
566
+ return text.split(/[.!?]+/).filter((s) => s.trim().length > 5).length;
567
+ }
568
+ function countSyllables(word) {
569
+ word = word.toLowerCase().replace(/[^a-z]/g, "");
570
+ if (word.length <= 3) return 1;
571
+ word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "");
572
+ word = word.replace(/^y/, "");
573
+ const matches = word.match(/[aeiouy]{1,2}/g);
574
+ return matches ? Math.max(matches.length, 1) : 1;
575
+ }
576
+
577
+ // src/modules/nlp/support/readability.ts
578
+ function computeFleschReadingEase(text) {
579
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
580
+ const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 5);
581
+ const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
582
+ if (words.length === 0 || sentences.length === 0) return 0;
583
+ const avgSentenceLength2 = words.length / sentences.length;
584
+ const avgSyllablesPerWord = totalSyllables / words.length;
585
+ return 206.835 - 1.015 * avgSentenceLength2 - 84.6 * avgSyllablesPerWord;
586
+ }
587
+ function countComplexWords(text) {
588
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
589
+ return words.filter((w) => countSyllables(w) >= 4).length;
590
+ }
591
+ function avgSentenceLength(text) {
592
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
593
+ const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 5);
594
+ if (sentences.length === 0) return 0;
595
+ return Math.round(words.length / sentences.length);
596
+ }
597
+
598
+ // src/modules/nlp/service.ts
599
+ function extractEntities(text) {
600
+ const doc = compromise(text);
601
+ const compromisePeople = [...new Set(doc.people().out("array"))];
602
+ const compromiseOrgs = [
603
+ ...new Set(doc.organizations().out("array"))
604
+ ];
605
+ const compromisePlaces = [...new Set(doc.places().out("array"))];
606
+ const acronyms = extractAcronymEntities(text);
607
+ const titleCaseEntities = extractTitleCaseEntities(text);
608
+ const supplementalPeople = [];
609
+ const supplementalOrgs = [];
610
+ const unclassified = [];
611
+ for (const entity of titleCaseEntities) {
612
+ if (isPersonByHonorific(text, entity)) {
613
+ supplementalPeople.push(entity);
614
+ } else if (isOrganizationByPattern(entity)) {
615
+ supplementalOrgs.push(entity);
616
+ } else {
617
+ unclassified.push(entity);
618
+ }
619
+ }
620
+ unclassified.push(...acronyms);
621
+ const people = mergeEntityLists(compromisePeople, supplementalPeople, 10);
622
+ const organizations = mergeEntityLists(
623
+ compromiseOrgs,
624
+ [...supplementalOrgs, ...unclassified],
625
+ 10
626
+ );
627
+ const places = smartDedup([...new Set(compromisePlaces)]).slice(0, 10);
628
+ const topics = extractTopicsByTfIdf(text, 15);
629
+ const imperativeVerbCount = doc.verbs().isImperative().length;
630
+ const numberCount = doc.numbers().length;
631
+ return {
632
+ people,
633
+ organizations,
634
+ places,
635
+ topics,
636
+ imperativeVerbCount,
637
+ numberCount
638
+ };
639
+ }
640
+
641
+ // src/modules/audits/constants.ts
642
+ var CATEGORY_DISPLAY_NAMES = {
643
+ contentExtractability: "Content Extractability",
644
+ contentStructure: "Content Structure for Reuse",
645
+ answerability: "Answerability",
646
+ entityClarity: "Entity Clarity",
647
+ groundingSignals: "Grounding Signals",
648
+ authorityContext: "Authority Context",
649
+ readabilityForCompression: "Readability for Compression"
650
+ };
651
+
652
+ // src/modules/audits/support/patterns.ts
653
+ var DEFINITION_PATTERNS = [
654
+ /\bis\s+defined\s+as\b/gi,
655
+ /\brefers?\s+to\b/gi,
656
+ /\bmeans?\s+that\b/gi,
657
+ /\bis\s+a\s+type\s+of\b/gi,
658
+ /\bcan\s+be\s+described\s+as\b/gi,
659
+ /\balso\s+known\s+as\b/gi
660
+ ];
661
+ var CITATION_PATTERNS = [
662
+ /\[\d+\]/g,
663
+ /\([\w\s]+,?\s*\d{4}\)/g,
664
+ /according\s+to/gi,
665
+ /research\s+(?:shows|indicates|suggests)/gi,
666
+ /studies?\s+(?:show|indicate|suggest|found)/gi,
667
+ /data\s+from/gi,
668
+ /as\s+reported\s+by/gi
669
+ ];
670
+ var ATTRIBUTION_PATTERNS = [
671
+ /according\s+to/gi,
672
+ /\bsaid\b/gi,
673
+ /\bstated\b/gi,
674
+ /\breported\b/gi,
675
+ /\bcited\s+by\b/gi
676
+ ];
677
+ var NUMERIC_CLAIM_PATTERNS = [
678
+ /\d+(?:\.\d+)?\s*%/g,
679
+ /\d+(?:\.\d+)?\s*(?:million|billion|thousand|trillion)/gi,
680
+ /\$[\d,.]+/g,
681
+ /increased\s+by/gi,
682
+ /decreased\s+by/gi,
683
+ /grew\s+by/gi
684
+ ];
685
+ var STEP_PATTERNS = [
686
+ /step\s+\d+/gi,
687
+ /^\s*\d+\.\s+\w/gm,
688
+ /\bfirst(?:ly)?,?\s/gi,
689
+ /\bsecond(?:ly)?,?\s/gi,
690
+ /\bfinally,?\s/gi,
691
+ /\bhow\s+to\b/gi
692
+ ];
693
+ var SUMMARY_MARKERS = [
694
+ /\bin\s+summary\b/gi,
695
+ /\bin\s+conclusion\b/gi,
696
+ /\bto\s+summarize\b/gi,
697
+ /\bkey\s+takeaways?\b/gi,
698
+ /\bbottom\s+line\b/gi,
699
+ /\btl;?dr\b/gi
700
+ ];
701
+ var QUESTION_PATTERNS = [
702
+ /what\s+is/gi,
703
+ /what\s+are/gi,
704
+ /how\s+to/gi,
705
+ /how\s+do/gi,
706
+ /why\s+is/gi,
707
+ /why\s+do/gi,
708
+ /when\s+to/gi,
709
+ /where\s+to/gi,
710
+ /which\s+is/gi,
711
+ /who\s+is/gi
712
+ ];
713
+ var DIRECT_ANSWER_PATTERNS = [
714
+ /^The\s+\w+\s+is\b/gm,
715
+ /^It\s+is\b/gm,
716
+ /^This\s+is\b/gm,
717
+ /^They\s+are\b/gm,
718
+ /\bsimply\s+put\b/gi,
719
+ /\bin\s+short\b/gi
720
+ ];
721
+ var TRANSITION_WORDS = [
722
+ "however",
723
+ "therefore",
724
+ "moreover",
725
+ "furthermore",
726
+ "consequently",
727
+ "additionally",
728
+ "in contrast",
729
+ "similarly",
730
+ "as a result",
731
+ "for example",
732
+ "for instance",
733
+ "on the other hand",
734
+ "nevertheless",
735
+ "meanwhile",
736
+ "likewise",
737
+ "in addition",
738
+ "specifically",
739
+ "in particular",
740
+ "notably",
741
+ "importantly"
742
+ ];
743
+ var AUTHOR_SELECTORS = [
744
+ '[rel="author"]',
745
+ ".author",
746
+ ".byline",
747
+ '[itemprop="author"]',
748
+ ".post-author",
749
+ ".entry-author",
750
+ 'meta[name="author"]'
751
+ ];
752
+ var DATE_SELECTORS = [
753
+ "time[datetime]",
754
+ '[itemprop="datePublished"]',
755
+ '[itemprop="dateModified"]',
756
+ ".published",
757
+ ".post-date",
758
+ ".entry-date",
759
+ 'meta[property="article:published_time"]',
760
+ 'meta[property="article:modified_time"]'
761
+ ];
762
+ var QUESTION_HEADING_PATTERN = /^(?:what|how|why|when|where|which|who|can|do|does|is|are|should|will)\b/i;
763
+ var QUOTED_ATTRIBUTION_PATTERNS = [
764
+ /"[^"]{10,}"\s*[-\u2013\u2014]\s*[A-Z][a-z]+/g,
765
+ /"[^"]{10,}",?\s+said\s+[A-Z]/g,
766
+ /"[^"]{10,}",?\s+according\s+to\s+[A-Z]/g,
767
+ /according\s+to\s+[A-Z][a-z]+[^,]*,\s*"[^"]{10,}"/g,
768
+ /\u201c[^\u201d]{10,}\u201d\s*[-\u2013\u2014]\s*[A-Z][a-z]+/g,
769
+ /\u201c[^\u201d]{10,}\u201d,?\s+said\s+[A-Z]/g
770
+ ];
771
+ var AI_CRAWLERS = [
772
+ "GPTBot",
773
+ "ChatGPT-User",
774
+ "ClaudeBot",
775
+ "PerplexityBot",
776
+ "Google-Extended"
777
+ ];
778
+ var MODIFIED_DATE_SELECTORS = [
779
+ '[itemprop="dateModified"]',
780
+ 'meta[property="article:modified_time"]'
781
+ ];
782
+ var PUBLISH_DATE_SELECTORS = [
783
+ "time[datetime]",
784
+ '[itemprop="datePublished"]',
785
+ 'meta[property="article:published_time"]'
786
+ ];
787
+
788
+ // src/modules/audits/support/dom.ts
789
+ function detectAnswerCapsules($) {
790
+ let total = 0;
791
+ let withCapsule = 0;
792
+ $("h2").each((_, el) => {
793
+ const headingText = $(el).text().trim();
794
+ const isQuestion = headingText.includes("?") || QUESTION_HEADING_PATTERN.test(headingText);
795
+ if (!isQuestion) return;
796
+ total++;
797
+ const nextP = $(el).nextAll("p").first();
798
+ if (!nextP.length) return;
799
+ const pText = nextP.text().trim();
800
+ const firstSentence = pText.split(/[.!?]/)[0] || "";
801
+ if (firstSentence.length > 0 && firstSentence.length <= 200) {
802
+ withCapsule++;
803
+ }
804
+ });
805
+ return { total, withCapsule };
806
+ }
807
+ function measureSectionLengths($) {
808
+ const headings = $("h1, h2, h3, h4, h5, h6");
809
+ if (headings.length === 0)
810
+ return { sectionCount: 0, avgWordsPerSection: 0, sections: [] };
811
+ const sections = [];
812
+ headings.each((_, el) => {
813
+ let words = 0;
814
+ let sibling = $(el).next();
815
+ while (sibling.length && !sibling.is("h1, h2, h3, h4, h5, h6")) {
816
+ const text = sibling.text().trim();
817
+ words += text.split(/\s+/).filter((w) => w.length > 0).length;
818
+ sibling = sibling.next();
819
+ }
820
+ if (words > 0) sections.push(words);
821
+ });
822
+ const avg = sections.length > 0 ? Math.round(sections.reduce((a, b) => a + b, 0) / sections.length) : 0;
823
+ return { sectionCount: sections.length, avgWordsPerSection: avg, sections };
824
+ }
825
+ function parseJsonLdObjects($) {
826
+ const objects = [];
827
+ $('script[type="application/ld+json"]').each((_, el) => {
828
+ try {
829
+ const data = JSON.parse($(el).html() || "{}");
830
+ if (Array.isArray(data)) objects.push(...data);
831
+ else objects.push(data);
832
+ } catch {
833
+ }
834
+ });
835
+ return objects;
836
+ }
837
+
838
+ // src/modules/audits/support/scoring.ts
839
+ function thresholdScore(value, brackets) {
840
+ for (const [threshold, score] of brackets) {
841
+ if (value >= threshold) return score;
842
+ }
843
+ return 0;
844
+ }
845
+ function statusFromScore(score, maxScore) {
846
+ const pct = maxScore > 0 ? score / maxScore : 0;
847
+ if (pct >= 0.7) return "good";
848
+ if (pct >= 0.3) return "needs_improvement";
849
+ return "critical";
850
+ }
851
+ function makeFactor(name, score, maxScore, value, statusOverride) {
852
+ return {
853
+ name,
854
+ score: Math.round(Math.min(score, maxScore)),
855
+ maxScore,
856
+ value,
857
+ status: statusOverride ?? statusFromScore(score, maxScore)
858
+ };
859
+ }
860
+ function sumFactors(factors) {
861
+ return factors.reduce((sum, f) => sum + f.score, 0);
862
+ }
863
+ function maxFactors(factors) {
864
+ return factors.reduce((sum, f) => sum + f.maxScore, 0);
865
+ }
866
+
867
+ // src/modules/audits/categories/answerability.ts
868
+ function auditAnswerability(page, preExtracted) {
869
+ const text = page.cleanText;
870
+ const $ = page.$;
871
+ const factors = [];
872
+ const { imperativeVerbCount = 0 } = preExtracted ?? extractEntities(text);
873
+ const defCount = countPatternMatches(text, DEFINITION_PATTERNS);
874
+ const defScore = thresholdScore(defCount, [
875
+ [6, 10],
876
+ [3, 7],
877
+ [1, 4],
878
+ [0, 0]
879
+ ]);
880
+ factors.push(
881
+ makeFactor(
882
+ "Definition Patterns",
883
+ defScore,
884
+ 10,
885
+ `${defCount} definition patterns`
886
+ )
887
+ );
888
+ const directCount = countPatternMatches(text, DIRECT_ANSWER_PATTERNS);
889
+ const directScore = thresholdScore(directCount, [
890
+ [5, 11],
891
+ [2, 8],
892
+ [1, 4],
893
+ [0, 0]
894
+ ]);
895
+ factors.push(
896
+ makeFactor(
897
+ "Direct Answer Statements",
898
+ directScore,
899
+ 11,
900
+ `${directCount} direct statements`
901
+ )
902
+ );
903
+ const capsules = detectAnswerCapsules(page.$);
904
+ const capsuleRatio = capsules.total > 0 ? capsules.withCapsule / capsules.total : 0;
905
+ const capsuleScore = capsules.total === 0 ? 0 : capsuleRatio >= 0.7 ? 13 : capsuleRatio >= 0.4 ? 9 : capsuleRatio > 0 ? 5 : 2;
906
+ factors.push(
907
+ makeFactor(
908
+ "Answer Capsules",
909
+ capsuleScore,
910
+ 13,
911
+ capsules.total > 0 ? `${capsules.withCapsule}/${capsules.total} question headings have answer capsules` : "No question-framed H2s found",
912
+ capsules.total === 0 ? "neutral" : void 0
913
+ )
914
+ );
915
+ const stepCount = countPatternMatches(text, STEP_PATTERNS);
916
+ const hasOl = $("ol").length > 0;
917
+ const stepTotal = stepCount + imperativeVerbCount + (hasOl ? 2 : 0);
918
+ const stepScore = thresholdScore(stepTotal, [
919
+ [5, 10],
920
+ [2, 7],
921
+ [1, 3],
922
+ [0, 0]
923
+ ]);
924
+ factors.push(
925
+ makeFactor(
926
+ "Step-by-Step Content",
927
+ stepScore,
928
+ 10,
929
+ `${stepCount} step indicators, ${imperativeVerbCount} instruction verbs${hasOl ? ", ordered lists found" : ""}`
930
+ )
931
+ );
932
+ const questionMatches = text.match(/[^.!?]*\?/g) || [];
933
+ const queryMatches = countPatternMatches(text, QUESTION_PATTERNS);
934
+ const qaScore = thresholdScore(questionMatches.length + queryMatches, [
935
+ [10, 11],
936
+ [5, 8],
937
+ [2, 5],
938
+ [1, 2],
939
+ [0, 0]
940
+ ]);
941
+ factors.push(
942
+ makeFactor(
943
+ "Q/A Patterns",
944
+ qaScore,
945
+ 11,
946
+ `${questionMatches.length} questions, ${queryMatches} query patterns`
947
+ )
948
+ );
949
+ const summaryCount = countPatternMatches(text, SUMMARY_MARKERS);
950
+ const summaryScore = summaryCount >= 2 ? 9 : summaryCount > 0 ? 5 : 0;
951
+ factors.push(
952
+ makeFactor(
953
+ "Summary/Conclusion",
954
+ summaryScore,
955
+ 9,
956
+ summaryCount > 0 ? `${summaryCount} summary markers` : "No summary markers"
957
+ )
958
+ );
959
+ return {
960
+ category: {
961
+ name: CATEGORY_DISPLAY_NAMES.answerability,
962
+ key: "answerability",
963
+ score: sumFactors(factors),
964
+ maxScore: maxFactors(factors),
965
+ factors
966
+ },
967
+ rawData: {
968
+ answerCapsules: capsules,
969
+ questionsFound: questionMatches.slice(0, 5)
970
+ }
971
+ };
972
+ }
973
+
974
+ // src/modules/audits/support/entity.ts
975
+ function resolveEntityName($) {
976
+ const ogSiteName = $('meta[property="og:site_name"]').attr("content")?.trim();
977
+ if (ogSiteName) return ogSiteName;
978
+ const jsonLdScripts = $('script[type="application/ld+json"]');
979
+ let orgName = null;
980
+ jsonLdScripts.each((_, el) => {
981
+ try {
982
+ const data = JSON.parse($(el).html() || "{}");
983
+ if (data["@type"] === "Organization" && data.name) {
984
+ orgName = String(data.name).trim();
985
+ }
986
+ if (data.publisher?.name) {
987
+ orgName = orgName || String(data.publisher.name).trim();
988
+ }
989
+ } catch {
990
+ }
991
+ });
992
+ return orgName || null;
993
+ }
994
+ function measureEntityConsistency($, pageTitle, entityName) {
995
+ if (!entityName) return { score: 0, surfacesFound: 0, surfacesChecked: 0 };
996
+ const nameLower = entityName.toLowerCase();
997
+ const surfacesChecked = 4;
998
+ let surfacesFound = 0;
999
+ if (pageTitle.toLowerCase().includes(nameLower)) surfacesFound++;
1000
+ const ogTitle = $('meta[property="og:title"]').attr("content") || "";
1001
+ if (ogTitle.toLowerCase().includes(nameLower)) surfacesFound++;
1002
+ const footerText = $("footer").text().toLowerCase();
1003
+ if (footerText.includes(nameLower)) surfacesFound++;
1004
+ const copyrightText = $('[class*="copyright"], [class*="legal"]').text().toLowerCase();
1005
+ const headerText = $("header").text().toLowerCase();
1006
+ if (copyrightText.includes(nameLower) || headerText.includes(nameLower))
1007
+ surfacesFound++;
1008
+ const score = surfacesFound >= 4 ? 10 : surfacesFound >= 3 ? 7 : surfacesFound >= 2 ? 4 : surfacesFound >= 1 ? 2 : 0;
1009
+ return { score, surfacesFound, surfacesChecked };
1010
+ }
1011
+
1012
+ // src/modules/audits/support/freshness.ts
1013
+ function evaluateFreshness($) {
1014
+ let modifiedDate = null;
1015
+ let publishDate = null;
1016
+ for (const sel of MODIFIED_DATE_SELECTORS) {
1017
+ const el = $(sel).first();
1018
+ if (el.length) {
1019
+ modifiedDate = el.attr("datetime") || el.attr("content") || el.text().trim();
1020
+ break;
1021
+ }
1022
+ }
1023
+ for (const sel of PUBLISH_DATE_SELECTORS) {
1024
+ const el = $(sel).first();
1025
+ if (el.length) {
1026
+ publishDate = el.attr("datetime") || el.attr("content") || el.text().trim();
1027
+ break;
1028
+ }
1029
+ }
1030
+ const mostRecent = modifiedDate || publishDate;
1031
+ let ageInMonths = null;
1032
+ if (mostRecent) {
1033
+ const parsed = new Date(mostRecent);
1034
+ if (!isNaN(parsed.getTime())) {
1035
+ const now = /* @__PURE__ */ new Date();
1036
+ ageInMonths = (now.getFullYear() - parsed.getFullYear()) * 12 + (now.getMonth() - parsed.getMonth());
1037
+ }
1038
+ }
1039
+ return {
1040
+ publishDate,
1041
+ modifiedDate,
1042
+ ageInMonths,
1043
+ hasModifiedDate: !!modifiedDate
1044
+ };
1045
+ }
1046
+
1047
+ // src/modules/audits/support/schema-analysis.ts
1048
+ var SCHEMA_REQUIRED_PROPERTIES = {
1049
+ Article: ["headline", "author", "datePublished"],
1050
+ NewsArticle: ["headline", "author", "datePublished"],
1051
+ BlogPosting: ["headline", "author", "datePublished"],
1052
+ FAQPage: ["mainEntity"],
1053
+ HowTo: ["name", "step"],
1054
+ Organization: ["name", "url"],
1055
+ LocalBusiness: ["name", "address"],
1056
+ Product: ["name"],
1057
+ WebPage: ["name"]
1058
+ };
1059
+ function evaluateSchemaCompleteness(schemas) {
1060
+ const details = [];
1061
+ for (const schema of schemas) {
1062
+ const type = String(schema["@type"] || "");
1063
+ const requiredProps = SCHEMA_REQUIRED_PROPERTIES[type];
1064
+ if (!requiredProps) continue;
1065
+ const present = requiredProps.filter((prop) => schema[prop] != null);
1066
+ const missing = requiredProps.filter((prop) => schema[prop] == null);
1067
+ details.push({ type, present, missing });
1068
+ }
1069
+ const avgCompleteness = details.length > 0 ? details.reduce(
1070
+ (sum, d) => sum + d.present.length / (d.present.length + d.missing.length),
1071
+ 0
1072
+ ) / details.length : 0;
1073
+ return { totalTypes: details.length, avgCompleteness, details };
1074
+ }
1075
+
1076
+ // src/modules/audits/categories/authority-context.ts
1077
+ function auditAuthorityContext(page) {
1078
+ const $ = page.$;
1079
+ const factors = [];
1080
+ const rawData = {};
1081
+ let authorFound = false;
1082
+ let authorName = "";
1083
+ for (const selector of AUTHOR_SELECTORS) {
1084
+ const elem = $(selector).first();
1085
+ if (elem.length) {
1086
+ authorFound = true;
1087
+ authorName = elem.text().trim() || elem.attr("content") || "Found";
1088
+ break;
1089
+ }
1090
+ }
1091
+ factors.push(
1092
+ makeFactor(
1093
+ "Author Attribution",
1094
+ authorFound ? 10 : 0,
1095
+ 10,
1096
+ authorFound ? authorName : "Not found"
1097
+ )
1098
+ );
1099
+ const hasOrgSchema = page.html.includes('"@type":"Organization"') || page.html.includes('"@type": "Organization"');
1100
+ const ogSiteName = $('meta[property="og:site_name"]').attr("content") || "";
1101
+ const orgFound = hasOrgSchema || ogSiteName.length > 0;
1102
+ factors.push(
1103
+ makeFactor(
1104
+ "Organization Identity",
1105
+ orgFound ? 10 : 0,
1106
+ 10,
1107
+ orgFound ? ogSiteName || "Schema found" : "Not found"
1108
+ )
1109
+ );
1110
+ const aboutLink = $('a[href*="about"], a[href*="team"], a[href*="company"]').length > 0;
1111
+ const contactLink = $('a[href*="contact"]').length > 0;
1112
+ const contactScore = aboutLink && contactLink ? 10 : aboutLink || contactLink ? 5 : 0;
1113
+ factors.push(
1114
+ makeFactor(
1115
+ "Contact/About Links",
1116
+ contactScore,
1117
+ 10,
1118
+ `${aboutLink ? "About" : ""}${aboutLink && contactLink ? " + " : ""}${contactLink ? "Contact" : ""}${!aboutLink && !contactLink ? "Not found" : ""}`
1119
+ )
1120
+ );
1121
+ let dateFound = false;
1122
+ let dateValue = "";
1123
+ for (const selector of DATE_SELECTORS) {
1124
+ const elem = $(selector).first();
1125
+ if (elem.length) {
1126
+ dateFound = true;
1127
+ dateValue = elem.attr("datetime") || elem.attr("content") || elem.text().trim();
1128
+ break;
1129
+ }
1130
+ }
1131
+ factors.push(
1132
+ makeFactor(
1133
+ "Publication Date",
1134
+ dateFound ? 8 : 0,
1135
+ 8,
1136
+ dateFound ? dateValue : "Not found"
1137
+ )
1138
+ );
1139
+ const freshness = evaluateFreshness(page.$);
1140
+ let freshScore = 0;
1141
+ if (freshness.ageInMonths !== null) {
1142
+ if (freshness.ageInMonths <= 6) freshScore = 12;
1143
+ else if (freshness.ageInMonths <= 12) freshScore = 9;
1144
+ else if (freshness.ageInMonths <= 24) freshScore = 5;
1145
+ else freshScore = 2;
1146
+ if (freshness.hasModifiedDate && freshScore < 12)
1147
+ freshScore = Math.min(freshScore + 2, 12);
1148
+ }
1149
+ factors.push(
1150
+ makeFactor(
1151
+ "Content Freshness",
1152
+ freshScore,
1153
+ 12,
1154
+ freshness.ageInMonths !== null ? `${freshness.ageInMonths} months old${freshness.hasModifiedDate ? ", modified date present" : ""}` : "No parseable date found"
1155
+ )
1156
+ );
1157
+ rawData.freshness = freshness;
1158
+ const jsonLdScripts = $('script[type="application/ld+json"]');
1159
+ const structuredDataTypes = [];
1160
+ jsonLdScripts.each((_, el) => {
1161
+ try {
1162
+ const data = JSON.parse($(el).html() || "{}");
1163
+ if (data["@type"]) structuredDataTypes.push(data["@type"]);
1164
+ } catch {
1165
+ }
1166
+ });
1167
+ const ogTags = ["og:title", "og:description", "og:image", "og:type"];
1168
+ const foundOgTags = ogTags.filter(
1169
+ (tag) => $(`meta[property="${tag}"]`).length > 0
1170
+ );
1171
+ const canonical = $('link[rel="canonical"]').attr("href");
1172
+ let structuredScore = 0;
1173
+ if (structuredDataTypes.length > 0) structuredScore += 4;
1174
+ if (foundOgTags.length >= 3) structuredScore += 4;
1175
+ else if (foundOgTags.length > 0) structuredScore += 2;
1176
+ if (canonical) structuredScore += 4;
1177
+ rawData.structuredDataTypes = structuredDataTypes;
1178
+ factors.push(
1179
+ makeFactor(
1180
+ "Structured Data",
1181
+ structuredScore,
1182
+ 12,
1183
+ `${structuredDataTypes.length > 0 ? structuredDataTypes.join(", ") : "No JSON-LD"}, ${foundOgTags.length}/4 OG tags${canonical ? ", canonical" : ""}`
1184
+ )
1185
+ );
1186
+ const schemaObjects = parseJsonLdObjects(page.$);
1187
+ const completeness = evaluateSchemaCompleteness(schemaObjects);
1188
+ const schemaCompleteScore = completeness.totalTypes === 0 ? 0 : completeness.avgCompleteness >= 0.8 ? 10 : completeness.avgCompleteness >= 0.5 ? 7 : completeness.avgCompleteness > 0 ? 4 : 0;
1189
+ factors.push(
1190
+ makeFactor(
1191
+ "Schema Completeness",
1192
+ schemaCompleteScore,
1193
+ 10,
1194
+ completeness.totalTypes > 0 ? `${completeness.totalTypes} schema types, ${Math.round(completeness.avgCompleteness * 100)}% complete` : "No recognized JSON-LD schemas found",
1195
+ completeness.totalTypes === 0 ? "neutral" : void 0
1196
+ )
1197
+ );
1198
+ rawData.schemaCompleteness = completeness;
1199
+ const entityName = resolveEntityName(page.$);
1200
+ const consistency = measureEntityConsistency(page.$, page.title, entityName);
1201
+ factors.push(
1202
+ makeFactor(
1203
+ "Entity Consistency",
1204
+ consistency.score,
1205
+ 10,
1206
+ entityName ? `"${entityName}" found in ${consistency.surfacesFound}/${consistency.surfacesChecked} surfaces` : "No identifiable entity name",
1207
+ !entityName ? "neutral" : void 0
1208
+ )
1209
+ );
1210
+ rawData.entityConsistency = {
1211
+ entityName: entityName || null,
1212
+ surfacesFound: consistency.surfacesFound,
1213
+ surfacesChecked: consistency.surfacesChecked
1214
+ };
1215
+ return {
1216
+ category: {
1217
+ name: CATEGORY_DISPLAY_NAMES.authorityContext,
1218
+ key: "authorityContext",
1219
+ score: sumFactors(factors),
1220
+ maxScore: maxFactors(factors),
1221
+ factors
1222
+ },
1223
+ rawData
1224
+ };
1225
+ }
1226
+
1227
+ // src/modules/audits/support/robots.ts
1228
+ function parseRobotGroups(robotsTxt) {
1229
+ const groups = [];
1230
+ let current = null;
1231
+ for (const raw of robotsTxt.split("\n")) {
1232
+ const line = raw.split("#")[0].trim();
1233
+ if (!line) {
1234
+ current = null;
1235
+ continue;
1236
+ }
1237
+ const colonAt = line.indexOf(":");
1238
+ if (colonAt === -1) continue;
1239
+ const field = line.slice(0, colonAt).trim().toLowerCase();
1240
+ const value = line.slice(colonAt + 1).trim();
1241
+ if (field === "user-agent") {
1242
+ if (!current) {
1243
+ current = { agents: [], rules: [] };
1244
+ groups.push(current);
1245
+ }
1246
+ current.agents.push(value.toLowerCase());
1247
+ } else if (field === "disallow" || field === "allow") {
1248
+ if (current) {
1249
+ current.rules.push({ type: field, path: value });
1250
+ }
1251
+ }
1252
+ }
1253
+ return groups;
1254
+ }
1255
+ function matchingRulesForCrawler(groups, crawlerLower) {
1256
+ const specific = [];
1257
+ const wildcard = [];
1258
+ for (const group of groups) {
1259
+ if (group.agents.includes(crawlerLower)) specific.push(...group.rules);
1260
+ else if (group.agents.includes("*")) wildcard.push(...group.rules);
1261
+ }
1262
+ return { specific, wildcard };
1263
+ }
1264
+ function resolvesPathAsBlocked(rules, path) {
1265
+ let bestMatchLength = -1;
1266
+ let bestMatchIsDisallow = false;
1267
+ for (const rule of rules) {
1268
+ const rulePath = rule.path;
1269
+ if (!rulePath || !path.startsWith(rulePath)) continue;
1270
+ if (rulePath.length > bestMatchLength) {
1271
+ bestMatchLength = rulePath.length;
1272
+ bestMatchIsDisallow = rule.type === "disallow";
1273
+ } else if (rulePath.length === bestMatchLength && rule.type === "allow") {
1274
+ bestMatchIsDisallow = false;
1275
+ }
1276
+ }
1277
+ return bestMatchLength >= 0 && bestMatchIsDisallow;
1278
+ }
1279
+ function findPartialBlocks(rules) {
1280
+ return rules.filter((r) => r.type === "disallow" && r.path && r.path !== "/").map((r) => r.path);
1281
+ }
1282
+ function checkCrawlerAccess(robotsTxt) {
1283
+ if (!robotsTxt)
1284
+ return { allowed: [], blocked: [], unknown: [...AI_CRAWLERS] };
1285
+ const groups = parseRobotGroups(robotsTxt);
1286
+ const allowed = [];
1287
+ const blocked = [];
1288
+ const unknown = [];
1289
+ const partiallyBlocked = [];
1290
+ for (const crawler of AI_CRAWLERS) {
1291
+ const crawlerLower = crawler.toLowerCase();
1292
+ const { specific, wildcard } = matchingRulesForCrawler(
1293
+ groups,
1294
+ crawlerLower
1295
+ );
1296
+ const applicableRules = specific.length > 0 ? specific : wildcard;
1297
+ if (applicableRules.length === 0) {
1298
+ unknown.push(crawler);
1299
+ continue;
1300
+ }
1301
+ const isSiteBlocked = resolvesPathAsBlocked(applicableRules, "/");
1302
+ if (isSiteBlocked) {
1303
+ blocked.push(crawler);
1304
+ } else {
1305
+ allowed.push(crawler);
1306
+ const pathBlocks = findPartialBlocks(applicableRules);
1307
+ for (const path of pathBlocks) {
1308
+ const entry = `${crawler}: ${path}`;
1309
+ if (!partiallyBlocked.includes(entry)) {
1310
+ partiallyBlocked.push(entry);
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ return {
1316
+ allowed,
1317
+ blocked,
1318
+ unknown,
1319
+ ...partiallyBlocked.length > 0 && { partiallyBlocked }
1320
+ };
1321
+ }
1322
+
1323
+ // src/modules/audits/categories/content-extractability.ts
1324
+ function auditContentExtractability(page, fetchResult, domainSignals) {
1325
+ const factors = [];
1326
+ const rawData = {};
1327
+ const fetchScore = fetchResult.statusCode === 200 ? 12 : fetchResult.statusCode < 400 ? 8 : 0;
1328
+ factors.push(
1329
+ makeFactor(
1330
+ "Fetch Success",
1331
+ fetchScore,
1332
+ 12,
1333
+ `HTTP ${fetchResult.statusCode} in ${fetchResult.fetchTimeMs}ms`
1334
+ )
1335
+ );
1336
+ const extractRatio = page.stats.rawByteLength > 0 ? page.stats.cleanTextLength / page.stats.rawByteLength : 0;
1337
+ const extractScore = extractRatio >= 0.05 && extractRatio <= 0.15 ? 12 : extractRatio > 0.15 ? 10 : extractRatio >= 0.01 ? 8 : 2;
1338
+ factors.push(
1339
+ makeFactor(
1340
+ "Text Extraction Quality",
1341
+ extractScore,
1342
+ 12,
1343
+ `${(extractRatio * 100).toFixed(1)}% content ratio`
1344
+ )
1345
+ );
1346
+ const boilerplateRatio = page.stats.boilerplateRatio;
1347
+ const bpScore = thresholdScore(1 - boilerplateRatio, [
1348
+ [0.7, 12],
1349
+ [0.5, 9],
1350
+ [0.3, 6],
1351
+ [0, 2]
1352
+ ]);
1353
+ factors.push(
1354
+ makeFactor(
1355
+ "Boilerplate Ratio",
1356
+ bpScore,
1357
+ 12,
1358
+ `${(boilerplateRatio * 100).toFixed(0)}% boilerplate`
1359
+ )
1360
+ );
1361
+ const wordCount = page.stats.wordCount;
1362
+ const wcScore = wordCount >= 300 && wordCount <= 3e3 ? 12 : wordCount > 3e3 ? 10 : wordCount >= 100 ? 8 : 2;
1363
+ factors.push(
1364
+ makeFactor("Word Count Adequacy", wcScore, 12, `${wordCount} words`)
1365
+ );
1366
+ if (domainSignals) {
1367
+ const access2 = checkCrawlerAccess(domainSignals.robotsTxt);
1368
+ const blockedCount = access2.blocked.length;
1369
+ const crawlerScore = blockedCount === 0 ? 10 : blockedCount <= 2 ? 6 : blockedCount <= 4 ? 3 : 0;
1370
+ factors.push(
1371
+ makeFactor(
1372
+ "AI Crawler Access",
1373
+ crawlerScore,
1374
+ 10,
1375
+ blockedCount === 0 ? `All major AI crawlers allowed` : `${access2.blocked.join(", ")} blocked in robots.txt`
1376
+ )
1377
+ );
1378
+ rawData.crawlerAccess = access2;
1379
+ rawData.llmsTxt = {
1380
+ llmsTxtExists: domainSignals.llmsTxtExists,
1381
+ llmsFullTxtExists: domainSignals.llmsFullTxtExists
1382
+ };
1383
+ const hasLlms = domainSignals.llmsTxtExists;
1384
+ const hasLlmsFull = domainSignals.llmsFullTxtExists;
1385
+ const llmsScore = hasLlms && hasLlmsFull ? 6 : hasLlms || hasLlmsFull ? 4 : 0;
1386
+ factors.push(
1387
+ makeFactor(
1388
+ "LLMs.txt Presence",
1389
+ llmsScore,
1390
+ 6,
1391
+ hasLlms && hasLlmsFull ? "llms.txt + llms-full.txt found" : hasLlms ? "llms.txt found" : hasLlmsFull ? "llms-full.txt found" : "Not found",
1392
+ !hasLlms && !hasLlmsFull ? "neutral" : void 0
1393
+ )
1394
+ );
1395
+ }
1396
+ const imageCount = page.stats.imageCount;
1397
+ const imagesWithAlt = page.stats.imagesWithAlt;
1398
+ const figcaptionCount = page.$("figure figcaption").length;
1399
+ const altRatio = imageCount > 0 ? imagesWithAlt / imageCount : 0;
1400
+ let imageAccessibilityScore = 0;
1401
+ if (imageCount > 0) {
1402
+ if (altRatio >= 0.9) imageAccessibilityScore += 5;
1403
+ else if (altRatio >= 0.5) imageAccessibilityScore += 3;
1404
+ else imageAccessibilityScore += 1;
1405
+ if (figcaptionCount > 0) imageAccessibilityScore += 3;
1406
+ }
1407
+ factors.push(
1408
+ makeFactor(
1409
+ "Image Accessibility",
1410
+ imageAccessibilityScore,
1411
+ 8,
1412
+ imageCount > 0 ? `${imagesWithAlt}/${imageCount} images have alt text${figcaptionCount > 0 ? `, ${figcaptionCount} figcaptions` : ""}` : "No images found",
1413
+ imageCount === 0 ? "neutral" : void 0
1414
+ )
1415
+ );
1416
+ rawData.imageAccessibility = { imageCount, imagesWithAlt, figcaptionCount };
1417
+ return {
1418
+ category: {
1419
+ name: CATEGORY_DISPLAY_NAMES.contentExtractability,
1420
+ key: "contentExtractability",
1421
+ score: sumFactors(factors),
1422
+ maxScore: maxFactors(factors),
1423
+ factors
1424
+ },
1425
+ rawData
1426
+ };
1427
+ }
1428
+
1429
+ // src/modules/audits/categories/content-structure.ts
1430
+ function auditContentStructure(page) {
1431
+ const $ = page.$;
1432
+ const factors = [];
1433
+ const h1 = page.stats.h1Count;
1434
+ const h2 = page.stats.h2Count;
1435
+ const h3 = page.stats.h3Count;
1436
+ let headingScore = 0;
1437
+ if (h1 === 1) headingScore += 4;
1438
+ else if (h1 > 0) headingScore += 2;
1439
+ if (h2 >= 2) headingScore += 4;
1440
+ else if (h2 > 0) headingScore += 2;
1441
+ if (h3 > 0) headingScore += 3;
1442
+ factors.push(
1443
+ makeFactor(
1444
+ "Heading Hierarchy",
1445
+ headingScore,
1446
+ 11,
1447
+ `${h1} H1, ${h2} H2, ${h3} H3`
1448
+ )
1449
+ );
1450
+ const listItems = page.stats.listItemCount;
1451
+ const listScore = thresholdScore(listItems, [
1452
+ [10, 11],
1453
+ [5, 8],
1454
+ [1, 4],
1455
+ [0, 0]
1456
+ ]);
1457
+ factors.push(
1458
+ makeFactor("Lists Presence", listScore, 11, `${listItems} list items`)
1459
+ );
1460
+ const tables = page.stats.tableCount;
1461
+ const tableScore = tables >= 2 ? 8 : tables >= 1 ? 5 : 0;
1462
+ factors.push(
1463
+ makeFactor(
1464
+ "Tables Presence",
1465
+ tableScore,
1466
+ 8,
1467
+ `${tables} table(s)`,
1468
+ tables === 0 ? "neutral" : void 0
1469
+ )
1470
+ );
1471
+ const pCount = page.stats.paragraphCount;
1472
+ const avgParagraphWords = pCount > 0 ? Math.round(page.stats.wordCount / pCount) : 0;
1473
+ const paragraphScore = avgParagraphWords >= 30 && avgParagraphWords <= 150 ? 11 : avgParagraphWords > 0 && avgParagraphWords < 200 ? 7 : 2;
1474
+ factors.push(
1475
+ makeFactor(
1476
+ "Paragraph Structure",
1477
+ paragraphScore,
1478
+ 11,
1479
+ `${pCount} paragraphs, avg ${avgParagraphWords} words`
1480
+ )
1481
+ );
1482
+ const hasBold = $("strong, b").length > 0;
1483
+ const headingRatio = pCount > 0 ? page.stats.headingCount / pCount : 0;
1484
+ let scanScore = 0;
1485
+ if (hasBold) scanScore += 4;
1486
+ if (avgParagraphWords <= 150) scanScore += 4;
1487
+ if (headingRatio >= 0.1) scanScore += 3;
1488
+ factors.push(
1489
+ makeFactor(
1490
+ "Scannability",
1491
+ scanScore,
1492
+ 11,
1493
+ `${hasBold ? "Bold text found" : "No bold text"}, ${headingRatio.toFixed(2)} heading ratio`
1494
+ )
1495
+ );
1496
+ const sectionData = measureSectionLengths(page.$);
1497
+ let sectionScore = 0;
1498
+ if (sectionData.sectionCount === 0) {
1499
+ sectionScore = 0;
1500
+ } else if (sectionData.avgWordsPerSection >= 120 && sectionData.avgWordsPerSection <= 180) {
1501
+ sectionScore = 12;
1502
+ } else if (sectionData.avgWordsPerSection >= 80 && sectionData.avgWordsPerSection <= 250) {
1503
+ sectionScore = 8;
1504
+ } else if (sectionData.avgWordsPerSection > 0) {
1505
+ sectionScore = 4;
1506
+ }
1507
+ factors.push(
1508
+ makeFactor(
1509
+ "Section Length",
1510
+ sectionScore,
1511
+ 12,
1512
+ sectionData.sectionCount > 0 ? `${sectionData.sectionCount} sections, avg ${sectionData.avgWordsPerSection} words` : "No headed sections found",
1513
+ sectionData.sectionCount === 0 ? "neutral" : void 0
1514
+ )
1515
+ );
1516
+ return {
1517
+ category: {
1518
+ name: CATEGORY_DISPLAY_NAMES.contentStructure,
1519
+ key: "contentStructure",
1520
+ score: sumFactors(factors),
1521
+ maxScore: maxFactors(factors),
1522
+ factors
1523
+ },
1524
+ rawData: {
1525
+ sectionLengths: sectionData
1526
+ }
1527
+ };
1528
+ }
1529
+
1530
+ // src/modules/audits/categories/entity-clarity.ts
1531
+ function auditEntityClarity(page, preExtracted) {
1532
+ const text = page.cleanText;
1533
+ const factors = [];
1534
+ const entities = preExtracted ?? extractEntities(text);
1535
+ const totalEntities = entities.people.length + entities.organizations.length + entities.places.length + entities.topics.length;
1536
+ const richScore = thresholdScore(totalEntities, [
1537
+ [9, 20],
1538
+ [4, 14],
1539
+ [1, 7],
1540
+ [0, 0]
1541
+ ]);
1542
+ factors.push(
1543
+ makeFactor(
1544
+ "Entity Richness",
1545
+ richScore,
1546
+ 20,
1547
+ `${totalEntities} entities (${entities.people.length} people, ${entities.organizations.length} orgs, ${entities.places.length} places)`,
1548
+ totalEntities === 0 ? "neutral" : void 0
1549
+ )
1550
+ );
1551
+ const titleWords = page.title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
1552
+ const h1Text = page.$("h1").first().text().toLowerCase();
1553
+ const h1Words = h1Text.split(/\s+/).filter((w) => w.length > 3);
1554
+ const keyWords = [.../* @__PURE__ */ new Set([...titleWords, ...h1Words])];
1555
+ const topicLower = entities.topics.map((t) => t.toLowerCase());
1556
+ let topicOverlap = 0;
1557
+ for (const kw of keyWords) {
1558
+ if (topicLower.some((t) => t.includes(kw)) || text.toLowerCase().split(kw).length > 3) {
1559
+ topicOverlap++;
1560
+ }
1561
+ }
1562
+ const consistencyRatio = keyWords.length > 0 ? topicOverlap / keyWords.length : 0;
1563
+ const consistencyScore = keyWords.length === 0 ? 0 : consistencyRatio >= 0.5 ? 25 : consistencyRatio > 0 ? 15 : 0;
1564
+ factors.push(
1565
+ makeFactor(
1566
+ "Topic Consistency",
1567
+ consistencyScore,
1568
+ 25,
1569
+ `${topicOverlap}/${keyWords.length} title keywords align with content topics`,
1570
+ keyWords.length === 0 ? "neutral" : void 0
1571
+ )
1572
+ );
1573
+ const wordCount = countWords(text);
1574
+ const densityPer100 = wordCount > 0 ? totalEntities / wordCount * 100 : 0;
1575
+ const densityScore = densityPer100 >= 2 && densityPer100 <= 8 ? 15 : densityPer100 >= 1 ? 10 : densityPer100 > 8 ? 10 : 3;
1576
+ factors.push(
1577
+ makeFactor(
1578
+ "Entity Density",
1579
+ densityScore,
1580
+ 15,
1581
+ `${densityPer100.toFixed(1)} entities per 100 words`
1582
+ )
1583
+ );
1584
+ return {
1585
+ category: {
1586
+ name: CATEGORY_DISPLAY_NAMES.entityClarity,
1587
+ key: "entityClarity",
1588
+ score: sumFactors(factors),
1589
+ maxScore: maxFactors(factors),
1590
+ factors
1591
+ },
1592
+ rawData: {
1593
+ entities
1594
+ }
1595
+ };
1596
+ }
1597
+
1598
+ // src/modules/audits/categories/grounding-signals.ts
1599
+ function auditGroundingSignals(page, preExtracted) {
1600
+ const $ = page.$;
1601
+ const text = page.cleanText;
1602
+ const factors = [];
1603
+ const { numberCount = 0 } = preExtracted ?? extractEntities(text);
1604
+ const externalLinks = page.externalLinks;
1605
+ const extScore = thresholdScore(externalLinks.length, [
1606
+ [6, 13],
1607
+ [3, 10],
1608
+ [1, 6],
1609
+ [0, 0]
1610
+ ]);
1611
+ factors.push(
1612
+ makeFactor(
1613
+ "External References",
1614
+ extScore,
1615
+ 13,
1616
+ `${externalLinks.length} external links`
1617
+ )
1618
+ );
1619
+ const citationCount = countPatternMatches(text, CITATION_PATTERNS);
1620
+ const blockquotes = $("blockquote, cite, q").length;
1621
+ const totalCitations = citationCount + blockquotes;
1622
+ const citScore = thresholdScore(totalCitations, [
1623
+ [6, 13],
1624
+ [3, 9],
1625
+ [1, 5],
1626
+ [0, 0]
1627
+ ]);
1628
+ factors.push(
1629
+ makeFactor(
1630
+ "Citation Patterns",
1631
+ citScore,
1632
+ 13,
1633
+ `${citationCount} citation indicators, ${blockquotes} quote elements`
1634
+ )
1635
+ );
1636
+ const numericCount = countPatternMatches(text, NUMERIC_CLAIM_PATTERNS);
1637
+ const totalNumericSignals = numericCount + numberCount;
1638
+ const numScore = thresholdScore(totalNumericSignals, [
1639
+ [9, 13],
1640
+ [4, 9],
1641
+ [1, 5],
1642
+ [0, 0]
1643
+ ]);
1644
+ factors.push(
1645
+ makeFactor(
1646
+ "Numeric Claims",
1647
+ numScore,
1648
+ 13,
1649
+ `${numericCount} statistical references, ${numberCount} numeric values`
1650
+ )
1651
+ );
1652
+ const attrCount = countPatternMatches(text, ATTRIBUTION_PATTERNS);
1653
+ const attrScore = thresholdScore(attrCount, [
1654
+ [5, 11],
1655
+ [2, 8],
1656
+ [1, 4],
1657
+ [0, 0]
1658
+ ]);
1659
+ factors.push(
1660
+ makeFactor(
1661
+ "Attribution Indicators",
1662
+ attrScore,
1663
+ 11,
1664
+ `${attrCount} attribution patterns`
1665
+ )
1666
+ );
1667
+ const quotedAttrPatterns = countPatternMatches(
1668
+ text,
1669
+ QUOTED_ATTRIBUTION_PATTERNS
1670
+ );
1671
+ const blockquotesWithCite = $("blockquote").filter(
1672
+ (_, el) => $(el).find("cite, footer, figcaption").length > 0
1673
+ ).length;
1674
+ const totalQuotedAttr = quotedAttrPatterns + blockquotesWithCite;
1675
+ const quotedAttrScore = thresholdScore(totalQuotedAttr, [
1676
+ [4, 10],
1677
+ [2, 7],
1678
+ [1, 4],
1679
+ [0, 0]
1680
+ ]);
1681
+ factors.push(
1682
+ makeFactor(
1683
+ "Quoted Attribution",
1684
+ quotedAttrScore,
1685
+ 10,
1686
+ `${totalQuotedAttr} attributed quotes`,
1687
+ totalQuotedAttr === 0 ? "neutral" : void 0
1688
+ )
1689
+ );
1690
+ return {
1691
+ category: {
1692
+ name: CATEGORY_DISPLAY_NAMES.groundingSignals,
1693
+ key: "groundingSignals",
1694
+ score: sumFactors(factors),
1695
+ maxScore: maxFactors(factors),
1696
+ factors
1697
+ },
1698
+ rawData: {
1699
+ externalLinks: externalLinks.slice(0, 10)
1700
+ }
1701
+ };
1702
+ }
1703
+
1704
+ // src/modules/audits/categories/readability.ts
1705
+ function auditReadabilityForCompression(page) {
1706
+ const text = page.cleanText;
1707
+ const factors = [];
1708
+ const avgSentLen = avgSentenceLength(text);
1709
+ const sentScore = avgSentLen >= 12 && avgSentLen <= 22 ? 15 : avgSentLen >= 8 && avgSentLen < 30 ? 10 : avgSentLen > 0 ? 5 : 0;
1710
+ factors.push(
1711
+ makeFactor(
1712
+ "Sentence Length",
1713
+ sentScore,
1714
+ 15,
1715
+ `Avg ${avgSentLen} words/sentence`
1716
+ )
1717
+ );
1718
+ const fre = computeFleschReadingEase(text);
1719
+ const freScore = fre >= 60 && fre <= 70 ? 15 : fre > 70 ? 13 : fre >= 50 ? 10 : fre >= 30 ? 6 : 3;
1720
+ factors.push(
1721
+ makeFactor(
1722
+ "Readability",
1723
+ freScore,
1724
+ 15,
1725
+ `Flesch Reading Ease: ${fre.toFixed(1)}`
1726
+ )
1727
+ );
1728
+ const totalWords = countWords(text);
1729
+ const complex = countComplexWords(text);
1730
+ const jargonRatio = totalWords > 0 ? complex / totalWords : 0;
1731
+ const jargonScore = jargonRatio <= 0.02 ? 15 : jargonRatio <= 0.05 ? 12 : jargonRatio <= 0.1 ? 8 : 3;
1732
+ factors.push(
1733
+ makeFactor(
1734
+ "Jargon Density",
1735
+ jargonScore,
1736
+ 15,
1737
+ `${(jargonRatio * 100).toFixed(1)}% complex words`
1738
+ )
1739
+ );
1740
+ const transCount = countTransitionWords(text, TRANSITION_WORDS);
1741
+ const transScore = thresholdScore(transCount, [
1742
+ [10, 15],
1743
+ [5, 11],
1744
+ [2, 7],
1745
+ [1, 3],
1746
+ [0, 0]
1747
+ ]);
1748
+ factors.push(
1749
+ makeFactor(
1750
+ "Transition Usage",
1751
+ transScore,
1752
+ 15,
1753
+ `${transCount} transition types found`
1754
+ )
1755
+ );
1756
+ return {
1757
+ category: {
1758
+ name: CATEGORY_DISPLAY_NAMES.readabilityForCompression,
1759
+ key: "readabilityForCompression",
1760
+ score: sumFactors(factors),
1761
+ maxScore: maxFactors(factors),
1762
+ factors
1763
+ },
1764
+ rawData: {
1765
+ avgSentenceLength: avgSentLen,
1766
+ readabilityScore: fre
1767
+ }
1768
+ };
1769
+ }
1770
+
1771
+ // src/modules/audits/service.ts
1772
+ function runAudits(page, fetchResult, domainSignals) {
1773
+ const entities = extractEntities(page.cleanText);
1774
+ const extractability = auditContentExtractability(
1775
+ page,
1776
+ fetchResult,
1777
+ domainSignals
1778
+ );
1779
+ const structure = auditContentStructure(page);
1780
+ const answerability = auditAnswerability(page, entities);
1781
+ const entityClarity = auditEntityClarity(page, entities);
1782
+ const groundingSignals = auditGroundingSignals(page, entities);
1783
+ const authorityContext = auditAuthorityContext(page);
1784
+ const readability = auditReadabilityForCompression(page);
1785
+ return {
1786
+ categories: {
1787
+ contentExtractability: extractability.category,
1788
+ contentStructure: structure.category,
1789
+ answerability: answerability.category,
1790
+ entityClarity: entityClarity.category,
1791
+ groundingSignals: groundingSignals.category,
1792
+ authorityContext: authorityContext.category,
1793
+ readabilityForCompression: readability.category
1794
+ },
1795
+ rawData: {
1796
+ title: page.title,
1797
+ metaDescription: page.metaDescription,
1798
+ wordCount: page.stats.wordCount,
1799
+ ...extractability.rawData,
1800
+ ...structure.rawData,
1801
+ ...answerability.rawData,
1802
+ ...entityClarity.rawData,
1803
+ ...groundingSignals.rawData,
1804
+ ...authorityContext.rawData,
1805
+ ...readability.rawData
1806
+ }
1807
+ };
1808
+ }
1809
+
1810
+ // src/modules/extractor/service.ts
1811
+ import * as cheerio from "cheerio";
1812
+
1813
+ // src/modules/extractor/support/boilerplate.ts
1814
+ var REMOVE_SELECTORS = [
1815
+ "script",
1816
+ "style",
1817
+ "noscript",
1818
+ "svg",
1819
+ "iframe",
1820
+ "nav",
1821
+ "header",
1822
+ "footer",
1823
+ "aside",
1824
+ '[role="navigation"]',
1825
+ '[role="banner"]',
1826
+ '[role="contentinfo"]',
1827
+ ".sidebar",
1828
+ "#sidebar",
1829
+ ".cookie-banner",
1830
+ "#cookie-consent",
1831
+ ".cookie-notice",
1832
+ ".nav",
1833
+ ".navbar",
1834
+ ".footer",
1835
+ ".header",
1836
+ ".menu",
1837
+ ".ad",
1838
+ ".ads",
1839
+ ".advertisement",
1840
+ '[class*="cookie"]',
1841
+ '[class*="consent"]',
1842
+ '[class*="popup"]',
1843
+ '[class*="modal"]'
1844
+ ];
1845
+ function removeBoilerplate($) {
1846
+ for (const selector of REMOVE_SELECTORS) {
1847
+ $(selector).remove();
1848
+ }
1849
+ }
1850
+
1851
+ // src/modules/extractor/support/text.ts
1852
+ function normalizeWhitespace(text) {
1853
+ return text.replace(/\s+/g, " ").trim();
1854
+ }
1855
+ var BLOCK_ELEMENTS = "p,div,td,th,li,h1,h2,h3,h4,h5,h6,dt,dd,br,blockquote,section,article";
1856
+ function extractCleanText($) {
1857
+ $(BLOCK_ELEMENTS).each((_, el) => {
1858
+ $(el).append(" ");
1859
+ });
1860
+ return normalizeWhitespace($("body").text());
1861
+ }
1862
+
1863
+ // src/modules/extractor/service.ts
1864
+ function extractPage(html, url) {
1865
+ const $ = cheerio.load(html);
1866
+ const title = $("title").text().trim() || $('meta[property="og:title"]').attr("content")?.trim() || "";
1867
+ const metaDescription = $('meta[name="description"]').attr("content")?.trim() || $('meta[property="og:description"]').attr("content")?.trim() || "";
1868
+ const $raw = cheerio.load(html);
1869
+ $raw("script, style, noscript").remove();
1870
+ const rawText = $raw("body").text().replace(/\s+/g, " ").trim();
1871
+ const rawByteLength = Buffer.byteLength(html, "utf-8");
1872
+ const h1Count = $("h1").length;
1873
+ const h2Count = $("h2").length;
1874
+ const h3Count = $("h3").length;
1875
+ const headingCount = h1Count + h2Count + h3Count + $("h4, h5, h6").length;
1876
+ const linkCount = $("a[href]").length;
1877
+ const imageCount = $("img").length;
1878
+ const listCount = $("ul, ol").length;
1879
+ const listItemCount = $("li").length;
1880
+ const tableCount = $("table").length;
1881
+ const paragraphCount = $("p").length;
1882
+ const GENERIC_ALT_VALUES = /* @__PURE__ */ new Set([
1883
+ "image",
1884
+ "photo",
1885
+ "logo",
1886
+ "icon",
1887
+ "picture",
1888
+ "img",
1889
+ "graphic",
1890
+ "thumbnail"
1891
+ ]);
1892
+ let imagesWithAlt = 0;
1893
+ $("img").each((_, el) => {
1894
+ const alt = $(el).attr("alt")?.trim() ?? "";
1895
+ const words = alt.split(/\s+/).filter((w) => w.length > 0);
1896
+ const isMeaningful = words.length > 1 && alt.length < 200 && !GENERIC_ALT_VALUES.has(alt.toLowerCase());
1897
+ if (isMeaningful) imagesWithAlt++;
1898
+ });
1899
+ const pageDomain = getDomain(url);
1900
+ const externalLinks = [];
1901
+ $('a[href^="http"]').each((_, el) => {
1902
+ const href = $(el).attr("href");
1903
+ try {
1904
+ if (getDomain(href) !== pageDomain) {
1905
+ externalLinks.push({
1906
+ url: href,
1907
+ text: $(el).text().trim().substring(0, 50)
1908
+ });
1909
+ }
1910
+ } catch {
1911
+ }
1912
+ });
1913
+ const externalLinkCount = externalLinks.length;
1914
+ const $clean = cheerio.load(html);
1915
+ removeBoilerplate($clean);
1916
+ const cleanText = extractCleanText($clean);
1917
+ const cleanTextLength = cleanText.length;
1918
+ const boilerplateRatio = rawText.length > 0 ? Math.max(0, Math.min(1, 1 - cleanTextLength / rawText.length)) : 0;
1919
+ const stats = {
1920
+ wordCount: countWords(cleanText),
1921
+ sentenceCount: countSentences(cleanText),
1922
+ paragraphCount,
1923
+ headingCount,
1924
+ h1Count,
1925
+ h2Count,
1926
+ h3Count,
1927
+ linkCount,
1928
+ externalLinkCount,
1929
+ imageCount,
1930
+ imagesWithAlt,
1931
+ listCount,
1932
+ listItemCount,
1933
+ tableCount,
1934
+ boilerplateRatio,
1935
+ rawByteLength,
1936
+ cleanTextLength
1937
+ };
1938
+ return {
1939
+ url,
1940
+ html,
1941
+ cleanText,
1942
+ title,
1943
+ metaDescription,
1944
+ stats,
1945
+ $,
1946
+ externalLinks
1947
+ };
1948
+ }
1949
+
1950
+ // src/modules/fetcher/schema.ts
1951
+ import { z } from "zod";
1952
+ var FetchOptionsSchema = z.object({
1953
+ url: z.url(),
1954
+ timeout: z.number().positive().default(45e3),
1955
+ userAgent: z.string().default(`AISEOAudit/${VERSION}`)
1956
+ });
1957
+ var FetchResultSchema = z.object({
1958
+ url: z.string(),
1959
+ finalUrl: z.string(),
1960
+ statusCode: z.number(),
1961
+ contentType: z.string(),
1962
+ html: z.string(),
1963
+ byteLength: z.number(),
1964
+ fetchTimeMs: z.number(),
1965
+ redirected: z.boolean()
1966
+ });
1967
+
1968
+ // src/modules/fetcher/service.ts
1969
+ async function fetchUrl(options) {
1970
+ const opts = FetchOptionsSchema.parse(options);
1971
+ const start = Date.now();
1972
+ const response = await httpGet({
1973
+ url: opts.url,
1974
+ timeout: opts.timeout,
1975
+ userAgent: opts.userAgent
1976
+ });
1977
+ const fetchTimeMs = Date.now() - start;
1978
+ const html = response.data;
1979
+ const finalUrl = response.finalUrl || opts.url;
1980
+ const contentType = response.headers["content-type"] || "unknown";
1981
+ return {
1982
+ url: opts.url,
1983
+ finalUrl,
1984
+ statusCode: response.status,
1985
+ contentType,
1986
+ html,
1987
+ byteLength: Buffer.byteLength(html, "utf-8"),
1988
+ fetchTimeMs,
1989
+ redirected: finalUrl !== opts.url
1990
+ };
1991
+ }
1992
+
1993
+ // src/modules/recommendations/examples.ts
1994
+ var listsConversionExample = `<!-- Before: prose enumeration -->
1995
+ <p>We offer design, development, and strategy services.</p>
1996
+
1997
+ <!-- After: unordered list -->
1998
+ <ul>
1999
+ <li>Design \u2014 UI/UX and brand identity</li>
2000
+ <li>Development \u2014 web and mobile engineering</li>
2001
+ <li>Strategy \u2014 AI readiness and growth planning</li>
2002
+ </ul>
2003
+
2004
+ <!-- Ordered list for sequential steps -->
2005
+ <ol>
2006
+ <li>Submit your project brief</li>
2007
+ <li>Schedule a discovery call</li>
2008
+ <li>Receive your proposal within 48 hours</li>
2009
+ </ol>`;
2010
+ var tablesMarkupExample = `<table>
2011
+ <caption>Plan feature comparison</caption>
2012
+ <thead>
2013
+ <tr>
2014
+ <th>Feature</th>
2015
+ <th>Starter</th>
2016
+ <th>Pro</th>
2017
+ <th>Enterprise</th>
2018
+ </tr>
2019
+ </thead>
2020
+ <tbody>
2021
+ <tr>
2022
+ <td>Users</td>
2023
+ <td>1</td>
2024
+ <td>10</td>
2025
+ <td>Unlimited</td>
2026
+ </tr>
2027
+ <tr>
2028
+ <td>Storage</td>
2029
+ <td>10 GB</td>
2030
+ <td>100 GB</td>
2031
+ <td>1 TB</td>
2032
+ </tr>
2033
+ </tbody>
2034
+ </table>`;
2035
+ var directAnswerExample = `<!-- Before: buried answer -->
2036
+ <p>There are many factors involved, and while it depends on the situation,
2037
+ in most cases companies that invest in AI SEO tend to see better visibility
2038
+ in AI-generated answers.</p>
2039
+
2040
+ <!-- After: direct answer first -->
2041
+ <p>Companies that invest in AI SEO see better visibility in AI-generated answers.
2042
+ Results depend on content quality, structured data completeness, and how well
2043
+ headings are framed as questions.</p>`;
2044
+ var summaryStructureExample = `<h2>Key Takeaways</h2>
2045
+ <ul>
2046
+ <li>AI SEO focuses on being cited in generated answers, not ranked in link lists.</li>
2047
+ <li>Structured data, answer capsules, and clear entity attribution are the highest-impact factors.</li>
2048
+ <li>Content freshness acts as a hard gate \u2014 AI engines strongly prefer content under 12 months old.</li>
2049
+ </ul>
2050
+
2051
+ <h2>Next Steps</h2>
2052
+ <p>Run an audit on your top pages to identify your highest-priority improvements.</p>`;
2053
+ var attributionIndicatorExample = `<!-- Before: unattributed claim -->
2054
+ <p>72% of AI-cited content uses question-framed headings.</p>
2055
+
2056
+ <!-- After: attributed with a link -->
2057
+ <p>According to <a href="https://arxiv.org/abs/2311.09735">Princeton's GEO research</a>,
2058
+ 72% of AI-cited content uses question-framed headings.</p>`;
2059
+ var citationMarkupExample = `<!-- In-text citation with marker -->
2060
+ <p>AI engines prioritize structured content by a factor of 3x <a href="#ref-1">[1]</a>.</p>
2061
+
2062
+ <!-- Cited title -->
2063
+ <p>As described in <cite>Generative Engine Optimization</cite>, answer density is key.</p>
2064
+
2065
+ <!-- References section -->
2066
+ <section>
2067
+ <h2>References</h2>
2068
+ <ol>
2069
+ <li id="ref-1">
2070
+ <cite><a href="https://arxiv.org/abs/2311.09735">Generative Engine Optimization (GEO)</a></cite>
2071
+ \u2014 Aggarwal et al., Princeton University, 2023
2072
+ </li>
2073
+ </ol>
2074
+ </section>`;
2075
+ var blockquoteAttributionExample = `<blockquote>
2076
+ <p>"Structured content with clear attribution is 3x more likely to be cited
2077
+ by generative AI engines than unstructured prose."</p>
2078
+ <footer>
2079
+ \u2014 <cite>Dr. Jane Smith, AI Research Lead,
2080
+ <a href="https://example.edu">Princeton University</a></cite>
2081
+ </footer>
2082
+ </blockquote>`;
2083
+ var transitionWordsExample = `<!-- Before: abrupt transitions -->
2084
+ <p>AI engines extract structured content. Many pages have no structure.</p>
2085
+ <p>Adding headings and lists improves your score.</p>
2086
+
2087
+ <!-- After: logical flow with transitions -->
2088
+ <p>AI engines extract structured content. <strong>However</strong>, many pages
2089
+ lack the organization needed for reliable extraction.</p>
2090
+ <p><strong>Therefore</strong>, adding headings and lists directly improves
2091
+ how AI engines process and cite your content.</p>`;
2092
+ var jargonReductionExample = `<!-- Before: jargon-heavy -->
2093
+ <p>Our RAG-based LLM pipeline leverages semantic chunking and vector embeddings
2094
+ to optimize retrieval latency for enterprise-scale deployments.</p>
2095
+
2096
+ <!-- After: jargon defined on first use -->
2097
+ <p>Our AI pipeline uses Retrieval-Augmented Generation (RAG) \u2014 a technique that
2098
+ combines a language model with a searchable knowledge base \u2014 to deliver fast,
2099
+ accurate answers at enterprise scale.</p>`;
2100
+ var contactLinksExample = `<!-- In site navigation or footer -->
2101
+ <nav aria-label="Company">
2102
+ <a href="/about">About Us</a>
2103
+ <a href="/contact">Contact</a>
2104
+ </nav>
2105
+
2106
+ <!-- Optional: contactPoint in your Organization JSON-LD -->
2107
+ "contactPoint": {
2108
+ "@type": "ContactPoint",
2109
+ "contactType": "customer support",
2110
+ "url": "https://yoursite.com/contact"
2111
+ }`;
2112
+ var imageAltTextExample = `<!-- Informational image with alt text -->
2113
+ <img src="diagram.png" alt="Flowchart showing the three stages of AI content extraction">
2114
+
2115
+ <!-- Image with caption using figure/figcaption -->
2116
+ <figure>
2117
+ <img src="chart.png" alt="Bar chart comparing AI citation rates by content type">
2118
+ <figcaption>AI engines cite structured content 3x more often than unstructured prose.</figcaption>
2119
+ </figure>
2120
+
2121
+ <!-- Decorative image \u2014 empty alt tells screen readers to skip it -->
2122
+ <img src="divider.png" alt="">`;
2123
+ var answerCapsuleExample = `<!-- Before -->
2124
+ <h2>Benefits of AI SEO</h2>
2125
+ <p>There are many reasons companies are investing in AI SEO strategies today...</p>
2126
+
2127
+ <!-- After (answer capsule pattern) -->
2128
+ <h2>What are the benefits of AI SEO?</h2>
2129
+ <p>AI SEO increases your content's visibility in AI-generated answers, driving qualified traffic from ChatGPT, Claude, and Perplexity.</p>
2130
+ <p>Companies investing in AI SEO see benefits across three areas...</p>`;
2131
+
2132
+ // src/modules/recommendations/constants.ts
2133
+ function constantRecommendation(text) {
2134
+ return () => ({ text });
2135
+ }
2136
+ var RECOMMENDATION_BUILDERS = {
2137
+ "Fetch Success": constantRecommendation(
2138
+ "Ensure the page returns HTTP 200 without excessive redirect chains. AI engines cannot extract content from pages that fail to load."
2139
+ ),
2140
+ "Text Extraction Quality": constantRecommendation(
2141
+ "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."
2142
+ ),
2143
+ "Boilerplate Ratio": constantRecommendation(
2144
+ "Reduce boilerplate content (navigation, footers, sidebars) relative to main content. Use semantic HTML elements like <main> and <article> to help engines isolate your content."
2145
+ ),
2146
+ "Word Count Adequacy": (rawData) => {
2147
+ const count = rawData.wordCount;
2148
+ if (count < 100) {
2149
+ return {
2150
+ text: `Your page has ${count} words, which is too thin for AI engines to reference. The ideal range is 300-3000 words.`
2151
+ };
2152
+ }
2153
+ if (count < 300) {
2154
+ return {
2155
+ text: `Your page has ${count} words. AI engines prefer 300-3000 words for comprehensive coverage. Consider expanding your content.`
2156
+ };
2157
+ }
2158
+ return {
2159
+ text: `Your page has ${count} words, which exceeds the ideal 300-3000 word range. Consider splitting into multiple focused pages.`
2160
+ };
2161
+ },
2162
+ "AI Crawler Access": (rawData) => {
2163
+ const access2 = rawData.crawlerAccess;
2164
+ if (!access2 || access2.blocked.length === 0) {
2165
+ return {
2166
+ text: "Ensure AI crawlers like GPTBot, ClaudeBot, and PerplexityBot are allowed in your robots.txt.",
2167
+ steps: [
2168
+ "Check your robots.txt for Disallow rules targeting AI crawlers",
2169
+ "Add explicit Allow rules for each AI crawler you want to reach your content"
2170
+ ],
2171
+ learnMoreUrl: "https://developers.google.com/search/docs/crawling-indexing/robots/intro"
2172
+ };
2173
+ }
2174
+ const blocked = access2.blocked;
2175
+ const blockedList = blocked.join(", ");
2176
+ const codeExample = `# Add these rules to your robots.txt:
2177
+
2178
+ ${blocked.map((c) => `User-agent: ${c}
2179
+ Allow: /`).join("\n\n")}`;
2180
+ if (access2.allowed.length > 0) {
2181
+ const allowed = access2.allowed.join(", ");
2182
+ return {
2183
+ text: `Your robots.txt is blocking ${blockedList}. ${allowed} ${access2.allowed.length === 1 ? "is" : "are"} allowed. Unblock all AI crawlers so your content can be discovered and cited.`,
2184
+ steps: [
2185
+ "Open your robots.txt file (usually at the site root)",
2186
+ `Remove any Disallow rules for: ${blockedList}`,
2187
+ "Add the Allow rules shown in the code example",
2188
+ "Deploy and verify at yoursite.com/robots.txt"
2189
+ ],
2190
+ codeExample,
2191
+ learnMoreUrl: "https://developers.google.com/search/docs/crawling-indexing/robots/intro"
2192
+ };
2193
+ }
2194
+ return {
2195
+ text: `Your robots.txt is blocking ${blockedList}. Blocking these crawlers means your content cannot be discovered or cited by AI engines.`,
2196
+ steps: [
2197
+ "Open your robots.txt file (usually at the site root)",
2198
+ `Remove any Disallow rules for: ${blockedList}`,
2199
+ "Add the Allow rules shown in the code example",
2200
+ "Deploy and verify at yoursite.com/robots.txt"
2201
+ ],
2202
+ codeExample,
2203
+ learnMoreUrl: "https://developers.google.com/search/docs/crawling-indexing/robots/intro"
2204
+ };
2205
+ },
2206
+ "LLMs.txt Presence": (rawData) => {
2207
+ const llms = rawData.llmsTxt;
2208
+ const title = rawData.title || "Your Site Name";
2209
+ const description = rawData.metaDescription || "Brief description of your site";
2210
+ const missingFiles = [];
2211
+ if (!llms?.llmsTxtExists) missingFiles.push("llms.txt");
2212
+ if (!llms?.llmsFullTxtExists) missingFiles.push("llms-full.txt");
2213
+ if (missingFiles.length === 0) {
2214
+ return { text: "Both llms.txt and llms-full.txt are present." };
2215
+ }
2216
+ const isLlmsMissing = !llms?.llmsTxtExists;
2217
+ const codeExample = isLlmsMissing ? `# llms.txt
2218
+
2219
+ # ${title}
2220
+
2221
+ > ${description}
2222
+
2223
+ ## Docs
2224
+
2225
+ - [About](/about): Learn more about ${title}
2226
+ - [Documentation](/docs): Technical documentation` : `# llms-full.txt
2227
+
2228
+ # ${title} \u2014 Full Documentation
2229
+
2230
+ > ${description}
2231
+
2232
+ This file provides comprehensive documentation for AI systems to understand and accurately reference ${title}.`;
2233
+ const fileName = missingFiles[0];
2234
+ if (llms?.llmsTxtExists && !llms?.llmsFullTxtExists) {
2235
+ return {
2236
+ text: "You have llms.txt but are missing llms-full.txt. Adding llms-full.txt provides AI systems with a comprehensive version of your site documentation for deeper ingestion.",
2237
+ steps: [
2238
+ "Create llms-full.txt at your domain root (e.g., yoursite.com/llms-full.txt)",
2239
+ "Include your site's full documentation, purpose, key features, and FAQ",
2240
+ "Deploy so the file is accessible via HTTP GET",
2241
+ "Verify by visiting yoursite.com/llms-full.txt"
2242
+ ],
2243
+ codeExample,
2244
+ learnMoreUrl: "https://llmstxt.org"
2245
+ };
2246
+ }
2247
+ if (!llms?.llmsTxtExists && llms?.llmsFullTxtExists) {
2248
+ return {
2249
+ text: "You have llms-full.txt but are missing llms.txt. Adding llms.txt provides AI systems with a concise structured overview of your site's purpose and key pages.",
2250
+ steps: [
2251
+ "Create llms.txt at your domain root (e.g., yoursite.com/llms.txt)",
2252
+ "Include your site's purpose, key pages, and documentation links (see code example)",
2253
+ "Deploy so the file is accessible via HTTP GET",
2254
+ "Verify by visiting yoursite.com/llms.txt"
2255
+ ],
2256
+ codeExample,
2257
+ learnMoreUrl: "https://llmstxt.org"
2258
+ };
2259
+ }
2260
+ return {
2261
+ text: `Missing ${missingFiles.join(" and ")}. These files help AI systems understand and accurately cite your site.`,
2262
+ steps: [
2263
+ `Create ${fileName} at your domain root (e.g., yoursite.com/${fileName})`,
2264
+ "Fill in your site's purpose, key pages, and documentation links (see code example)",
2265
+ "Deploy so the file is accessible via HTTP GET",
2266
+ `Verify by visiting yoursite.com/${fileName}`
2267
+ ],
2268
+ codeExample,
2269
+ learnMoreUrl: "https://llmstxt.org"
2270
+ };
2271
+ },
2272
+ "Image Accessibility": (rawData) => {
2273
+ const images = rawData.imageAccessibility;
2274
+ const altTextSteps = [
2275
+ "Add a descriptive alt attribute to every <img> tag",
2276
+ "Write alt text that describes what the image shows, not just its file name",
2277
+ 'Leave alt empty (alt="") for purely decorative images',
2278
+ "Wrap images that need caption context in <figure> with a <figcaption>"
2279
+ ];
2280
+ if (!images || images.imageCount === 0) {
2281
+ return {
2282
+ text: "Add descriptive alt text to all images and use <figure> with <figcaption> for semantic image context.",
2283
+ steps: altTextSteps,
2284
+ codeExample: imageAltTextExample
2285
+ };
2286
+ }
2287
+ const imagesWithoutAlt = images.imageCount - images.imagesWithAlt;
2288
+ const altCoveragePercent = Math.round(
2289
+ images.imagesWithAlt / images.imageCount * 100
2290
+ );
2291
+ let text = `${images.imagesWithAlt} of your ${images.imageCount} images have alt text (${altCoveragePercent}%). `;
2292
+ if (imagesWithoutAlt > 0) {
2293
+ text += `Add alt text to the remaining ${imagesWithoutAlt} image${imagesWithoutAlt === 1 ? "" : "s"}. `;
2294
+ }
2295
+ if (images.figcaptionCount === 0) {
2296
+ text += "Consider using <figure> with <figcaption> for images that need descriptive context.";
2297
+ }
2298
+ return { text, steps: altTextSteps, codeExample: imageAltTextExample };
2299
+ },
2300
+ "Heading Hierarchy": (rawData) => {
2301
+ const title = rawData.title || "Your Page Topic";
2302
+ const topic = title.split(" ").slice(-2).join(" ");
2303
+ return {
2304
+ text: "Use a clear H1 > H2 > H3 heading hierarchy. Headings serve as structural anchors that AI engines use to segment and reuse content.",
2305
+ steps: [
2306
+ "Use exactly 1 H1 tag for the main page title",
2307
+ "Use H2 tags for major sections (aim for 3+ sections)",
2308
+ "Use H3 tags for subsections within each H2",
2309
+ "Never skip levels (e.g., don't jump from H1 to H3)",
2310
+ "Frame H2s as questions when possible for answer capsule compatibility"
2311
+ ],
2312
+ codeExample: `<!-- Recommended heading structure -->
2313
+ <h1>${title}</h1>
2314
+
2315
+ <h2>What is ${topic}?</h2>
2316
+ <p>Definition and overview...</p>
2317
+
2318
+ <h3>Key Features</h3>
2319
+ <p>Details...</p>
2320
+
2321
+ <h2>Why Does ${topic} Matter?</h2>
2322
+ <p>Importance and context...</p>
2323
+
2324
+ <h2>How to Get Started</h2>
2325
+ <p>Step-by-step guide...</p>`
2326
+ };
2327
+ },
2328
+ "Lists Presence": () => ({
2329
+ text: "Add bulleted or numbered lists to organize information. Lists are easily extracted and reused by AI engines.",
2330
+ steps: [
2331
+ "Identify prose that enumerates 3 or more items in a sentence",
2332
+ "Convert those sentences into <ul> or <ol> list elements",
2333
+ "Use <ol> for sequential steps and <ul> for unordered collections",
2334
+ "Keep list items parallel \u2014 each starting with the same grammatical form"
2335
+ ],
2336
+ codeExample: listsConversionExample
2337
+ }),
2338
+ "Tables Presence": () => ({
2339
+ text: "Consider adding data tables for comparative or structured data. Tables are highly parseable by AI engines.",
2340
+ steps: [
2341
+ "Identify any comparisons, pricing tiers, specs, or feature matrices in your content",
2342
+ "Structure them as HTML <table> elements with a <thead> and <tbody>",
2343
+ "Add a <caption> element describing what the table shows",
2344
+ "Ensure every column has a clear <th> header so AI engines can map values to labels"
2345
+ ],
2346
+ codeExample: tablesMarkupExample
2347
+ }),
2348
+ "Paragraph Structure": constantRecommendation(
2349
+ "Keep paragraphs between 30-150 words for optimal readability and extractability."
2350
+ ),
2351
+ Scannability: constantRecommendation(
2352
+ "Use bold text, short paragraphs, and frequent headings to improve scannability for both humans and AI."
2353
+ ),
2354
+ "Section Length": (rawData) => {
2355
+ const sections = rawData.sectionLengths;
2356
+ if (!sections || sections.sectionCount === 0) {
2357
+ return {
2358
+ text: "Add headings to create distinct sections. Each headed section should be a self-contained unit that an AI engine could extract and reuse."
2359
+ };
2360
+ }
2361
+ const avg = Math.round(sections.avgWordsPerSection);
2362
+ if (avg < 120) {
2363
+ return {
2364
+ text: `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.`
2365
+ };
2366
+ }
2367
+ return {
2368
+ text: `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.`
2369
+ };
2370
+ },
2371
+ "Definition Patterns": (rawData) => {
2372
+ const detectedTopic = rawData.entities?.topics?.[0];
2373
+ const primaryTerm = detectedTopic ?? "AI SEO";
2374
+ const codeExample = `<!-- Inline definition on first use -->
2375
+ <p>${primaryTerm} is defined as [your one-sentence definition here].</p>
2376
+
2377
+ <!-- Definition list for multiple terms -->
2378
+ <dl>
2379
+ <dt>${primaryTerm}</dt>
2380
+ <dd>[A clear, concise definition that AI engines can extract and reuse.]</dd>
2381
+
2382
+ <dt>[Second key term]</dt>
2383
+ <dd>[The definition of your second most important concept.]</dd>
2384
+ </dl>`;
2385
+ return {
2386
+ text: 'Define key terms and concepts clearly (e.g., "X is defined as..." or "X refers to..."). Clear definitions are directly reusable by AI engines.',
2387
+ steps: [
2388
+ "Identify the 3 to 5 key terms central to your page topic",
2389
+ "Add a one-sentence definition for each using the 'X is' or 'X refers to' pattern",
2390
+ "Place each definition early in the section that first uses the term",
2391
+ "Use a <dl> definition list when the page covers many terms"
2392
+ ],
2393
+ codeExample
2394
+ };
2395
+ },
2396
+ "Direct Answer Statements": () => ({
2397
+ text: "Start key sentences with direct statements that could serve as standalone answers.",
2398
+ steps: [
2399
+ "Find paragraphs where the main point is buried after qualifications or context",
2400
+ "Move the core assertion to the first sentence of the paragraph",
2401
+ "Write opening sentences so they can stand alone as a complete answer",
2402
+ "Follow the direct statement with supporting detail, examples, and caveats"
2403
+ ],
2404
+ codeExample: directAnswerExample
2405
+ }),
2406
+ "Answer Capsules": (rawData) => {
2407
+ const capsules = rawData.answerCapsules;
2408
+ const detectedQuestion = rawData.questionsFound?.[0];
2409
+ const steps = [
2410
+ "Rewrite H2 headings as questions your audience would ask an AI (e.g., 'Benefits of X' \u2192 'What are the benefits of X?')",
2411
+ "Place a 1-2 sentence direct answer (under 200 characters) as the first paragraph after each H2",
2412
+ "Start the answer with a definitive statement, not a qualifier",
2413
+ "Follow the capsule with detailed supporting content"
2414
+ ];
2415
+ const codeExample = detectedQuestion ? `<!-- Before -->
2416
+ <h2>${detectedQuestion.replace(/\?$/, "").replace(/^(what|how|why|when|where|who)\s+/i, (m) => m.charAt(0).toUpperCase() + m.slice(1))}</h2>
2417
+ <p>There are many reasons to consider this topic today...</p>
2418
+
2419
+ <!-- After (answer capsule pattern) -->
2420
+ <h2>${detectedQuestion.endsWith("?") ? detectedQuestion : `${detectedQuestion}?`}</h2>
2421
+ <p>[Your direct, one-sentence answer here \u2014 under 200 characters.]</p>
2422
+ <p>[Supporting detail and context follows...]</p>` : answerCapsuleExample;
2423
+ if (!capsules || capsules.total === 0) {
2424
+ return {
2425
+ text: "Frame your H2 headings as questions and place a concise answer (under 200 characters) in the first sentence. 72% of AI-cited content uses this pattern.",
2426
+ steps,
2427
+ codeExample
2428
+ };
2429
+ }
2430
+ const missing = capsules.total - capsules.withCapsule;
2431
+ return {
2432
+ text: `${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.`,
2433
+ steps,
2434
+ codeExample
2435
+ };
2436
+ },
2437
+ "Step-by-Step Content": constantRecommendation(
2438
+ "Break down processes into clear, numbered steps. Step-by-step content is highly reusable by AI engines."
2439
+ ),
2440
+ "Q/A Patterns": (rawData) => {
2441
+ const questions = rawData.questionsFound;
2442
+ if (!questions || questions.length === 0) {
2443
+ return {
2444
+ text: 'Include and answer common questions your audience might have. Structure content to directly answer "what is", "how to" style queries.'
2445
+ };
2446
+ }
2447
+ return {
2448
+ text: `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.`
2449
+ };
2450
+ },
2451
+ "Summary/Conclusion": () => ({
2452
+ text: "Add a conclusion section with key takeaways or a summary. This helps AI engines quickly extract the main points.",
2453
+ steps: [
2454
+ "Add an H2 heading at the end of the page: 'Summary', 'Key Takeaways', or 'Conclusion'",
2455
+ "List 3 to 5 bullet points covering the most important points from the page",
2456
+ "Keep each bullet to one sentence \u2014 conclusion bullets are the most-cited part of a page",
2457
+ "Optionally follow the summary with a 'Next Steps' or 'Learn More' section"
2458
+ ],
2459
+ codeExample: summaryStructureExample
2460
+ }),
2461
+ "Entity Richness": (rawData) => {
2462
+ const entities = rawData.entities;
2463
+ const minimumRecommendedEntities = 9;
2464
+ const howToAddEntitiesSteps = [
2465
+ "Name the key people, organizations, and places relevant to your topic",
2466
+ "Link people and organizations to authoritative sources like Wikipedia or official sites",
2467
+ "Add context for each entity: their role, location, or relevance to the topic",
2468
+ `Aim for ${minimumRecommendedEntities} or more distinct named entities per page`
2469
+ ];
2470
+ if (!entities) {
2471
+ return {
2472
+ text: "Reference relevant experts, organizations, and places in your field. Named entities help AI engines understand context.",
2473
+ steps: howToAddEntitiesSteps
2474
+ };
2475
+ }
2476
+ const detectedEntityCount = entities.people.length + entities.organizations.length + entities.places.length + entities.topics.length;
2477
+ const entityBreakdownParts = [];
2478
+ if (entities.people.length > 0)
2479
+ entityBreakdownParts.push(`${entities.people.length} people`);
2480
+ if (entities.organizations.length > 0)
2481
+ entityBreakdownParts.push(
2482
+ `${entities.organizations.length} organizations`
2483
+ );
2484
+ if (entities.places.length > 0)
2485
+ entityBreakdownParts.push(`${entities.places.length} places`);
2486
+ if (entities.topics.length > 0)
2487
+ entityBreakdownParts.push(`${entities.topics.length} topics`);
2488
+ if (detectedEntityCount === 0) {
2489
+ return {
2490
+ text: "No named entities were detected. Reference specific people, organizations, and places to help AI engines understand what your content is about.",
2491
+ steps: howToAddEntitiesSteps
2492
+ };
2493
+ }
2494
+ return {
2495
+ text: `Found ${detectedEntityCount} unique entities (${entityBreakdownParts.join(", ")}). AI engines perform best with ${minimumRecommendedEntities}+ distinct entities. Add more specific names, organizations, and places relevant to your topic.`,
2496
+ steps: howToAddEntitiesSteps
2497
+ };
2498
+ },
2499
+ "Topic Consistency": constantRecommendation(
2500
+ "Align your main topics with your title and headings. Topic consistency helps AI engines understand what your page is about."
2501
+ ),
2502
+ "Entity Density": constantRecommendation(
2503
+ "Ensure a balanced density of named entities (2-8 per 100 words). Too few makes content vague; too many makes it hard to parse."
2504
+ ),
2505
+ "External References": (rawData) => {
2506
+ const externalLinks = rawData.externalLinks;
2507
+ const linkCount = externalLinks?.length ?? 0;
2508
+ const minimumRecommendedLinks = 6;
2509
+ const howToAddReferenceSteps = [
2510
+ "Identify factual claims that can be supported by an external source",
2511
+ "Find authoritative sources: research papers, industry reports, official documentation",
2512
+ "Wrap the linked text in a meaningful anchor \u2014 describe what you are linking to, not 'click here'",
2513
+ `Aim for ${minimumRecommendedLinks} or more external links per page`
2514
+ ];
2515
+ const firstLink = externalLinks?.[0];
2516
+ const linkedCitationExample = firstLink ? `<!-- Anchor an external reference to the claim it supports -->
2517
+ <p>
2518
+ As referenced in <a href="${firstLink.url}"
2519
+ rel="noopener">${firstLink.text || "this source"}</a>, [your claim here].
2520
+ </p>
2521
+
2522
+ <!-- Add more references using the same pattern -->
2523
+ <p>
2524
+ According to <a href="[source-url]" rel="noopener">[Source Name]</a>,
2525
+ [another claim supported by evidence].
2526
+ </p>` : `<!-- Anchor an external reference to the claim it supports -->
2527
+ <p>
2528
+ Structured content is cited 3x more often by AI engines,
2529
+ according to <a href="https://arxiv.org/abs/2311.09735"
2530
+ rel="noopener">Princeton's GEO research</a>.
2531
+ </p>`;
2532
+ if (linkCount === 0) {
2533
+ return {
2534
+ text: "Add links to reputable external sources to ground your claims. AI engines use external references to verify and attribute information.",
2535
+ steps: howToAddReferenceSteps,
2536
+ codeExample: linkedCitationExample
2537
+ };
2538
+ }
2539
+ return {
2540
+ text: `Found ${linkCount} external link${linkCount === 1 ? "" : "s"}. AI engines prefer content with ${minimumRecommendedLinks}+ external references. Add more links to authoritative sources that support your claims.`,
2541
+ steps: howToAddReferenceSteps,
2542
+ codeExample: linkedCitationExample
2543
+ };
2544
+ },
2545
+ "Citation Patterns": () => ({
2546
+ text: 'Use formal citation patterns (e.g., [1], "according to") when referencing sources.',
2547
+ steps: [
2548
+ "Add in-text citation markers like [1] or (Author, Year) after specific claims",
2549
+ "Include a 'References' or 'Sources' section at the bottom of the page",
2550
+ "Use <cite> elements around titles of books, articles, or research papers",
2551
+ "Link each citation marker to its corresponding reference entry"
2552
+ ],
2553
+ codeExample: citationMarkupExample
2554
+ }),
2555
+ "Numeric Claims": constantRecommendation(
2556
+ "Include relevant statistics and data points to support your content with verifiable claims."
2557
+ ),
2558
+ "Attribution Indicators": () => ({
2559
+ text: 'Attribute claims to specific sources or experts. Phrases like "according to" help AI engines trace information.',
2560
+ steps: [
2561
+ "Identify factual claims that currently have no source",
2562
+ "Prepend 'According to [Source]' or 'Per [Organization]' before each claim",
2563
+ "Link the source name to its original URL so AI engines can follow the reference",
2564
+ "Add a citation marker (e.g., [1]) for formal references tied to a sources section"
2565
+ ],
2566
+ codeExample: attributionIndicatorExample
2567
+ }),
2568
+ "Quoted Attribution": () => ({
2569
+ text: 'Add expert quotes with clear attribution. Use patterns like "Quote text" \u2014 Expert Name, or "Quote text," said Expert Name. Research shows quotation addition increased AI visibility by 30-40%.',
2570
+ steps: [
2571
+ "Find a relevant quote from an expert, publication, or research paper",
2572
+ "Wrap it in a <blockquote> with a <footer> containing a <cite> attribution",
2573
+ "Include the expert's full name, title, and organization",
2574
+ "Link the attribution to the original source where possible"
2575
+ ],
2576
+ codeExample: blockquoteAttributionExample,
2577
+ learnMoreUrl: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/blockquote"
2578
+ }),
2579
+ "Author Attribution": (rawData) => {
2580
+ const authorName = rawData.entities?.people?.[0] || "Author Name";
2581
+ const slug = authorName.toLowerCase().replace(/\s+/g, "-");
2582
+ return {
2583
+ text: "Add visible author information with a byline to establish content credibility and enable AI attribution.",
2584
+ steps: [
2585
+ "Add a visible byline near the top of the article (after the H1)",
2586
+ `Link the author name to an about/bio page using rel="author"`,
2587
+ "Add author information to your JSON-LD schema (see code example)"
2588
+ ],
2589
+ codeExample: `<!-- Add a visible byline -->
2590
+ <p class="byline">By <a href="/about/${slug}" rel="author">${authorName}</a></p>
2591
+
2592
+ <!-- Add to your JSON-LD schema -->
2593
+ "author": {
2594
+ "@type": "Person",
2595
+ "name": "${authorName}",
2596
+ "url": "https://yoursite.com/about/${slug}"
2597
+ }`,
2598
+ learnMoreUrl: "https://schema.org/author"
2599
+ };
2600
+ },
2601
+ "Organization Identity": (rawData) => {
2602
+ const detectedOrgName = rawData.entities?.organizations?.[0] ?? "Your Organization";
2603
+ return {
2604
+ text: "Add Organization structured data or og:site_name to help engines identify the source.",
2605
+ steps: [
2606
+ "Add an og:site_name meta tag to every page's <head>",
2607
+ "Add an Organization JSON-LD block to your site's global <head>",
2608
+ "Ensure the organization name is identical across JSON-LD, og:site_name, and visible page content"
2609
+ ],
2610
+ codeExample: `<!-- og:site_name in <head> -->
2611
+ <meta property="og:site_name" content="${detectedOrgName}">
2612
+
2613
+ <!-- Organization JSON-LD (add to global site header) -->
2614
+ <script type="application/ld+json">
2615
+ {
2616
+ "@context": "https://schema.org",
2617
+ "@type": "Organization",
2618
+ "name": "${detectedOrgName}",
2619
+ "url": "https://yoursite.com",
2620
+ "logo": "https://yoursite.com/logo.png",
2621
+ "sameAs": [
2622
+ "https://twitter.com/yourhandle",
2623
+ "https://linkedin.com/company/yourcompany"
2624
+ ]
2625
+ }
2626
+ </script>`,
2627
+ learnMoreUrl: "https://schema.org/Organization"
2628
+ };
2629
+ },
2630
+ "Contact/About Links": () => ({
2631
+ text: "Link to About and Contact pages to establish credibility and enable source verification.",
2632
+ steps: [
2633
+ "Add links to your About and Contact pages in the site navigation",
2634
+ "Include them in the footer of every page",
2635
+ "Ensure the links are reachable via plain anchor tags \u2014 not JavaScript-only interactions"
2636
+ ],
2637
+ codeExample: contactLinksExample
2638
+ }),
2639
+ "Publication Date": () => {
2640
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2641
+ return {
2642
+ text: "Include publication and last-updated dates using proper HTML5 time elements or schema markup.",
2643
+ steps: [
2644
+ "Add a visible publication date near the article title or byline",
2645
+ "Wrap the date in a <time> element with a machine-readable datetime attribute",
2646
+ "Add datePublished and dateModified to your JSON-LD schema"
2647
+ ],
2648
+ codeExample: `<!-- Visible dates with <time> elements -->
2649
+ <p>Published: <time datetime="${today}" itemprop="datePublished">${today}</time></p>
2650
+ <p>Updated: <time datetime="${today}" itemprop="dateModified">${today}</time></p>
2651
+
2652
+ <!-- In your JSON-LD schema -->
2653
+ "datePublished": "${today}",
2654
+ "dateModified": "${today}"`,
2655
+ learnMoreUrl: "https://schema.org/datePublished"
2656
+ };
2657
+ },
2658
+ "Content Freshness": (rawData) => {
2659
+ const freshness = rawData.freshness;
2660
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2661
+ const codeExample = `<!-- Add/update in your HTML -->
2662
+ <time datetime="${today}" itemprop="dateModified">${today}</time>
2663
+
2664
+ <!-- Add/update in your JSON-LD -->
2665
+ "dateModified": "${today}"`;
2666
+ const steps = [
2667
+ "Review and update your content with current information",
2668
+ "Update the dateModified in both visible HTML and JSON-LD schema (see code example)",
2669
+ "Set up a recurring reminder to review content every 6 months"
2670
+ ];
2671
+ if (!freshness || freshness.ageInMonths === null) {
2672
+ return {
2673
+ text: "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.",
2674
+ steps: [
2675
+ "Add a <time> element with a datetime attribute to your page",
2676
+ "Add datePublished and dateModified to your JSON-LD schema",
2677
+ steps[2]
2678
+ ],
2679
+ codeExample
2680
+ };
2681
+ }
2682
+ const months = Math.round(freshness.ageInMonths);
2683
+ let text;
2684
+ if (months > 24) {
2685
+ text = `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.`;
2686
+ } else if (months > 12) {
2687
+ text = `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.`;
2688
+ } else if (!freshness.hasModifiedDate) {
2689
+ text = `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.`;
2690
+ } else {
2691
+ text = "Update your content to include a recent publication or modified date. Content freshness acts as a hard gate for AI engine citations.";
2692
+ }
2693
+ return { text, steps, codeExample };
2694
+ },
2695
+ "Structured Data": (rawData) => {
2696
+ const types = rawData.structuredDataTypes;
2697
+ if (types && types.length > 0) {
2698
+ return {
2699
+ text: `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.`,
2700
+ steps: [
2701
+ "Verify all JSON-LD types have required properties (check Schema Completeness factor)",
2702
+ `Add og:title, og:description, and og:image meta tags if missing`,
2703
+ `Add a <link rel="canonical"> tag pointing to the preferred URL`
2704
+ ],
2705
+ learnMoreUrl: "https://schema.org/docs/gs.html"
2706
+ };
2707
+ }
2708
+ const title = rawData.title || "Your Page Title";
2709
+ const description = rawData.metaDescription || "Your page description";
2710
+ const schemaType = rawData.questionsFound?.length ? "FAQPage" : "Article";
2711
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2712
+ let codeExample;
2713
+ if (schemaType === "FAQPage" && rawData.questionsFound?.length) {
2714
+ const questions = rawData.questionsFound.slice(0, 3);
2715
+ const faqEntries = questions.map(
2716
+ (q) => ` {
2717
+ "@type": "Question",
2718
+ "name": "${q}",
2719
+ "acceptedAnswer": {
2720
+ "@type": "Answer",
2721
+ "text": "Your answer here"
2722
+ }
2723
+ }`
2724
+ ).join(",\n");
2725
+ codeExample = `<script type="application/ld+json">
2726
+ {
2727
+ "@context": "https://schema.org",
2728
+ "@type": "FAQPage",
2729
+ "mainEntity": [
2730
+ ${faqEntries}
2731
+ ]
2732
+ }
2733
+ </script>`;
2734
+ } else {
2735
+ codeExample = `<script type="application/ld+json">
2736
+ {
2737
+ "@context": "https://schema.org",
2738
+ "@type": "Article",
2739
+ "headline": "${title}",
2740
+ "description": "${description}",
2741
+ "author": {
2742
+ "@type": "Person",
2743
+ "name": "Author Name"
2744
+ },
2745
+ "publisher": {
2746
+ "@type": "Organization",
2747
+ "name": "Your Organization"
2748
+ },
2749
+ "datePublished": "${today}",
2750
+ "dateModified": "${today}"
2751
+ }
2752
+ </script>`;
2753
+ }
2754
+ return {
2755
+ text: "Add JSON-LD structured data and Open Graph tags to provide machine-readable context.",
2756
+ steps: [
2757
+ `Add a ${schemaType} JSON-LD block to your <head> (see code example)`,
2758
+ "Fill in author, publisher, and date fields with real values",
2759
+ "Add og:title, og:description, and og:image <meta> tags",
2760
+ `Add a <link rel="canonical"> pointing to the preferred URL`,
2761
+ "Validate at https://search.google.com/test/rich-results"
2762
+ ],
2763
+ codeExample,
2764
+ learnMoreUrl: "https://schema.org/docs/gs.html"
2765
+ };
2766
+ },
2767
+ "Schema Completeness": (rawData) => {
2768
+ const schema = rawData.schemaCompleteness;
2769
+ if (!schema || schema.details.length === 0) {
2770
+ return {
2771
+ text: "Add JSON-LD schema with all recommended properties. Complete schemas help AI engines attribute and trust your content.",
2772
+ learnMoreUrl: "https://schema.org/docs/gs.html"
2773
+ };
2774
+ }
2775
+ const incomplete = schema.details.filter((d) => d.missing.length > 0);
2776
+ if (incomplete.length === 0) {
2777
+ return {
2778
+ text: "Ensure your JSON-LD schema types include all recommended properties for maximum AI engine trust."
2779
+ };
2780
+ }
2781
+ const summaries = incomplete.map(
2782
+ (d) => `${d.type} is missing ${d.missing.join(", ")}`
2783
+ );
2784
+ const primary = incomplete[0];
2785
+ const placeholders = {
2786
+ headline: `"headline": "${rawData.title || "Your headline"}"`,
2787
+ author: `"author": { "@type": "Person", "name": "Author Name" }`,
2788
+ datePublished: `"datePublished": "${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}"`,
2789
+ dateModified: `"dateModified": "${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}"`,
2790
+ description: `"description": "${rawData.metaDescription || "Your description"}"`,
2791
+ image: `"image": "https://yoursite.com/image.jpg"`,
2792
+ publisher: `"publisher": { "@type": "Organization", "name": "Your Org" }`,
2793
+ name: `"name": "${rawData.title || "Your Name"}"`,
2794
+ url: `"url": "https://yoursite.com"`,
2795
+ mainEntity: `"mainEntity": []`,
2796
+ step: `"step": [{ "@type": "HowToStep", "text": "Step 1..." }]`,
2797
+ address: `"address": { "@type": "PostalAddress", "streetAddress": "..." }`
2798
+ };
2799
+ const missingProps = primary.missing.map(
2800
+ (prop) => placeholders[prop] || `"${prop}": "..."`
2801
+ );
2802
+ const codeExample = `// Add these properties to your existing ${primary.type} schema:
2803
+ {
2804
+ ${missingProps.join(",\n ")}
2805
+ }`;
2806
+ return {
2807
+ text: `Your ${summaries.join("; ")}. Adding these properties helps AI engines attribute and trust your content.`,
2808
+ steps: incomplete.map(
2809
+ (d) => `Add ${d.missing.join(", ")} to your ${d.type} schema`
2810
+ ),
2811
+ codeExample,
2812
+ learnMoreUrl: `https://schema.org/${primary.type}`
2813
+ };
2814
+ },
2815
+ "Entity Consistency": (rawData) => {
2816
+ const ec = rawData.entityConsistency;
2817
+ if (!ec || !ec.entityName) {
2818
+ return {
2819
+ text: "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."
2820
+ };
2821
+ }
2822
+ return {
2823
+ text: `"${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.`
2824
+ };
2825
+ },
2826
+ "Sentence Length": (rawData) => {
2827
+ const avg = rawData.avgSentenceLength;
2828
+ if (avg === void 0) {
2829
+ return {
2830
+ text: "Aim for an average sentence length of 12-22 words for optimal readability and compressibility."
2831
+ };
2832
+ }
2833
+ const rounded = Math.round(avg);
2834
+ if (rounded > 22) {
2835
+ return {
2836
+ text: `Your average sentence is ${rounded} words. The ideal range for AI compression is 12-22 words. Break long sentences into shorter, more direct statements.`
2837
+ };
2838
+ }
2839
+ if (rounded < 12) {
2840
+ return {
2841
+ text: `Your average sentence is ${rounded} words. While short sentences are readable, combining some into 12-22 word sentences provides better context for AI summarization.`
2842
+ };
2843
+ }
2844
+ return {
2845
+ text: `Your average sentence length is ${rounded} words. Fine-tune toward the 12-22 word sweet spot for optimal AI compression.`
2846
+ };
2847
+ },
2848
+ Readability: (rawData) => {
2849
+ const score = rawData.readabilityScore;
2850
+ if (score === void 0) {
2851
+ return {
2852
+ text: "Simplify language where possible. A Flesch Reading Ease score of 60-70 is ideal for broad AI reusability."
2853
+ };
2854
+ }
2855
+ const rounded = Math.round(score);
2856
+ if (rounded < 30) {
2857
+ return {
2858
+ text: `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.`
2859
+ };
2860
+ }
2861
+ if (rounded < 50) {
2862
+ return {
2863
+ text: `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.`
2864
+ };
2865
+ }
2866
+ if (rounded < 60) {
2867
+ return {
2868
+ text: `Your Flesch Reading Ease score is ${rounded} (fairly difficult). You're close to the ideal 60-70 range. Minor simplification would improve AI compressibility.`
2869
+ };
2870
+ }
2871
+ return {
2872
+ text: `Your Flesch Reading Ease score is ${rounded}. A score of 60-70 is ideal for broad AI reusability.`
2873
+ };
2874
+ },
2875
+ "Jargon Density": () => ({
2876
+ text: "Define technical terms or replace with simpler alternatives. High jargon density reduces AI reusability.",
2877
+ steps: [
2878
+ "List the 5 most domain-specific terms on the page",
2879
+ "For each term: either add a plain-English definition on first use, or replace with simpler language",
2880
+ "Use the 'is defined as' or 'also known as' pattern for terms you must keep",
2881
+ "Aim for a Flesch Reading Ease score of 60 to 70 by simplifying sentence structure"
2882
+ ],
2883
+ codeExample: jargonReductionExample
2884
+ }),
2885
+ "Transition Usage": () => ({
2886
+ text: "Use transition words (however, therefore, additionally) to improve content flow and logical structure.",
2887
+ steps: [
2888
+ "Add contrast transitions between opposing ideas: 'however', 'although', 'on the other hand'",
2889
+ "Add sequence transitions between steps or points: 'first', 'next', 'finally'",
2890
+ "Add addition transitions to build on a point: 'additionally', 'furthermore', 'in addition'",
2891
+ "Add conclusion transitions at the end of sections: 'therefore', 'as a result', 'in summary'"
2892
+ ],
2893
+ codeExample: transitionWordsExample
2894
+ })
2895
+ };
2896
+
2897
+ // src/modules/recommendations/service.ts
2898
+ function generateRecommendations(auditResult) {
2899
+ const recommendations = [];
2900
+ for (const category of Object.values(auditResult.categories)) {
2901
+ for (const factor of category.factors) {
2902
+ const pct = factor.maxScore > 0 ? factor.score / factor.maxScore : 1;
2903
+ if (pct >= 0.7) continue;
2904
+ const priority = pct < 0.3 ? "high" : pct < 0.5 ? "medium" : "low";
2905
+ const builder = RECOMMENDATION_BUILDERS[factor.name];
2906
+ const output = builder ? builder(auditResult.rawData) : {
2907
+ text: `Review and improve "${factor.name}" based on best practices for AI search readiness.`
2908
+ };
2909
+ recommendations.push({
2910
+ category: category.name,
2911
+ factor: factor.name,
2912
+ currentValue: factor.value,
2913
+ priority,
2914
+ recommendation: output.text,
2915
+ ...output.steps && { steps: output.steps },
2916
+ ...output.codeExample && { codeExample: output.codeExample },
2917
+ ...output.learnMoreUrl && { learnMoreUrl: output.learnMoreUrl }
2918
+ });
2919
+ }
2920
+ }
2921
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
2922
+ recommendations.sort((a, b) => {
2923
+ const pd = priorityOrder[a.priority] - priorityOrder[b.priority];
2924
+ if (pd !== 0) return pd;
2925
+ return a.factor.localeCompare(b.factor);
2926
+ });
2927
+ return recommendations;
2928
+ }
2929
+
2930
+ // src/modules/scoring/constants.ts
2931
+ var GRADE_THRESHOLDS = [
2932
+ [93, "A"],
2933
+ [90, "A-"],
2934
+ [87, "B+"],
2935
+ [83, "B"],
2936
+ [80, "B-"],
2937
+ [77, "C+"],
2938
+ [73, "C"],
2939
+ [70, "C-"],
2940
+ [67, "D+"],
2941
+ [63, "D"],
2942
+ [60, "D-"],
2943
+ [0, "F"]
2944
+ ];
2945
+
2946
+ // src/modules/scoring/service.ts
2947
+ function computeScore(categories, weights) {
2948
+ const weightMap = {
2949
+ contentExtractability: weights.contentExtractability,
2950
+ contentStructure: weights.contentStructure,
2951
+ answerability: weights.answerability,
2952
+ entityClarity: weights.entityClarity,
2953
+ groundingSignals: weights.groundingSignals,
2954
+ authorityContext: weights.authorityContext,
2955
+ readabilityForCompression: weights.readabilityForCompression
2956
+ };
2957
+ const totalWeight = Object.values(weightMap).reduce((sum, w) => sum + w, 0);
2958
+ let totalPoints = 0;
2959
+ let maxPoints = 0;
2960
+ let weightedScore = 0;
2961
+ for (const [key, category] of Object.entries(categories)) {
2962
+ totalPoints += category.score;
2963
+ maxPoints += category.maxScore;
2964
+ const w = weightMap[key] ?? 1;
2965
+ const normalizedWeight = totalWeight > 0 ? w / totalWeight : 1 / 7;
2966
+ const categoryPct = category.maxScore > 0 ? category.score / category.maxScore * 100 : 0;
2967
+ weightedScore += categoryPct * normalizedWeight;
2968
+ }
2969
+ const overallScore = Math.round(weightedScore);
2970
+ const grade = computeGrade(overallScore);
2971
+ return { overallScore, grade, totalPoints, maxPoints };
2972
+ }
2973
+ function computeGrade(score) {
2974
+ for (const [threshold, grade] of GRADE_THRESHOLDS) {
2975
+ if (score >= threshold) return grade;
2976
+ }
2977
+ return "F";
2978
+ }
2979
+
2980
+ // src/modules/analyzer/service.ts
2981
+ async function fetchDomainSignals(url, timeout, userAgent) {
2982
+ const cappedTimeout = Math.min(timeout, DOMAIN_SIGNAL_TIMEOUT_CAP);
2983
+ const [robotsRes, llmsRes, llmsFullRes] = await Promise.allSettled([
2984
+ httpGet({
2985
+ url: `${url}/robots.txt`,
2986
+ timeout: cappedTimeout,
2987
+ userAgent
2988
+ }),
2989
+ httpHead({
2990
+ url: `${url}/llms.txt`,
2991
+ timeout: cappedTimeout,
2992
+ userAgent
2993
+ }),
2994
+ httpHead({
2995
+ url: `${url}/llms-full.txt`,
2996
+ timeout: cappedTimeout,
2997
+ userAgent
2998
+ })
2999
+ ]);
3000
+ return {
3001
+ signalsBase: url,
3002
+ robotsTxt: robotsRes.status === "fulfilled" && robotsRes.value.status === 200 ? robotsRes.value.data : null,
3003
+ llmsTxtExists: llmsRes.status === "fulfilled" && llmsRes.value.status === 200,
3004
+ llmsFullTxtExists: llmsFullRes.status === "fulfilled" && llmsFullRes.value.status === 200
3005
+ };
3006
+ }
3007
+ function buildResult(url, fetchResult, domainSignals, config) {
3008
+ const page = extractPage(fetchResult.html, url);
3009
+ const auditResult = runAudits(page, fetchResult, domainSignals);
3010
+ const scoring = computeScore(auditResult.categories, config.weights);
3011
+ const recommendations = generateRecommendations(auditResult);
3012
+ return {
3013
+ url,
3014
+ signalsBase: domainSignals.signalsBase,
3015
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
3016
+ overallScore: scoring.overallScore,
3017
+ grade: scoring.grade,
3018
+ totalPoints: scoring.totalPoints,
3019
+ maxPoints: scoring.maxPoints,
3020
+ categories: auditResult.categories,
3021
+ recommendations,
3022
+ rawData: auditResult.rawData,
3023
+ meta: {
3024
+ version: VERSION
3025
+ }
3026
+ };
3027
+ }
3028
+ async function analyzeUrlWithSignals(url, fetchResult, domainSignals, config) {
3029
+ const startTime = Date.now();
3030
+ const result = buildResult(url, fetchResult, domainSignals, config);
3031
+ return {
3032
+ ...result,
3033
+ meta: { ...result.meta, analysisDurationMs: Date.now() - startTime }
3034
+ };
3035
+ }
3036
+ async function analyzeUrl(options, config) {
3037
+ const startTime = Date.now();
3038
+ const url = normalizeUrl(options.url);
3039
+ const timeout = options.timeout ?? config.timeout;
3040
+ const userAgent = options.userAgent ?? config.userAgent;
3041
+ const fetchResult = await fetchUrl({ url, timeout, userAgent });
3042
+ const signalsBase = options.signalsBase ?? fetchResult.finalUrl ?? url;
3043
+ const domainSignals = await fetchDomainSignals(
3044
+ signalsBase,
3045
+ timeout,
3046
+ userAgent
3047
+ );
3048
+ const result = buildResult(url, fetchResult, domainSignals, config);
3049
+ return {
3050
+ ...result,
3051
+ meta: { ...result.meta, analysisDurationMs: Date.now() - startTime }
3052
+ };
3053
+ }
3054
+
3055
+ // src/utils/fs.ts
3056
+ import { access, writeFile as fsWriteFile } from "fs/promises";
3057
+ async function fileExists(path) {
3058
+ try {
3059
+ await access(path);
3060
+ return true;
3061
+ } catch {
3062
+ return false;
3063
+ }
3064
+ }
3065
+ async function writeOutputFile(path, content) {
3066
+ await fsWriteFile(path, content, "utf-8");
3067
+ }
3068
+
3069
+ // src/modules/config/service.ts
3070
+ import { readFile } from "fs/promises";
3071
+ import { dirname, join, resolve } from "path";
3072
+
3073
+ // src/modules/config/constants.ts
3074
+ var CONFIG_FILENAMES = [
3075
+ "aiseo.config.json",
3076
+ ".aiseo.config.json",
3077
+ "aiseo-audit.config.json"
3078
+ ];
3079
+
3080
+ // src/modules/config/schema.ts
3081
+ import { z as z2 } from "zod";
3082
+ var DEFAULT_USER_AGENT = `AISEOAudit/${VERSION}`;
3083
+ var DEFAULT_WEIGHTS = {
3084
+ contentExtractability: 1,
3085
+ contentStructure: 1,
3086
+ answerability: 1,
3087
+ entityClarity: 1,
3088
+ groundingSignals: 1,
3089
+ authorityContext: 1,
3090
+ readabilityForCompression: 1
3091
+ };
3092
+ var CategoryWeightSchema = z2.object({
3093
+ contentExtractability: z2.number().min(0).default(1),
3094
+ contentStructure: z2.number().min(0).default(1),
3095
+ answerability: z2.number().min(0).default(1),
3096
+ entityClarity: z2.number().min(0).default(1),
3097
+ groundingSignals: z2.number().min(0).default(1),
3098
+ authorityContext: z2.number().min(0).default(1),
3099
+ readabilityForCompression: z2.number().min(0).default(1)
3100
+ }).default(DEFAULT_WEIGHTS);
3101
+ var AiseoConfigSchema = z2.object({
3102
+ timeout: z2.number().positive().default(45e3),
3103
+ userAgent: z2.string().default(DEFAULT_USER_AGENT),
3104
+ format: z2.enum(["pretty", "json", "md", "html"]).default("pretty"),
3105
+ failUnder: z2.number().min(0).max(100).optional(),
3106
+ weights: CategoryWeightSchema
3107
+ }).default({
3108
+ timeout: 45e3,
3109
+ userAgent: DEFAULT_USER_AGENT,
3110
+ format: "pretty",
3111
+ weights: DEFAULT_WEIGHTS
3112
+ });
3113
+
3114
+ // src/modules/config/service.ts
3115
+ async function findConfigFile(startDir) {
3116
+ let dir = resolve(startDir);
3117
+ while (true) {
3118
+ for (const filename of CONFIG_FILENAMES) {
3119
+ const candidate = join(dir, filename);
3120
+ if (await fileExists(candidate)) return candidate;
3121
+ }
3122
+ const parent = dirname(dir);
3123
+ if (parent === dir) return null;
3124
+ dir = parent;
3125
+ }
3126
+ }
3127
+ async function loadConfig(configPath) {
3128
+ if (configPath) {
3129
+ const resolvedPath = resolve(configPath);
3130
+ const content = await readFile(resolvedPath, "utf-8");
3131
+ try {
3132
+ return AiseoConfigSchema.parse(JSON.parse(content));
3133
+ } catch (err) {
3134
+ throw new Error(
3135
+ `Invalid config file "${resolvedPath}": ${err instanceof Error ? err.message : String(err)}`
3136
+ );
3137
+ }
3138
+ }
3139
+ const found = await findConfigFile(process.cwd());
3140
+ if (found) {
3141
+ const content = await readFile(found, "utf-8");
3142
+ try {
3143
+ return AiseoConfigSchema.parse(JSON.parse(content));
3144
+ } catch (err) {
3145
+ throw new Error(
3146
+ `Invalid config file "${found}": ${err instanceof Error ? err.message : String(err)}`
3147
+ );
3148
+ }
3149
+ }
3150
+ return AiseoConfigSchema.parse({});
3151
+ }
3152
+
3153
+ // src/modules/report/support/html.ts
3154
+ function scoreColorHex(pct) {
3155
+ if (pct >= 90) return "#00cc66";
3156
+ if (pct >= 50) return "#ffaa33";
3157
+ return "#ff3333";
3158
+ }
3159
+ function scoreTextColorHex(pct) {
3160
+ if (pct >= 90) return "#008800";
3161
+ if (pct >= 50) return "#ffaa33";
3162
+ return "#cc0000";
3163
+ }
3164
+ function scoreClass(pct) {
3165
+ if (pct >= 90) return "pass";
3166
+ if (pct >= 50) return "average";
3167
+ return "fail";
3168
+ }
3169
+ function statusIcon(status) {
3170
+ if (status === "good") return "&#10003;";
3171
+ if (status === "neutral") return "&#8212;";
3172
+ if (status === "needs_improvement") return "&#9650;";
3173
+ return "&#10007;";
3174
+ }
3175
+ function statusClass(status) {
3176
+ if (status === "good") return "good";
3177
+ if (status === "neutral") return "neutral";
3178
+ if (status === "needs_improvement") return "warn";
3179
+ return "fail";
3180
+ }
3181
+ function escapeHtml(text) {
3182
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3183
+ }
3184
+ function buildGaugeSvg(score) {
3185
+ const pct = Math.max(0, Math.min(100, score));
3186
+ const arcColor = scoreColorHex(pct);
3187
+ const textColor = scoreTextColorHex(pct);
3188
+ const radius = 56;
3189
+ const circumference = 2 * Math.PI * radius;
3190
+ const offset = circumference * (1 - pct / 100);
3191
+ const dim = 64;
3192
+ const fontSize = 22;
3193
+ const strokeWidth = 7;
3194
+ return `<svg class="gauge" viewBox="0 0 120 120" width="${dim}" height="${dim}">
3195
+ <circle cx="60" cy="60" r="${radius}" fill="none" stroke="#e0e0e0" stroke-width="${strokeWidth}"/>
3196
+ <circle cx="60" cy="60" r="${radius}" fill="none" stroke="${arcColor}" stroke-width="${strokeWidth}"
3197
+ stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
3198
+ stroke-linecap="round" transform="rotate(-90 60 60)"/>
3199
+ <text x="60" y="${60 + fontSize * 0.35}" text-anchor="middle" font-size="${fontSize}" font-weight="700" fill="${textColor}">${score}</text>
3200
+ </svg>`;
3201
+ }
3202
+ function buildMultiSegmentGauge(overallScore, grade, totalPoints, maxPoints, categories) {
3203
+ const radius = 80;
3204
+ const strokeWidth = 14;
3205
+ const pad2 = 8;
3206
+ const size = (radius + strokeWidth / 2 + pad2) * 2;
3207
+ const cx = size / 2;
3208
+ const cy = size / 2;
3209
+ const circ = 2 * Math.PI * radius;
3210
+ const textColor = scoreTextColorHex(overallScore);
3211
+ const trackCircle = `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="#e8e8e8" stroke-width="${strokeWidth}"/>`;
3212
+ const arcs = [];
3213
+ let consumed = 0;
3214
+ let segIdx = 0;
3215
+ for (const cat of categories) {
3216
+ const catDeg = maxPoints > 0 ? cat.score / maxPoints * 360 : 0;
3217
+ if (catDeg < 0.1) {
3218
+ consumed += catDeg;
3219
+ continue;
3220
+ }
3221
+ const catPct = cat.maxScore > 0 ? Math.round(cat.score / cat.maxScore * 100) : 0;
3222
+ const color = scoreColorHex(catPct);
3223
+ const arcLen = catDeg / 360 * circ;
3224
+ const offset = circ * 0.25 - consumed / 360 * circ;
3225
+ const catName = escapeHtml(cat.name);
3226
+ const idx = segIdx;
3227
+ arcs.push(
3228
+ `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none"
3229
+ stroke="${color}" stroke-width="${strokeWidth}"
3230
+ stroke-dasharray="${arcLen.toFixed(2)} ${(circ - arcLen).toFixed(2)}"
3231
+ stroke-dashoffset="${offset.toFixed(2)}"
3232
+ class="seg-arc" data-idx="${idx}"
3233
+ onmouseenter="document.getElementById('seg-pop-${idx}').style.display='flex'"
3234
+ onmouseleave="document.getElementById('seg-pop-${idx}').style.display='none'"/>`
3235
+ );
3236
+ consumed += catDeg;
3237
+ const divRad = consumed / 360 * 2 * Math.PI - Math.PI / 2;
3238
+ const half = strokeWidth / 2 + 1;
3239
+ const dx = Math.cos(divRad);
3240
+ const dy = Math.sin(divRad);
3241
+ arcs.push(
3242
+ `<line x1="${(cx + (radius - half) * dx).toFixed(2)}" y1="${(cy + (radius - half) * dy).toFixed(2)}"
3243
+ x2="${(cx + (radius + half) * dx).toFixed(2)}" y2="${(cy + (radius + half) * dy).toFixed(2)}"
3244
+ stroke="#fff" stroke-width="2" pointer-events="none"/>`
3245
+ );
3246
+ segIdx++;
3247
+ }
3248
+ const popovers = categories.filter((cat) => cat.score > 0).map((cat, i) => {
3249
+ const catPct = cat.maxScore > 0 ? Math.round(cat.score / cat.maxScore * 100) : 0;
3250
+ const color = scoreColorHex(catPct);
3251
+ return `<div id="seg-pop-${i}" class="seg-popover">
3252
+ <span class="seg-popover-dot" style="background:${color}"></span>
3253
+ <span class="seg-popover-name">${escapeHtml(cat.name)}</span>
3254
+ <span class="seg-popover-score">${catPct}%</span>
3255
+ <span class="seg-popover-pts">${cat.score}/${cat.maxScore} pts</span>
3256
+ </div>`;
3257
+ }).join("");
3258
+ return `<div class="overall-gauge-wrap">
3259
+ <svg class="gauge" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
3260
+ ${trackCircle}
3261
+ ${arcs.join("\n ")}
3262
+ <text x="${cx}" y="${cy - 8}" text-anchor="middle" font-size="40" font-weight="700" fill="${textColor}">${overallScore}</text>
3263
+ <text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="14" font-weight="600" fill="${textColor}">${escapeHtml(grade)}</text>
3264
+ <text x="${cx}" y="${cy + 30}" text-anchor="middle" font-size="11" fill="#999">${totalPoints}/${maxPoints} pts</text>
3265
+ </svg>
3266
+ <div class="seg-popovers">${popovers}</div>
3267
+ <div class="score-scale">
3268
+ <span class="scale-fail">0-49</span>
3269
+ <span class="scale-average">50-89</span>
3270
+ <span class="scale-pass">90-100</span>
3271
+ </div>
3272
+ </div>`;
3273
+ }
3274
+ function buildCategoryGauge(category) {
3275
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
3276
+ return `<a class="gauge-item" href="#cat-${escapeHtml(category.name.replace(/\s+/g, "-").toLowerCase())}">
3277
+ ${buildGaugeSvg(pct)}
3278
+ <span class="gauge-label">${escapeHtml(category.name)}</span>
3279
+ </a>`;
3280
+ }
3281
+ function buildCategorySection(category) {
3282
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
3283
+ const cls = scoreClass(pct);
3284
+ const id = category.name.replace(/\s+/g, "-").toLowerCase();
3285
+ const factorRows = category.factors.map(
3286
+ (f) => `
3287
+ <div class="audit-row">
3288
+ <span class="audit-icon ${statusClass(f.status)}">${statusIcon(f.status)}</span>
3289
+ <span class="audit-name">${escapeHtml(f.name)}</span>
3290
+ <span class="audit-detail">${escapeHtml(f.value)}</span>
3291
+ <span class="audit-score">${f.score}/${f.maxScore}</span>
3292
+ </div>`
3293
+ ).join("");
3294
+ return `
3295
+ <div class="category" id="cat-${id}">
3296
+ <div class="category-header">
3297
+ <div class="category-title ${cls}">${escapeHtml(category.name)}</div>
3298
+ <div class="category-score ${cls}">${pct}%</div>
3299
+ </div>
3300
+ <div class="audits">${factorRows}</div>
3301
+ </div>`;
3302
+ }
3303
+ function buildRecommendationRow(rec) {
3304
+ const cls = rec.priority === "high" ? "priority-high" : rec.priority === "medium" ? "priority-med" : "priority-low";
3305
+ const label = rec.priority === "high" ? "HIGH" : rec.priority === "medium" ? "MED" : "LOW";
3306
+ let detailHtml = "";
3307
+ if (rec.steps || rec.codeExample || rec.learnMoreUrl) {
3308
+ let inner = "";
3309
+ if (rec.steps && rec.steps.length > 0) {
3310
+ const items = rec.steps.map((s) => `<li>${escapeHtml(s)}</li>`).join("");
3311
+ inner += `<ol class="rec-steps">${items}</ol>`;
3312
+ }
3313
+ if (rec.codeExample) {
3314
+ inner += `<pre class="rec-code"><code>${escapeHtml(rec.codeExample)}</code></pre>`;
3315
+ }
3316
+ if (rec.learnMoreUrl) {
3317
+ inner += `<a class="rec-learn-more" href="${escapeHtml(rec.learnMoreUrl)}" target="_blank" rel="noopener">Learn more &rarr;</a>`;
3318
+ }
3319
+ detailHtml = `<div class="rec-detail">${inner}</div>`;
3320
+ }
3321
+ return `
3322
+ <div class="rec-row ${cls}">
3323
+ <span class="rec-tag">${label}</span>
3324
+ <span class="rec-factor">${escapeHtml(rec.factor)}</span>
3325
+ <span class="rec-text">${escapeHtml(rec.recommendation)}</span>
3326
+ </div>${detailHtml}`;
3327
+ }
3328
+ function buildRecommendationsByCategory(recommendations, categories) {
3329
+ const categoryNames = Object.values(categories).map((c) => c.name);
3330
+ const grouped = /* @__PURE__ */ new Map();
3331
+ for (const name of categoryNames) {
3332
+ const recs = recommendations.filter((r) => r.category === name);
3333
+ if (recs.length > 0) grouped.set(name, recs);
3334
+ }
3335
+ if (grouped.size === 0) return "";
3336
+ let html = `<div class="recs-section">
3337
+ <div class="recs-title">Recommendations</div>`;
3338
+ for (const [categoryName, recs] of grouped) {
3339
+ html += `<div class="rec-group">
3340
+ <div class="rec-group-name">${escapeHtml(categoryName)}</div>`;
3341
+ html += recs.map(buildRecommendationRow).join("");
3342
+ html += `</div>`;
3343
+ }
3344
+ html += `</div>`;
3345
+ return html;
3346
+ }
3347
+ function renderHtml(result) {
3348
+ const categoryEntries = Object.entries(result.categories);
3349
+ const categories = categoryEntries.map(([, c]) => c);
3350
+ const categoriesWithKeys = categoryEntries.map(([key, c]) => ({
3351
+ key,
3352
+ name: c.name,
3353
+ score: c.score,
3354
+ maxScore: c.maxScore
3355
+ }));
3356
+ const gauges = categories.map(buildCategoryGauge).join("");
3357
+ const sections = categories.map(buildCategorySection).join("");
3358
+ const recsHtml = buildRecommendationsByCategory(
3359
+ result.recommendations,
3360
+ result.categories
3361
+ );
3362
+ const overallGauge = buildMultiSegmentGauge(
3363
+ result.overallScore,
3364
+ result.grade,
3365
+ result.totalPoints,
3366
+ result.maxPoints,
3367
+ categoriesWithKeys
3368
+ );
3369
+ return `<!DOCTYPE html>
3370
+ <html lang="en">
3371
+ <head>
3372
+ <meta charset="utf-8">
3373
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3374
+ <title>AI SEO Audit - ${escapeHtml(result.url)}</title>
3375
+ <style>
3376
+ :root {
3377
+ --pass: #00cc66;
3378
+ --pass-text: #008800;
3379
+ --average: #ffaa33;
3380
+ --average-text: #ffaa33;
3381
+ --fail: #ff3333;
3382
+ --fail-text: #cc0000;
3383
+ --bg: #fff;
3384
+ --surface: #fff;
3385
+ --text: #212121;
3386
+ --text-secondary: #757575;
3387
+ --border: #e0e0e0;
3388
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
3389
+ }
3390
+
3391
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3392
+
3393
+ body {
3394
+ font-family: var(--font);
3395
+ background: var(--bg);
3396
+ color: var(--text);
3397
+ line-height: 1.6;
3398
+ -webkit-font-smoothing: antialiased;
3399
+ }
3400
+
3401
+ /* Topbar */
3402
+ .topbar {
3403
+ display: flex;
3404
+ align-items: center;
3405
+ height: 40px;
3406
+ padding: 0 16px;
3407
+ background: var(--surface);
3408
+ border-bottom: 1px solid var(--border);
3409
+ font-size: 13px;
3410
+ }
3411
+ .topbar-title {
3412
+ font-weight: 600;
3413
+ margin-right: 12px;
3414
+ white-space: nowrap;
3415
+ }
3416
+ .topbar-url {
3417
+ color: var(--text-secondary);
3418
+ overflow: hidden;
3419
+ text-overflow: ellipsis;
3420
+ white-space: nowrap;
3421
+ }
3422
+
3423
+ /* Container */
3424
+ .report {
3425
+ max-width: 960px;
3426
+ margin: 0 auto;
3427
+ padding: 0 32px;
3428
+ }
3429
+
3430
+ /* Category gauges row */
3431
+ .gauges-row {
3432
+ display: flex;
3433
+ flex-wrap: wrap;
3434
+ justify-content: center;
3435
+ align-items: flex-start;
3436
+ gap: 12px;
3437
+ padding: 24px 0;
3438
+ border-bottom: 1px solid var(--border);
3439
+ }
3440
+ .gauge-item {
3441
+ display: flex;
3442
+ flex-direction: column;
3443
+ align-items: center;
3444
+ width: 110px;
3445
+ padding: 10px 6px 8px;
3446
+ text-decoration: none;
3447
+ color: var(--text);
3448
+ border-radius: 8px;
3449
+ transition: background 0.15s;
3450
+ }
3451
+ .gauge-item:hover {
3452
+ background: #f5f5f5;
3453
+ }
3454
+ .gauge-item .gauge { display: block; }
3455
+ .gauge-label {
3456
+ margin-top: 8px;
3457
+ font-size: 11px;
3458
+ font-weight: 500;
3459
+ text-align: center;
3460
+ color: var(--text-secondary);
3461
+ line-height: 1.25;
3462
+ }
3463
+
3464
+ /* Overall score */
3465
+ .overall {
3466
+ display: flex;
3467
+ align-items: center;
3468
+ flex-direction: column;
3469
+ padding: 32px 0 24px;
3470
+ border-bottom: 1px solid var(--border);
3471
+ }
3472
+ .overall-gauge-wrap {
3473
+ display: flex;
3474
+ flex-direction: column;
3475
+ align-items: center;
3476
+ position: relative;
3477
+ }
3478
+ .overall-gauge-wrap .gauge {
3479
+ display: block;
3480
+ overflow: visible;
3481
+ }
3482
+ .seg-arc {
3483
+ cursor: pointer;
3484
+ transition: stroke-width 0.15s ease, filter 0.15s ease;
3485
+ }
3486
+ .seg-arc:hover {
3487
+ stroke-width: 20;
3488
+ filter: brightness(1.1);
3489
+ }
3490
+
3491
+ /* Segment popovers */
3492
+ .seg-popovers {
3493
+ position: relative;
3494
+ min-height: 36px;
3495
+ display: flex;
3496
+ justify-content: center;
3497
+ margin-top: 4px;
3498
+ }
3499
+ .seg-popover {
3500
+ display: none;
3501
+ align-items: center;
3502
+ gap: 8px;
3503
+ background: #fff;
3504
+ border: 1px solid var(--border);
3505
+ border-radius: 8px;
3506
+ padding: 8px 14px;
3507
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
3508
+ font-size: 13px;
3509
+ white-space: nowrap;
3510
+ }
3511
+ .seg-popover-dot {
3512
+ width: 10px;
3513
+ height: 10px;
3514
+ border-radius: 50%;
3515
+ flex-shrink: 0;
3516
+ }
3517
+ .seg-popover-name { font-weight: 600; }
3518
+ .seg-popover-score { font-weight: 700; }
3519
+ .seg-popover-pts { color: var(--text-secondary); }
3520
+
3521
+ /* Score scale legend */
3522
+ .score-scale {
3523
+ display: flex;
3524
+ justify-content: center;
3525
+ gap: 16px;
3526
+ margin-top: 12px;
3527
+ font-size: 11px;
3528
+ color: var(--text-secondary);
3529
+ }
3530
+ .score-scale span::before {
3531
+ content: "";
3532
+ display: inline-block;
3533
+ width: 8px;
3534
+ height: 8px;
3535
+ border-radius: 50%;
3536
+ margin-right: 4px;
3537
+ vertical-align: middle;
3538
+ }
3539
+ .scale-fail::before { background: var(--fail); }
3540
+ .scale-average::before { background: var(--average); }
3541
+ .scale-pass::before { background: var(--pass); }
3542
+
3543
+ /* Categories */
3544
+ .category {
3545
+ padding: 24px 0 16px;
3546
+ border-bottom: 1px solid var(--border);
3547
+ }
3548
+ .category:last-child { border-bottom: none; }
3549
+ .category-header {
3550
+ display: flex;
3551
+ justify-content: space-between;
3552
+ align-items: baseline;
3553
+ margin-bottom: 12px;
3554
+ }
3555
+ .category-title {
3556
+ font-size: 18px;
3557
+ font-weight: 600;
3558
+ }
3559
+ .category-title.pass { color: var(--pass-text); }
3560
+ .category-title.average { color: var(--average-text); }
3561
+ .category-title.fail { color: var(--fail-text); }
3562
+ .category-score {
3563
+ font-size: 14px;
3564
+ font-weight: 700;
3565
+ }
3566
+ .category-score.pass { color: var(--pass-text); }
3567
+ .category-score.average { color: var(--average-text); }
3568
+ .category-score.fail { color: var(--fail-text); }
3569
+
3570
+ /* Audit rows */
3571
+ .audit-row {
3572
+ display: flex;
3573
+ align-items: baseline;
3574
+ padding: 8px 0;
3575
+ border-top: 1px solid #f0f0f0;
3576
+ font-size: 13px;
3577
+ gap: 8px;
3578
+ }
3579
+ .audit-icon {
3580
+ width: 18px;
3581
+ flex-shrink: 0;
3582
+ text-align: center;
3583
+ font-size: 12px;
3584
+ }
3585
+ .audit-icon.good { color: var(--pass); }
3586
+ .audit-icon.warn { color: var(--average); }
3587
+ .audit-icon.fail { color: var(--fail); }
3588
+ .audit-icon.neutral { color: var(--text-secondary); }
3589
+ .audit-name {
3590
+ font-weight: 500;
3591
+ min-width: 180px;
3592
+ flex-shrink: 0;
3593
+ }
3594
+ .audit-detail {
3595
+ flex: 1;
3596
+ color: var(--text-secondary);
3597
+ }
3598
+ .audit-score {
3599
+ color: var(--text-secondary);
3600
+ white-space: nowrap;
3601
+ text-align: right;
3602
+ min-width: 44px;
3603
+ }
3604
+
3605
+ /* Recommendations */
3606
+ .recs-section {
3607
+ padding: 24px 0 16px;
3608
+ border-top: 1px solid var(--border);
3609
+ }
3610
+ .recs-title {
3611
+ font-size: 20px;
3612
+ font-weight: 600;
3613
+ margin-bottom: 20px;
3614
+ }
3615
+ .rec-group {
3616
+ margin-bottom: 20px;
3617
+ }
3618
+ .rec-group-name {
3619
+ font-size: 13px;
3620
+ font-weight: 600;
3621
+ text-transform: uppercase;
3622
+ letter-spacing: 0.4px;
3623
+ color: var(--text-secondary);
3624
+ padding-bottom: 6px;
3625
+ border-bottom: 1px solid var(--border);
3626
+ margin-bottom: 2px;
3627
+ }
3628
+ .rec-row {
3629
+ display: flex;
3630
+ gap: 10px;
3631
+ align-items: baseline;
3632
+ padding: 8px 0;
3633
+ border-bottom: 1px solid #f5f5f5;
3634
+ font-size: 13px;
3635
+ }
3636
+ .rec-tag {
3637
+ font-size: 9px;
3638
+ font-weight: 700;
3639
+ padding: 2px 5px;
3640
+ border-radius: 3px;
3641
+ white-space: nowrap;
3642
+ flex-shrink: 0;
3643
+ letter-spacing: 0.3px;
3644
+ }
3645
+ .priority-high .rec-tag { background: #fce8e6; color: var(--fail-text); }
3646
+ .priority-med .rec-tag { background: #fef7e0; color: var(--average-text); }
3647
+ .priority-low .rec-tag { background: #f1f3f4; color: var(--text-secondary); }
3648
+ .rec-factor {
3649
+ font-weight: 600;
3650
+ white-space: nowrap;
3651
+ flex-shrink: 0;
3652
+ }
3653
+ .rec-text { color: var(--text-secondary); }
3654
+ .rec-detail {
3655
+ padding: 8px 0 8px 16px;
3656
+ border-bottom: 1px solid #f5f5f5;
3657
+ font-size: 12px;
3658
+ color: var(--text-secondary);
3659
+ }
3660
+ .rec-steps {
3661
+ margin: 0 0 8px 0;
3662
+ padding-left: 20px;
3663
+ }
3664
+ .rec-steps li { margin-bottom: 3px; }
3665
+ .rec-code {
3666
+ background: #f8f9fa;
3667
+ border: 1px solid var(--border);
3668
+ border-radius: 4px;
3669
+ padding: 10px 12px;
3670
+ font-size: 11px;
3671
+ overflow-x: auto;
3672
+ margin: 0 0 8px 0;
3673
+ white-space: pre;
3674
+ }
3675
+ .rec-learn-more {
3676
+ font-size: 11px;
3677
+ color: var(--text-secondary);
3678
+ text-decoration: none;
3679
+ }
3680
+ .rec-learn-more:hover { text-decoration: underline; }
3681
+
3682
+ /* Footer */
3683
+ .footer {
3684
+ padding: 16px 0;
3685
+ border-top: 1px solid var(--border);
3686
+ font-size: 11px;
3687
+ color: var(--text-secondary);
3688
+ display: flex;
3689
+ justify-content: space-between;
3690
+ flex-wrap: wrap;
3691
+ gap: 8px;
3692
+ }
3693
+
3694
+ @media (max-width: 600px) {
3695
+ .report { padding: 0 16px; }
3696
+ .gauges-row { gap: 4px; }
3697
+ .gauge-item { width: 80px; padding: 8px 4px; }
3698
+ .audit-row { flex-wrap: wrap; }
3699
+ .audit-name { min-width: 140px; }
3700
+ .rec-row { flex-wrap: wrap; }
3701
+ }
3702
+ </style>
3703
+ </head>
3704
+ <body>
3705
+
3706
+ <div class="topbar">
3707
+ <span class="topbar-title">AI SEO Audit</span>
3708
+ <span class="topbar-url">${escapeHtml(result.url)}</span>
3709
+ </div>
3710
+
3711
+ <div class="report">
3712
+ <div class="gauges-row">
3713
+ ${gauges}
3714
+ </div>
3715
+
3716
+ <div class="overall">
3717
+ ${overallGauge}
3718
+ </div>
3719
+
3720
+ ${sections}
3721
+
3722
+ ${recsHtml}
3723
+
3724
+ <div class="footer">
3725
+ <span>Generated by aiseo-audit v${escapeHtml(result.meta.version)}</span>
3726
+ <span>${escapeHtml(result.analyzedAt)} &middot; ${result.meta.analysisDurationMs}ms</span>
3727
+ <span>Domain signals checked at: <code>${escapeHtml(result.signalsBase)}</code></span>
3728
+ ${result.url.startsWith("http://") ? '<span style="color:#e8a735;margin-top:4px">Note: Audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production.</span>' : ""}
3729
+ </div>
3730
+ </div>
3731
+
3732
+ </body>
3733
+ </html>`;
3734
+ }
3735
+ function buildSitemapUrlSection(urlResult, index) {
3736
+ if (urlResult.status === "failed") {
3737
+ return `
3738
+ <div class="sitemap-url-section failed">
3739
+ <div class="sitemap-url-header">
3740
+ <span class="sitemap-url-status fail">&#10007;</span>
3741
+ <span class="sitemap-url-label">${escapeHtml(urlResult.url)}</span>
3742
+ </div>
3743
+ <div class="sitemap-url-error">Error: ${escapeHtml(urlResult.error)}</div>
3744
+ </div>`;
3745
+ }
3746
+ const { result } = urlResult;
3747
+ const pct = result.overallScore;
3748
+ const scoreCol = scoreColorHex(pct);
3749
+ const categoryEntries = Object.entries(result.categories);
3750
+ const topRec = result.recommendations[0];
3751
+ const categoryRows = categoryEntries.map(([, c]) => {
3752
+ const catPct = c.maxScore > 0 ? Math.round(c.score / c.maxScore * 100) : 0;
3753
+ const cls = scoreClass(catPct);
3754
+ return `<div class="sitemap-cat-row">
3755
+ <span class="sitemap-cat-name">${escapeHtml(c.name)}</span>
3756
+ <span class="sitemap-cat-score ${cls}">${catPct}%</span>
3757
+ </div>`;
3758
+ }).join("");
3759
+ const recHtml = topRec ? `<div class="sitemap-top-rec">Top recommendation: <strong>${escapeHtml(topRec.factor)}</strong> \u2014 ${escapeHtml(topRec.recommendation)}</div>` : "";
3760
+ return `
3761
+ <div class="sitemap-url-section" id="url-${index}">
3762
+ <div class="sitemap-url-header">
3763
+ <span class="sitemap-url-status" style="color:${scoreCol}">&#10003;</span>
3764
+ <span class="sitemap-url-label">${escapeHtml(result.url)}</span>
3765
+ <span class="sitemap-url-score" style="color:${scoreCol}">${result.overallScore}/100</span>
3766
+ <span class="sitemap-url-grade" style="color:${scoreCol}">${escapeHtml(result.grade)}</span>
3767
+ </div>
3768
+ <div class="sitemap-url-body">
3769
+ <div class="sitemap-cats">${categoryRows}</div>
3770
+ ${recHtml}
3771
+ </div>
3772
+ </div>`;
3773
+ }
3774
+ function renderSitemapHtml(result) {
3775
+ const avgTextColor = scoreTextColorHex(result.averageScore);
3776
+ const hasHttpUrls = result.urlResults.some(
3777
+ (r) => r.status === "success" && r.result.url.startsWith("http://")
3778
+ );
3779
+ const categoryAvgRows = Object.values(result.categoryAverages).map((avg) => {
3780
+ const cls = scoreClass(avg.averagePct);
3781
+ return `<div class="sitemap-cat-row">
3782
+ <span class="sitemap-cat-name">${escapeHtml(avg.name)}</span>
3783
+ <span class="sitemap-cat-score ${cls}">${avg.averagePct}%</span>
3784
+ </div>`;
3785
+ }).join("");
3786
+ const urlSections = result.urlResults.map((r, i) => buildSitemapUrlSection(r, i)).join("");
3787
+ return `<!DOCTYPE html>
3788
+ <html lang="en">
3789
+ <head>
3790
+ <meta charset="utf-8">
3791
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3792
+ <title>AI SEO Sitemap Audit - ${escapeHtml(result.sitemapUrl)}</title>
3793
+ <style>
3794
+ :root {
3795
+ --pass: #00cc66;
3796
+ --pass-text: #008800;
3797
+ --average: #ffaa33;
3798
+ --average-text: #ffaa33;
3799
+ --fail: #ff3333;
3800
+ --fail-text: #cc0000;
3801
+ --bg: #fff;
3802
+ --surface: #fff;
3803
+ --text: #212121;
3804
+ --text-secondary: #757575;
3805
+ --border: #e0e0e0;
3806
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
3807
+ }
3808
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3809
+ body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; -webkit-font-smoothing: antialiased; }
3810
+ .topbar { display: flex; align-items: center; height: 40px; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 13px; }
3811
+ .topbar-title { font-weight: 600; margin-right: 12px; white-space: nowrap; }
3812
+ .topbar-url { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3813
+ .report { max-width: 960px; margin: 0 auto; padding: 0 32px; }
3814
+ .summary { display: flex; gap: 32px; padding: 32px 0 24px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
3815
+ .summary-score { display: flex; flex-direction: column; align-items: center; }
3816
+ .summary-score-value { font-size: 64px; font-weight: 700; line-height: 1; }
3817
+ .summary-score-grade { font-size: 24px; font-weight: 600; }
3818
+ .summary-stats { display: flex; flex-direction: column; gap: 6px; font-size: 14px; justify-content: center; }
3819
+ .summary-stat { color: var(--text-secondary); }
3820
+ .summary-signals { font-size: 12px; color: var(--text-secondary); padding-top: 4px; }
3821
+ .category-averages { padding: 24px 0; border-bottom: 1px solid var(--border); }
3822
+ .category-averages-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
3823
+ .sitemap-cat-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-top: 1px solid #f0f0f0; font-size: 13px; }
3824
+ .sitemap-cat-name { color: var(--text); }
3825
+ .sitemap-cat-score { font-weight: 700; }
3826
+ .sitemap-cat-score.pass { color: var(--pass-text); }
3827
+ .sitemap-cat-score.average { color: var(--average-text); }
3828
+ .sitemap-cat-score.fail { color: var(--fail-text); }
3829
+ .url-results { padding: 24px 0; }
3830
+ .url-results-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
3831
+ .sitemap-url-section { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
3832
+ .sitemap-url-section.failed { border-color: #fce8e6; }
3833
+ .sitemap-url-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: #fafafa; cursor: pointer; font-size: 13px; }
3834
+ .sitemap-url-status { font-size: 14px; flex-shrink: 0; }
3835
+ .sitemap-url-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
3836
+ .sitemap-url-score { font-weight: 700; white-space: nowrap; }
3837
+ .sitemap-url-grade { font-weight: 700; white-space: nowrap; }
3838
+ .sitemap-url-body { padding: 12px 16px; border-top: 1px solid var(--border); }
3839
+ .sitemap-url-error { padding: 12px 16px; color: var(--fail-text); font-size: 13px; }
3840
+ .sitemap-top-rec { font-size: 12px; color: var(--text-secondary); margin-top: 10px; padding-top: 10px; border-top: 1px solid #f0f0f0; }
3841
+ .footer { padding: 16px 0; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-secondary); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
3842
+ .pass { color: var(--pass-text); }
3843
+ .average { color: var(--average-text); }
3844
+ .fail { color: var(--fail-text); }
3845
+ @media (max-width: 600px) {
3846
+ .report { padding: 0 16px; }
3847
+ .summary { flex-direction: column; gap: 16px; }
3848
+ }
3849
+ </style>
3850
+ </head>
3851
+ <body>
3852
+
3853
+ <div class="topbar">
3854
+ <span class="topbar-title">AI SEO Sitemap Audit</span>
3855
+ <span class="topbar-url">${escapeHtml(result.sitemapUrl)}</span>
3856
+ </div>
3857
+
3858
+ <div class="report">
3859
+ <div class="summary">
3860
+ <div class="summary-score">
3861
+ <span class="summary-score-value" style="color:${avgTextColor}">${result.averageScore}</span>
3862
+ <span class="summary-score-grade" style="color:${avgTextColor}">${escapeHtml(result.averageGrade)}</span>
3863
+ <span style="font-size:12px;color:var(--text-secondary);margin-top:4px">Average Score</span>
3864
+ </div>
3865
+ <div class="summary-stats">
3866
+ <div class="summary-stat">Total URLs: <strong>${result.totalUrls}</strong></div>
3867
+ <div class="summary-stat">Audited: <strong style="color:var(--pass-text)">${result.succeededCount}</strong></div>
3868
+ <div class="summary-stat">Failed: <strong style="color:${result.failedCount > 0 ? "var(--fail-text)" : "var(--text-secondary)"}">${result.failedCount}</strong></div>
3869
+ <div class="summary-signals">Domain signals checked at: <code>${escapeHtml(result.signalsBase)}</code></div>
3870
+ </div>
3871
+ </div>
3872
+
3873
+ <div class="category-averages">
3874
+ <div class="category-averages-title">Site-Wide Category Averages</div>
3875
+ ${categoryAvgRows}
3876
+ </div>
3877
+
3878
+ <div class="url-results">
3879
+ <div class="url-results-title">URL Results</div>
3880
+ ${urlSections}
3881
+ </div>
3882
+
3883
+ <div class="footer">
3884
+ <span>Generated by aiseo-audit v${escapeHtml(result.meta.version)}</span>
3885
+ <span>${escapeHtml(result.analyzedAt)} &middot; ${result.meta.analysisDurationMs}ms</span>
3886
+ ${hasHttpUrls ? '<span style="color:#e8a735;margin-top:4px">Note: Some URLs were audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production.</span>' : ""}
3887
+ </div>
3888
+ </div>
3889
+
3890
+ </body>
3891
+ </html>`;
3892
+ }
3893
+
3894
+ // src/modules/report/support/json.ts
3895
+ function renderJson(result) {
3896
+ const notes = [];
3897
+ if (result.url.startsWith("http://")) {
3898
+ notes.push(
3899
+ "Audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
3900
+ );
3901
+ }
3902
+ const output = notes.length > 0 ? { ...result, notes } : result;
3903
+ return JSON.stringify(output, null, 2);
3904
+ }
3905
+ function renderSitemapJson(result) {
3906
+ const notes = [];
3907
+ const hasHttpUrls = result.urlResults.some(
3908
+ (r) => r.status === "success" && r.result.url.startsWith("http://")
3909
+ );
3910
+ if (hasHttpUrls) {
3911
+ notes.push(
3912
+ "Some URLs were audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
3913
+ );
3914
+ }
3915
+ const output = notes.length > 0 ? { ...result, notes } : result;
3916
+ return JSON.stringify(output, null, 2);
3917
+ }
3918
+
3919
+ // src/modules/report/support/markdown.ts
3920
+ function renderMarkdown(result) {
3921
+ const lines = [];
3922
+ lines.push(`# AI SEO Audit`);
3923
+ lines.push("");
3924
+ lines.push(`**URL:** ${result.url}`);
3925
+ lines.push("");
3926
+ lines.push(`**Domain signals checked at:** \`${result.signalsBase}\``);
3927
+ lines.push("");
3928
+ lines.push("| Category | Score | Percentage |");
3929
+ lines.push("|----------|-------|------------|");
3930
+ for (const category of Object.values(result.categories)) {
3931
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
3932
+ lines.push(
3933
+ `| ${category.name} | ${category.score}/${category.maxScore} | ${pct}% |`
3934
+ );
3935
+ }
3936
+ lines.push("");
3937
+ lines.push(
3938
+ `## Overall: ${result.overallScore}/100 (${result.grade}) - ${result.totalPoints}/${result.maxPoints} pts`
3939
+ );
3940
+ lines.push("");
3941
+ for (const category of Object.values(result.categories)) {
3942
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
3943
+ lines.push(`### ${category.name} (${pct}%)`);
3944
+ lines.push("");
3945
+ lines.push("| Factor | Score | Status | Details |");
3946
+ lines.push("|--------|-------|--------|---------|");
3947
+ for (const factor of category.factors) {
3948
+ const statusIcon2 = factor.status === "good" ? "pass" : factor.status === "neutral" ? "-" : factor.status === "needs_improvement" ? "warn" : "fail";
3949
+ lines.push(
3950
+ `| ${factor.name} | ${factor.score}/${factor.maxScore} | ${statusIcon2} | ${factor.value} |`
3951
+ );
3952
+ }
3953
+ lines.push("");
3954
+ }
3955
+ if (result.recommendations.length > 0) {
3956
+ lines.push("## Recommendations");
3957
+ lines.push("");
3958
+ const categoryNames = Object.values(result.categories).map((c) => c.name);
3959
+ for (const categoryName of categoryNames) {
3960
+ const categoryRecs = result.recommendations.filter(
3961
+ (r) => r.category === categoryName
3962
+ );
3963
+ if (categoryRecs.length === 0) continue;
3964
+ lines.push(`### ${categoryName}`);
3965
+ lines.push("");
3966
+ for (const rec of categoryRecs) {
3967
+ const tag = rec.priority === "high" ? "**HIGH**" : rec.priority === "medium" ? "*MED*" : "LOW";
3968
+ lines.push(`- [${tag}] **${rec.factor}**: ${rec.recommendation}`);
3969
+ if (rec.steps && rec.steps.length > 0) {
3970
+ rec.steps.forEach((step, idx) => {
3971
+ lines.push(` ${idx + 1}. ${step}`);
3972
+ });
3973
+ }
3974
+ if (rec.codeExample) {
3975
+ lines.push("");
3976
+ lines.push(" ```");
3977
+ rec.codeExample.split("\n").forEach((line) => {
3978
+ lines.push(` ${line}`);
3979
+ });
3980
+ lines.push(" ```");
3981
+ }
3982
+ if (rec.learnMoreUrl) {
3983
+ lines.push("");
3984
+ lines.push(` [Learn more](${rec.learnMoreUrl})`);
3985
+ }
3986
+ if (rec.steps || rec.codeExample || rec.learnMoreUrl) {
3987
+ lines.push("");
3988
+ }
3989
+ }
3990
+ lines.push("");
3991
+ }
3992
+ }
3993
+ lines.push("---");
3994
+ lines.push(
3995
+ `*Generated by aiseo-audit v${result.meta.version} | ${result.analyzedAt} | ${result.meta.analysisDurationMs}ms*`
3996
+ );
3997
+ if (result.url.startsWith("http://")) {
3998
+ lines.push("");
3999
+ lines.push(
4000
+ "> **Note:** Audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
4001
+ );
4002
+ }
4003
+ return lines.join("\n");
4004
+ }
4005
+ function renderSitemapMarkdown(result) {
4006
+ const lines = [];
4007
+ lines.push("# AI SEO Sitemap Audit Report");
4008
+ lines.push("");
4009
+ lines.push(`**Sitemap:** ${result.sitemapUrl}`);
4010
+ lines.push(`**Domain signals checked at:** \`${result.signalsBase}\``);
4011
+ lines.push(`**Analyzed at:** ${result.analyzedAt}`);
4012
+ lines.push("");
4013
+ lines.push("## Summary");
4014
+ lines.push("");
4015
+ lines.push(`| Metric | Value |`);
4016
+ lines.push(`|--------|-------|`);
4017
+ lines.push(`| Average Score | ${result.averageScore}/100 |`);
4018
+ lines.push(`| Average Grade | ${result.averageGrade} |`);
4019
+ lines.push(`| Total URLs | ${result.totalUrls} |`);
4020
+ lines.push(`| Succeeded | ${result.succeededCount} |`);
4021
+ lines.push(`| Failed | ${result.failedCount} |`);
4022
+ lines.push("");
4023
+ if (Object.keys(result.categoryAverages).length > 0) {
4024
+ lines.push("## Site-Wide Category Averages");
4025
+ lines.push("");
4026
+ lines.push("| Category | Average Score |");
4027
+ lines.push("|----------|---------------|");
4028
+ for (const avg of Object.values(result.categoryAverages)) {
4029
+ lines.push(`| ${avg.name} | ${avg.averagePct}% |`);
4030
+ }
4031
+ lines.push("");
4032
+ }
4033
+ lines.push("## URL Results");
4034
+ lines.push("");
4035
+ for (const urlResult of result.urlResults) {
4036
+ if (urlResult.status === "failed") {
4037
+ lines.push(`### \u274C ${urlResult.url}`);
4038
+ lines.push("");
4039
+ lines.push(`**Error:** ${urlResult.error}`);
4040
+ lines.push("");
4041
+ continue;
4042
+ }
4043
+ const { result: r } = urlResult;
4044
+ lines.push(`### ${r.url}`);
4045
+ lines.push("");
4046
+ lines.push(
4047
+ `**Score:** ${r.overallScore}/100 | **Grade:** ${r.grade} | **Points:** ${r.totalPoints}/${r.maxPoints}`
4048
+ );
4049
+ lines.push("");
4050
+ lines.push("| Category | Score | Percentage |");
4051
+ lines.push("|----------|-------|------------|");
4052
+ for (const category of Object.values(r.categories)) {
4053
+ const pct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
4054
+ lines.push(
4055
+ `| ${category.name} | ${category.score}/${category.maxScore} | ${pct}% |`
4056
+ );
4057
+ }
4058
+ lines.push("");
4059
+ if (r.recommendations.length > 0) {
4060
+ lines.push("**Recommendations:**");
4061
+ lines.push("");
4062
+ for (const rec of r.recommendations) {
4063
+ const tag = rec.priority === "high" ? "**HIGH**" : rec.priority === "medium" ? "*MED*" : "LOW";
4064
+ lines.push(`- [${tag}] **${rec.factor}**: ${rec.recommendation}`);
4065
+ }
4066
+ lines.push("");
4067
+ }
4068
+ }
4069
+ lines.push("---");
4070
+ lines.push(
4071
+ `*Generated by aiseo-audit v${result.meta.version} | ${result.analyzedAt} | ${result.meta.analysisDurationMs}ms*`
4072
+ );
4073
+ const hasHttpUrls = result.urlResults.some(
4074
+ (r) => r.status === "success" && r.result.url.startsWith("http://")
4075
+ );
4076
+ if (hasHttpUrls) {
4077
+ lines.push("");
4078
+ lines.push(
4079
+ "> **Note:** Some URLs were audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
4080
+ );
4081
+ }
4082
+ return lines.join("\n");
4083
+ }
4084
+
4085
+ // src/modules/report/support/pretty.ts
4086
+ import chalk from "chalk";
4087
+ function scoreColor(score, max) {
4088
+ const pct = max > 0 ? score / max * 100 : 0;
4089
+ if (pct >= 90) return chalk.green;
4090
+ if (pct >= 50) return chalk.yellow;
4091
+ return chalk.red;
4092
+ }
4093
+ function gradeColor(grade) {
4094
+ if (grade.startsWith("A")) return chalk.green;
4095
+ if (grade.startsWith("B") || grade.startsWith("C")) return chalk.yellow;
4096
+ return chalk.red;
4097
+ }
4098
+ function pad(str, len) {
4099
+ return str + " ".repeat(Math.max(0, len - str.length));
4100
+ }
4101
+ function dots(len) {
4102
+ return chalk.dim(".".repeat(len));
4103
+ }
4104
+ function renderDomainSignalsBlock(lines, signalsBase, rawData) {
4105
+ const robotsFound = rawData.crawlerAccess !== void 0;
4106
+ const llmsFound = rawData.llmsTxt?.llmsTxtExists ?? false;
4107
+ const llmsFullFound = rawData.llmsTxt?.llmsFullTxtExists ?? false;
4108
+ lines.push(chalk.dim(` Domain signals checked at: ${signalsBase}`));
4109
+ lines.push(
4110
+ chalk.dim(
4111
+ ` robots.txt ........ ${robotsFound ? chalk.green("found") : chalk.red("not found")}`
4112
+ )
4113
+ );
4114
+ lines.push(
4115
+ chalk.dim(
4116
+ ` llms.txt .......... ${llmsFound ? chalk.green("found") : chalk.red("not found")}`
4117
+ )
4118
+ );
4119
+ lines.push(
4120
+ chalk.dim(
4121
+ ` llms-full.txt ..... ${llmsFullFound ? chalk.green("found") : chalk.red("not found")}`
4122
+ )
4123
+ );
4124
+ }
4125
+ function renderPretty(result) {
4126
+ const lines = [];
4127
+ const width = 60;
4128
+ const divider = chalk.dim("=".repeat(width));
4129
+ const thinDivider = chalk.dim("-".repeat(width));
4130
+ lines.push("");
4131
+ lines.push(divider);
4132
+ lines.push(chalk.bold(" AI SEO Audit Report"));
4133
+ lines.push(chalk.dim(` ${result.url}`));
4134
+ lines.push(divider);
4135
+ lines.push("");
4136
+ const overallScoreColor = scoreColor(result.overallScore, 100);
4137
+ const overallGradeColor = gradeColor(result.grade);
4138
+ lines.push(
4139
+ ` Overall Score: ${overallScoreColor(`${result.overallScore}/100`)} Grade: ${overallGradeColor(result.grade)}`
4140
+ );
4141
+ lines.push(chalk.dim(` Points: ${result.totalPoints}/${result.maxPoints}`));
4142
+ lines.push("");
4143
+ renderDomainSignalsBlock(lines, result.signalsBase, result.rawData);
4144
+ lines.push("");
4145
+ lines.push(thinDivider);
4146
+ for (const category of Object.values(result.categories)) {
4147
+ const catColor = scoreColor(category.score, category.maxScore);
4148
+ const catPct = category.maxScore > 0 ? Math.round(category.score / category.maxScore * 100) : 0;
4149
+ const catName = pad(category.name, 38);
4150
+ const catDots = dots(Math.max(2, 40 - category.name.length));
4151
+ lines.push("");
4152
+ lines.push(
4153
+ ` ${chalk.bold(catName)} ${catDots} ${catColor(`${category.score}/${category.maxScore}`)} ${chalk.dim(`(${catPct}%)`)}`
4154
+ );
4155
+ for (const factor of category.factors) {
4156
+ const fColor = scoreColor(factor.score, factor.maxScore);
4157
+ const fName = pad(` ${factor.name}`, 40);
4158
+ const fDots = dots(Math.max(2, 42 - factor.name.length));
4159
+ lines.push(
4160
+ ` ${chalk.dim(fName)} ${fDots} ${fColor(`${factor.score}/${factor.maxScore}`)} ${chalk.dim(factor.value)}`
4161
+ );
4162
+ }
4163
+ }
4164
+ lines.push("");
4165
+ lines.push(thinDivider);
4166
+ if (result.recommendations.length > 0) {
4167
+ lines.push("");
4168
+ lines.push(chalk.bold(" Recommendations:"));
4169
+ lines.push("");
4170
+ for (let i = 0; i < result.recommendations.length; i++) {
4171
+ const rec = result.recommendations[i];
4172
+ const tag = rec.priority === "high" ? chalk.red(`[HIGH]`) : rec.priority === "medium" ? chalk.yellow(`[MED] `) : chalk.dim(`[LOW] `);
4173
+ lines.push(` ${i + 1}. ${tag} ${chalk.bold(rec.factor)}`);
4174
+ lines.push(` ${chalk.dim(rec.recommendation)}`);
4175
+ if (rec.steps && rec.steps.length > 0) {
4176
+ lines.push("");
4177
+ lines.push(` ${chalk.dim("Steps:")}`);
4178
+ rec.steps.forEach((step, idx) => {
4179
+ lines.push(` ${chalk.dim(`${idx + 1}. ${step}`)}`);
4180
+ });
4181
+ }
4182
+ if (rec.codeExample) {
4183
+ lines.push("");
4184
+ lines.push(` ${chalk.dim("Example:")}`);
4185
+ lines.push(` ${chalk.dim("\u250C" + "\u2500".repeat(50))}`);
4186
+ rec.codeExample.split("\n").forEach((line) => {
4187
+ lines.push(` ${chalk.dim("\u2502")} ${chalk.dim(line)}`);
4188
+ });
4189
+ lines.push(` ${chalk.dim("\u2514" + "\u2500".repeat(50))}`);
4190
+ }
4191
+ if (rec.learnMoreUrl) {
4192
+ lines.push("");
4193
+ lines.push(` ${chalk.dim(`Learn more: ${rec.learnMoreUrl}`)}`);
4194
+ }
4195
+ lines.push("");
4196
+ }
4197
+ }
4198
+ lines.push(divider);
4199
+ lines.push(chalk.dim(` Analyzed at: ${result.analyzedAt}`));
4200
+ lines.push(chalk.dim(` Duration: ${result.meta.analysisDurationMs}ms`));
4201
+ if (result.url.startsWith("http://")) {
4202
+ lines.push(
4203
+ chalk.yellow(
4204
+ " Note: Audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
4205
+ )
4206
+ );
4207
+ }
4208
+ lines.push(divider);
4209
+ lines.push("");
4210
+ return lines.join("\n");
4211
+ }
4212
+ function renderSitemapPretty(result) {
4213
+ const lines = [];
4214
+ const width = 60;
4215
+ const divider = chalk.dim("=".repeat(width));
4216
+ const thinDivider = chalk.dim("-".repeat(width));
4217
+ lines.push("");
4218
+ lines.push(divider);
4219
+ lines.push(chalk.bold(" AI SEO Sitemap Audit Report"));
4220
+ lines.push(chalk.dim(` ${result.sitemapUrl}`));
4221
+ lines.push(divider);
4222
+ lines.push("");
4223
+ const averageScoreColor = scoreColor(result.averageScore, 100);
4224
+ const averageGradeColor = gradeColor(result.averageGrade);
4225
+ lines.push(
4226
+ ` Average Score: ${averageScoreColor(`${result.averageScore}/100`)} Grade: ${averageGradeColor(result.averageGrade)}`
4227
+ );
4228
+ lines.push(
4229
+ chalk.dim(
4230
+ ` URLs: ${result.succeededCount} audited, ${result.failedCount} failed, ${result.totalUrls} total`
4231
+ )
4232
+ );
4233
+ lines.push("");
4234
+ lines.push(chalk.dim(` Domain signals checked at: ${result.signalsBase}`));
4235
+ lines.push("");
4236
+ lines.push(thinDivider);
4237
+ if (Object.keys(result.categoryAverages).length > 0) {
4238
+ lines.push("");
4239
+ lines.push(chalk.bold(" Site-Wide Category Averages:"));
4240
+ lines.push("");
4241
+ for (const avg of Object.values(result.categoryAverages)) {
4242
+ const color = scoreColor(avg.averagePct, 100);
4243
+ const name = pad(avg.name, 38);
4244
+ const dts = dots(Math.max(2, 40 - avg.name.length));
4245
+ lines.push(` ${chalk.bold(name)} ${dts} ${color(`${avg.averagePct}%`)}`);
4246
+ }
4247
+ lines.push("");
4248
+ lines.push(thinDivider);
4249
+ }
4250
+ lines.push("");
4251
+ lines.push(chalk.bold(" URL Results:"));
4252
+ lines.push("");
4253
+ for (const urlResult of result.urlResults) {
4254
+ if (urlResult.status === "failed") {
4255
+ lines.push(` ${chalk.red("\u2717")} ${chalk.dim(urlResult.url)}`);
4256
+ lines.push(` ${chalk.red(`Error: ${urlResult.error}`)}`);
4257
+ lines.push("");
4258
+ continue;
4259
+ }
4260
+ const { result: r } = urlResult;
4261
+ const urlScoreColor = scoreColor(r.overallScore, 100);
4262
+ const urlGradeColor = gradeColor(r.grade);
4263
+ const topRec = r.recommendations[0];
4264
+ lines.push(` ${chalk.green("\u2713")} ${chalk.dim(r.url)}`);
4265
+ lines.push(
4266
+ ` Score: ${urlScoreColor(`${r.overallScore}/100`)} Grade: ${urlGradeColor(r.grade)}`
4267
+ );
4268
+ if (topRec) {
4269
+ lines.push(
4270
+ ` ${chalk.dim(`Top rec: ${topRec.factor} \u2014 ${topRec.recommendation}`)}`
4271
+ );
4272
+ }
4273
+ lines.push("");
4274
+ }
4275
+ lines.push(divider);
4276
+ lines.push(chalk.dim(` Analyzed at: ${result.analyzedAt}`));
4277
+ lines.push(chalk.dim(` Duration: ${result.meta.analysisDurationMs}ms`));
4278
+ const hasHttpUrls = result.urlResults.some(
4279
+ (r) => r.status === "success" && r.result.url.startsWith("http://")
4280
+ );
4281
+ if (hasHttpUrls) {
4282
+ lines.push(
4283
+ chalk.yellow(
4284
+ " Note: Some URLs were audited over HTTP. Domain signals (robots.txt, llms.txt) may differ in production."
4285
+ )
4286
+ );
4287
+ }
4288
+ lines.push(divider);
4289
+ lines.push("");
4290
+ return lines.join("\n");
4291
+ }
4292
+
4293
+ // src/modules/report/service.ts
4294
+ function renderReport(result, options) {
4295
+ switch (options.format) {
4296
+ case "json":
4297
+ return renderJson(result);
4298
+ case "md":
4299
+ return renderMarkdown(result);
4300
+ case "html":
4301
+ return renderHtml(result);
4302
+ case "pretty":
4303
+ default:
4304
+ return renderPretty(result);
4305
+ }
4306
+ }
4307
+ function renderSitemapReport(result, options) {
4308
+ switch (options.format) {
4309
+ case "json":
4310
+ return renderSitemapJson(result);
4311
+ case "md":
4312
+ return renderSitemapMarkdown(result);
4313
+ case "html":
4314
+ return renderSitemapHtml(result);
4315
+ case "pretty":
4316
+ default:
4317
+ return renderSitemapPretty(result);
4318
+ }
4319
+ }
4320
+
4321
+ // src/modules/sitemap/service.ts
4322
+ import { scaffold } from "xml-to-html-converter";
4323
+ function stripCdata(raw) {
4324
+ const trimmed = raw.trim();
4325
+ if (trimmed.startsWith("<![CDATA[") && trimmed.endsWith("]]>")) {
4326
+ return trimmed.slice(9, -3);
4327
+ }
4328
+ return trimmed;
4329
+ }
4330
+ function collectLocText(nodes, urls) {
4331
+ for (const node of nodes) {
4332
+ if (node.xmlTag === "loc" && node.children) {
4333
+ const text = node.children.filter((c) => c.role === "textLeaf").map((c) => stripCdata(c.raw)).join("").trim();
4334
+ if (text) urls.push(text);
4335
+ }
4336
+ if (node.children) collectLocText(node.children, urls);
4337
+ }
4338
+ }
4339
+ function extractLocUrls(xml) {
4340
+ const nodes = scaffold(xml);
4341
+ const urls = [];
4342
+ collectLocText(nodes, urls);
4343
+ return urls;
4344
+ }
4345
+ function hasSitemapIndexNode(nodes) {
4346
+ for (const node of nodes) {
4347
+ if (node.xmlTag === "sitemapindex") return true;
4348
+ if (node.children && hasSitemapIndexNode(node.children)) return true;
4349
+ }
4350
+ return false;
4351
+ }
4352
+ async function fetchSitemapUrls(sitemapUrl, timeout, userAgent) {
4353
+ const response = await httpGet({
4354
+ url: sitemapUrl,
4355
+ timeout,
4356
+ userAgent
4357
+ });
4358
+ if (response.status !== 200) {
4359
+ throw new Error(`Failed to fetch sitemap: HTTP ${response.status}`);
4360
+ }
4361
+ const nodes = scaffold(response.data);
4362
+ if (hasSitemapIndexNode(nodes)) {
4363
+ return fetchSitemapIndexUrls(response.data, timeout, userAgent);
4364
+ }
4365
+ const urls = [];
4366
+ collectLocText(nodes, urls);
4367
+ return urls;
4368
+ }
4369
+ async function fetchSitemapIndexUrls(xml, timeout, userAgent) {
4370
+ const childSitemapUrls = extractLocUrls(xml);
4371
+ const allUrls = [];
4372
+ for (const childUrl of childSitemapUrls) {
4373
+ const response = await httpGet({
4374
+ url: childUrl,
4375
+ timeout,
4376
+ userAgent
4377
+ });
4378
+ if (response.status === 200) {
4379
+ allUrls.push(...extractLocUrls(response.data));
4380
+ }
4381
+ }
4382
+ return allUrls;
4383
+ }
4384
+ function computeCategoryAverages(urlResults) {
4385
+ const successResults = urlResults.filter((r) => r.status === "success").map(
4386
+ (r) => r.result
4387
+ );
4388
+ if (successResults.length === 0) return {};
4389
+ const categoryTotals = {};
4390
+ for (const result of successResults) {
4391
+ for (const [key, category] of Object.entries(result.categories)) {
4392
+ const pct = category.maxScore > 0 ? category.score / category.maxScore * 100 : 0;
4393
+ if (!categoryTotals[key]) {
4394
+ categoryTotals[key] = { name: category.name, totalPct: 0, count: 0 };
4395
+ }
4396
+ categoryTotals[key].totalPct += pct;
4397
+ categoryTotals[key].count += 1;
4398
+ }
4399
+ }
4400
+ const averages = {};
4401
+ for (const [key, totals] of Object.entries(categoryTotals)) {
4402
+ averages[key] = {
4403
+ name: totals.name,
4404
+ averagePct: Math.round(totals.totalPct / totals.count)
4405
+ };
4406
+ }
4407
+ return averages;
4408
+ }
4409
+ async function analyzeSitemap(options, config) {
4410
+ const startTime = Date.now();
4411
+ const timeout = options.timeout ?? config.timeout;
4412
+ const userAgent = options.userAgent ?? config.userAgent;
4413
+ const urls = await fetchSitemapUrls(options.sitemapUrl, timeout, userAgent);
4414
+ const sitemapDir = options.sitemapUrl.substring(
4415
+ 0,
4416
+ options.sitemapUrl.lastIndexOf("/")
4417
+ );
4418
+ const signalsBase = options.signalsBase ?? sitemapDir;
4419
+ const domainSignals = await fetchDomainSignals(
4420
+ signalsBase,
4421
+ timeout,
4422
+ userAgent
4423
+ );
4424
+ const urlResults = [];
4425
+ for (const rawUrl of urls) {
4426
+ const url = normalizeUrl(rawUrl);
4427
+ try {
4428
+ const fetchResult = await fetchUrl({ url, timeout, userAgent });
4429
+ const result = await analyzeUrlWithSignals(
4430
+ url,
4431
+ fetchResult,
4432
+ domainSignals,
4433
+ config
4434
+ );
4435
+ urlResults.push({ status: "success", result });
4436
+ } catch (error) {
4437
+ urlResults.push({
4438
+ status: "failed",
4439
+ url,
4440
+ error: error instanceof Error ? error.message : String(error)
4441
+ });
4442
+ }
4443
+ }
4444
+ const successResults = urlResults.filter((r) => r.status === "success").map(
4445
+ (r) => r.result
4446
+ );
4447
+ const succeededCount = successResults.length;
4448
+ const failedCount = urlResults.length - succeededCount;
4449
+ const averageScore = succeededCount > 0 ? Math.round(
4450
+ successResults.reduce((sum, r) => sum + r.overallScore, 0) / succeededCount
4451
+ ) : 0;
4452
+ const averageGrade = computeGrade(averageScore);
4453
+ const categoryAverages = computeCategoryAverages(urlResults);
4454
+ return {
4455
+ sitemapUrl: options.sitemapUrl,
4456
+ signalsBase: domainSignals.signalsBase,
4457
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
4458
+ totalUrls: urlResults.length,
4459
+ succeededCount,
4460
+ failedCount,
4461
+ averageScore,
4462
+ averageGrade,
4463
+ categoryAverages,
4464
+ urlResults,
4465
+ meta: {
4466
+ version: VERSION,
4467
+ analysisDurationMs: Date.now() - startTime
4468
+ }
4469
+ };
4470
+ }
4471
+
4472
+ export {
4473
+ VERSION,
4474
+ FetchError,
4475
+ isValidUrl,
4476
+ analyzeUrl,
4477
+ writeOutputFile,
4478
+ loadConfig,
4479
+ renderReport,
4480
+ renderSitemapReport,
4481
+ analyzeSitemap
4482
+ };
4483
+ //# sourceMappingURL=chunk-2BXVAEAS.mjs.map