@aexol/spectral 0.9.126 → 0.9.127

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.
Files changed (64) hide show
  1. package/dist/agent/index.d.ts.map +1 -1
  2. package/dist/agent/index.js +5 -0
  3. package/dist/extensions/seo/analyzers/content.d.ts +29 -6
  4. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
  5. package/dist/extensions/seo/analyzers/content.js +243 -8
  6. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
  7. package/dist/extensions/seo/analyzers/crawler.js +133 -7
  8. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
  9. package/dist/extensions/seo/analyzers/drift.js +86 -29
  10. package/dist/extensions/seo/analyzers/images.d.ts +10 -4
  11. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
  12. package/dist/extensions/seo/analyzers/images.js +153 -5
  13. package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
  14. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
  15. package/dist/extensions/seo/analyzers/performance.js +77 -6
  16. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
  17. package/dist/extensions/seo/analyzers/readability.js +17 -9
  18. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
  19. package/dist/extensions/seo/analyzers/schema.js +99 -10
  20. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
  21. package/dist/extensions/seo/analyzers/sitemap.js +57 -16
  22. package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
  23. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
  24. package/dist/extensions/seo/analyzers/technical.js +408 -37
  25. package/dist/extensions/seo/config.d.ts +2 -0
  26. package/dist/extensions/seo/config.d.ts.map +1 -1
  27. package/dist/extensions/seo/config.js +2 -0
  28. package/dist/extensions/seo/tools/batch.js +1 -1
  29. package/dist/extensions/seo/tools/content.d.ts.map +1 -1
  30. package/dist/extensions/seo/tools/content.js +5 -3
  31. package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
  32. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  33. package/dist/extensions/seo/tools/full-audit.js +391 -66
  34. package/dist/extensions/seo/tools/helpers.d.ts +15 -4
  35. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
  36. package/dist/extensions/seo/tools/helpers.js +57 -6
  37. package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
  38. package/dist/extensions/seo/tools/performance.js +17 -11
  39. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  40. package/dist/extensions/seo/tools/report.js +513 -47
  41. package/dist/extensions/seo/types.d.ts +295 -3
  42. package/dist/extensions/seo/types.d.ts.map +1 -1
  43. package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
  44. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
  45. package/dist/extensions/seo/utils/fetcher.js +77 -11
  46. package/dist/extensions/seo/utils/parser.d.ts +3 -1
  47. package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
  48. package/dist/extensions/seo/utils/parser.js +105 -25
  49. package/dist/extensions/seo/utils/renderer.d.ts +6 -1
  50. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
  51. package/dist/extensions/seo/utils/renderer.js +207 -1
  52. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
  53. package/dist/extensions/seo/utils/url-safety.js +19 -3
  54. package/dist/extensions/web/tools/fetch.js +2 -2
  55. package/dist/memory/tools/compact-context.d.ts.map +1 -1
  56. package/dist/memory/tools/compact-context.js +10 -3
  57. package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
  58. package/dist/sdk/agent-core/agent-loop.js +87 -4
  59. package/dist/sdk/agent-core/types.d.ts +1 -0
  60. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  61. package/dist/sdk/ai/utils/validation.d.ts +0 -7
  62. package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
  63. package/dist/sdk/ai/utils/validation.js +21 -1
  64. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAIX,SAAS,EAET,MAAM,4BAA4B,CAAC;AAUpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EAAE,KAAK,WAAW,EAAmC,MAAM,aAAa,CAAC;AAEhF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOrD,OAAO,KAAK,EAAE,gBAAgB,IAAI,oBAAoB,EAAE,MAAM,8CAA8C,CAAC;AA4F7G,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CASpG;AAED,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAAC,GAC/C,MAAM,CAKR;AAiHD;;;GAGG;AACH,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,GAChD,IAAI,CAAC,oBAAoB,CAAC,gBAAgB,CAAC,EAAE,WAAW,CAAC,CAK3D;AAED,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,EAC/B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,YAAY,EACjB,GAAG,EAAE,gBAAgB,GACnB,SAAS,EAAE,CAqCb;AAuaD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IAAE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;CAAE,GACpF,IAAI,CAgZN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAIX,SAAS,EAET,MAAM,4BAA4B,CAAC;AAUpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EAAE,KAAK,WAAW,EAAmC,MAAM,aAAa,CAAC;AAEhF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOrD,OAAO,KAAK,EAAE,gBAAgB,IAAI,oBAAoB,EAAE,MAAM,8CAA8C,CAAC;AA4F7G,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CASpG;AAED,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAAC,GAC/C,MAAM,CAKR;AAiHD;;;GAGG;AACH,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,GAChD,IAAI,CAAC,oBAAoB,CAAC,gBAAgB,CAAC,EAAE,WAAW,CAAC,CAK3D;AAED,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,EAC/B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,YAAY,EACjB,GAAG,EAAE,gBAAgB,GACnB,SAAS,EAAE,CAqCb;AA4aD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IAAE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;CAAE,GACpF,IAAI,CAgZN"}
@@ -494,6 +494,11 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
494
494
  if (event.type === "tool_execution_end" && event.isError) {
495
495
  emitUpdate();
496
496
  }
497
+ if (event.type === "agent_end" && event.error) {
498
+ hadError = true;
499
+ currentResult.errorMessage = event.error;
500
+ currentResult.stderr = event.error;
501
+ }
497
502
  }
498
503
  // Drain the stream result (already consumed by iteration, but ensure cleanup)
499
504
  try {
@@ -5,12 +5,35 @@
5
5
  * analysis: filler phrase detection, AI-pattern detection, information density,
6
6
  * and repetition scoring. No external API calls.
7
7
  */
8
- import type { ContentQualityResult } from "../types.js";
8
+ import type { ContentQualityResult, HeadingInfo, PageData, SchemaBlock } from "../types.js";
9
+ /** Split text into sentences, respecting common abbreviations. */
10
+ export declare function splitSentences(text: string): string[];
11
+ /** Calculate Flesch-Kincaid Grade Level and related readability metrics. */
12
+ export declare function calculateReadability(text: string): {
13
+ fleschKincaid: number;
14
+ sentenceCount: number;
15
+ avgSentenceLength: number;
16
+ avgWordLength: number;
17
+ };
18
+ /** Extract named entities from text using proper noun heuristics. */
19
+ export declare function extractEntities(text: string, schemaBlocks?: SchemaBlock[]): string[];
20
+ /** Check whether heading keywords appear in body text. */
21
+ export declare function headingContentAlignment(headings: HeadingInfo[], bodyText: string, stopWords?: Set<string>): {
22
+ score: number;
23
+ missingKeywords: string[];
24
+ };
25
+ /** Evaluate heading structure depth and completeness. */
26
+ export declare function calculateSemanticDepth(headings: HeadingInfo[]): {
27
+ score: number;
28
+ hasH1: boolean;
29
+ hasH2: boolean;
30
+ hasH3: boolean;
31
+ headingCount: number;
32
+ };
9
33
  /**
10
- * Analyze content quality of a text string.
11
- *
12
- * All scores are 0-100 where higher is better (except filler and AI pattern
13
- * scores, where higher means MORE problematic content).
34
+ * Detect language using word-frequency profiles of common function words.
35
+ * Returns the ISO 639-1 language code of the best match, or null if confidence is too low.
14
36
  */
15
- export declare function analyzeContentQuality(text: string, url?: string): ContentQualityResult;
37
+ export declare function detectLanguage(text: string): string | null;
38
+ export declare function analyzeContentQuality(text: string, url?: string, pageData?: PageData, customStopWords?: string[]): ContentQualityResult;
16
39
  //# sourceMappingURL=content.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/content.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA8HxD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAgDtF"}
1
+ {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/content.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,WAAW,EAAE,QAAQ,EAAqB,WAAW,EAAE,MAAM,aAAa,CAAC;AAqJ/G,kEAAkE;AAClE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAcrD;AAaD,4EAA4E;AAC5E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG;IAClD,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;CACvB,CA2BA;AAED,qEAAqE;AACrE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,GAAG,MAAM,EAAE,CA0BpF;AAED,0DAA0D;AAC1D,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,WAAW,EAAE,EACvB,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACtB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,EAAE,CAAA;CAAE,CAsB9C;AAED,yDAAyD;AACzD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;CACtB,CAiBA;AAmBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgC1D;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,GAAG,CAAC,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,QAAQ,EACnB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,oBAAoB,CAoFtB"}
@@ -1,3 +1,19 @@
1
+ import { analyzeReadability } from "./readability.js";
2
+ /** Common stop words — filtered during tokenization to improve signal. */
3
+ const STOP_WORDS = new Set([
4
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
5
+ "of", "with", "by", "from", "up", "about", "into", "through", "during",
6
+ "before", "after", "above", "below", "between", "out", "off", "over",
7
+ "under", "again", "further", "then", "once", "here", "there", "when",
8
+ "where", "why", "how", "all", "both", "each", "few", "more", "most",
9
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
10
+ "so", "than", "too", "very", "s", "t", "can", "will", "just", "don",
11
+ "should", "now", "is", "are", "was", "were", "be", "been", "being",
12
+ "have", "has", "had", "having", "do", "does", "did", "doing", "it",
13
+ "its", "this", "that", "these", "those", "i", "me", "my", "we", "our",
14
+ "you", "your", "he", "she", "they", "them", "their", "what", "which",
15
+ "who", "whom", "would", "could", "may", "might", "shall", "must",
16
+ ]);
1
17
  /** Common filler phrases that add no information value. */
2
18
  const FILLER_PHRASES = [
3
19
  "in today's fast-paced world",
@@ -72,11 +88,19 @@ const AI_PATTERNS = [
72
88
  "transform your",
73
89
  "redefine the way",
74
90
  ];
75
- function tokenize(text) {
91
+ /** Common English abbreviations that should not trigger sentence splits. */
92
+ const ABBREVIATIONS = new Set([
93
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "ave", "blvd",
94
+ "etc", "vs", "approx", "dept", "est", "eg", "ie", "inc", "ltd",
95
+ "co", "corp", "jan", "feb", "mar", "apr", "jun", "jul", "aug",
96
+ "sep", "oct", "nov", "dec",
97
+ ]);
98
+ function tokenize(text, stopWords) {
99
+ const sw = stopWords ?? STOP_WORDS;
76
100
  return text
77
101
  .toLowerCase()
78
102
  .split(/[^a-z0-9]+/)
79
- .filter((t) => t.length > 0);
103
+ .filter((t) => t.length > 0 && !sw.has(t));
80
104
  }
81
105
  function countPatternMatches(text, patterns) {
82
106
  const lower = text.toLowerCase();
@@ -95,7 +119,6 @@ function countPatternMatches(text, patterns) {
95
119
  function calculateRepetition(tokens) {
96
120
  if (tokens.length === 0)
97
121
  return 0;
98
- // Count trigram repetitions.
99
122
  const trigrams = new Map();
100
123
  for (let i = 0; i < tokens.length - 2; i++) {
101
124
  const trigram = `${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`;
@@ -112,21 +135,204 @@ function calculateRepetition(tokens) {
112
135
  function calculateInformationDensity(text, tokens) {
113
136
  if (tokens.length === 0)
114
137
  return 0;
115
- // Count entities (capitalized words) and numbers.
116
138
  const entities = (text.match(/\b[A-Z][a-z]+\b/g) ?? []).length;
117
139
  const numbers = (text.match(/\b\d+([.,]\d+)?\b/g) ?? []).length;
118
140
  const informative = entities + numbers;
119
- // Density = informative tokens / total tokens, scaled 0-100.
120
141
  return Math.min(100, Math.round((informative / tokens.length) * 1000));
121
142
  }
143
+ /** Split text into sentences, respecting common abbreviations. */
144
+ export function splitSentences(text) {
145
+ if (!text)
146
+ return [];
147
+ // Replace known abbreviations with placeholders to avoid false splits.
148
+ let processed = text;
149
+ for (const abbr of ABBREVIATIONS) {
150
+ const escaped = abbr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
151
+ const re = new RegExp(`\\b${escaped}\\.`, "gi");
152
+ processed = processed.replace(re, (match) => match.replace(".", "##DOT##"));
153
+ }
154
+ // Split on .!? followed by whitespace and uppercase letter (or end of string).
155
+ const sentences = processed.split(/(?<=[.!?])\s+(?=[A-Z])/);
156
+ return sentences
157
+ .map((s) => s.replace(/##DOT##/g, ".").trim())
158
+ .filter((s) => s.length > 0);
159
+ }
160
+ /** Count syllables in a word using a simple vowel-group heuristic. */
161
+ function countSyllables(word) {
162
+ const cleaned = word.toLowerCase().replace(/[^a-z]/g, "");
163
+ if (cleaned.length <= 3)
164
+ return 1;
165
+ const vowelGroups = cleaned.match(/[aeiouy]+/g);
166
+ let count = vowelGroups ? vowelGroups.length : 1;
167
+ // Subtract silent 'e' at end.
168
+ if (cleaned.endsWith("e") && count > 1)
169
+ count--;
170
+ return Math.max(1, count);
171
+ }
172
+ /** Calculate Flesch-Kincaid Grade Level and related readability metrics. */
173
+ export function calculateReadability(text) {
174
+ const sentences = splitSentences(text);
175
+ const sentenceCount = sentences.length;
176
+ if (sentenceCount === 0) {
177
+ return { fleschKincaid: 0, sentenceCount: 0, avgSentenceLength: 0, avgWordLength: 0 };
178
+ }
179
+ const words = text.split(/\s+/).filter(Boolean);
180
+ const wordCount = words.length;
181
+ if (wordCount === 0) {
182
+ return { fleschKincaid: 0, sentenceCount, avgSentenceLength: 0, avgWordLength: 0 };
183
+ }
184
+ const avgSentenceLength = wordCount / sentenceCount;
185
+ let totalSyllables = 0;
186
+ let totalLetters = 0;
187
+ for (const word of words) {
188
+ totalSyllables += countSyllables(word);
189
+ totalLetters += word.replace(/[^a-zA-Z]/g, "").length;
190
+ }
191
+ const avgWordLength = totalLetters / wordCount;
192
+ // Flesch-Kincaid Grade Level = 0.39*(words/sentences) + 11.8*(syllables/words) - 15.59
193
+ const fleschKincaid = 0.39 * avgSentenceLength + 11.8 * (totalSyllables / wordCount) - 15.59;
194
+ return {
195
+ fleschKincaid: Math.round(fleschKincaid * 10) / 10,
196
+ sentenceCount,
197
+ avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
198
+ avgWordLength: Math.round(avgWordLength * 10) / 10,
199
+ };
200
+ }
201
+ /** Extract named entities from text using proper noun heuristics. */
202
+ export function extractEntities(text, schemaBlocks) {
203
+ const entities = new Set();
204
+ // Extract consecutive capitalized words (potential proper nouns).
205
+ const properNouns = text.match(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g) ?? [];
206
+ for (const noun of properNouns) {
207
+ // Only keep multi-word or clearly proper noun single words (not sentence starters).
208
+ if (noun.includes(" ") || noun.length > 3) {
209
+ entities.add(noun);
210
+ }
211
+ }
212
+ // Cross-reference with schema @type values.
213
+ if (schemaBlocks) {
214
+ for (const block of schemaBlocks) {
215
+ if (block.type && block.type !== "Unknown" && block.type !== "Invalid" && block.type !== "InvalidJSON") {
216
+ entities.add(block.type);
217
+ }
218
+ // Also check parsed schema for name properties.
219
+ if (block.parsed && typeof block.parsed === "object") {
220
+ const parsed = block.parsed;
221
+ if (typeof parsed.name === "string" && parsed.name.length > 0) {
222
+ entities.add(parsed.name);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return [...entities];
228
+ }
229
+ /** Check whether heading keywords appear in body text. */
230
+ export function headingContentAlignment(headings, bodyText, stopWords) {
231
+ const sw = stopWords ?? STOP_WORDS;
232
+ const lowerBody = bodyText.toLowerCase();
233
+ // Extract meaningful keywords from H1/H2 text (words 4+ chars, not stop words).
234
+ const headingKeywords = new Set();
235
+ for (const h of headings) {
236
+ if (h.level > 2)
237
+ continue; // Only H1/H2.
238
+ const words = h.text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !sw.has(w));
239
+ for (const w of words)
240
+ headingKeywords.add(w);
241
+ }
242
+ if (headingKeywords.size === 0)
243
+ return { score: 100, missingKeywords: [] };
244
+ const missingKeywords = [];
245
+ let found = 0;
246
+ for (const kw of headingKeywords) {
247
+ if (lowerBody.includes(kw)) {
248
+ found++;
249
+ }
250
+ else {
251
+ missingKeywords.push(kw);
252
+ }
253
+ }
254
+ const score = Math.round((found / headingKeywords.size) * 100);
255
+ return { score, missingKeywords };
256
+ }
257
+ /** Evaluate heading structure depth and completeness. */
258
+ export function calculateSemanticDepth(headings) {
259
+ let hasH1 = false;
260
+ let hasH2 = false;
261
+ let hasH3 = false;
262
+ let headingCount = 0;
263
+ for (const h of headings) {
264
+ headingCount++;
265
+ if (h.level === 1)
266
+ hasH1 = true;
267
+ if (h.level === 2)
268
+ hasH2 = true;
269
+ if (h.level === 3)
270
+ hasH3 = true;
271
+ }
272
+ let score = 0;
273
+ if (hasH1)
274
+ score += 40;
275
+ if (hasH2)
276
+ score += 35;
277
+ if (hasH3)
278
+ score += 15;
279
+ if (headingCount >= 5)
280
+ score += 10;
281
+ return { score, hasH1, hasH2, hasH3, headingCount };
282
+ }
122
283
  /**
123
284
  * Analyze content quality of a text string.
124
285
  *
125
286
  * All scores are 0-100 where higher is better (except filler and AI pattern
126
287
  * scores, where higher means MORE problematic content).
127
288
  */
128
- export function analyzeContentQuality(text, url) {
129
- const tokens = tokenize(text);
289
+ const LANGUAGE_PROFILES = {
290
+ en: ["the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "her", "she", "or", "an", "will", "my", "one", "all", "would", "there", "their"],
291
+ pl: ["i", "w", "się", "na", "nie", "to", "jest", "że", "z", "do", "o", "a", "co", "po", "tak", "jak", "ale", "mnie", "już", "tego", "czy", "jej", "był", "bardzo", "dla", "go", "przy", "przed", "nad", "pod"],
292
+ de: ["der", "die", "und", "in", "zu", "den", "das", "nicht", "von", "sie", "ist", "des", "sich", "mit", "dem", "dass", "er", "es", "ein", "ich", "auf", "so", "eine", "auch", "als", "an", "nach", "wie", "im", "für"],
293
+ fr: ["de", "la", "le", "et", "les", "des", "en", "un", "une", "du", "que", "pas", "pour", "dans", "qui", "par", "sur", "au", "avec", "ne", "ce", "plus", "se", "son", "aux", "dont", "comme", "tout", "cette", "même"],
294
+ es: ["de", "que", "el", "en", "y", "a", "los", "se", "del", "las", "un", "por", "con", "no", "una", "su", "para", "es", "al", "lo", "como", "más", "o", "pero", "sus", "le", "ha", "me", "si", "sin"],
295
+ it: ["di", "che", "e", "la", "il", "un", "a", "per", "in", "una", "con", "non", "si", "le", "del", "da", "più", "al", "ma", "gli", "ad", "anche", "o", "cui", "suo", "tra", "mio", "questa", "quello", "dalla"],
296
+ pt: ["de", "que", "e", "do", "a", "o", "não", "um", "da", "se", "com", "uma", "os", "no", "me", "por", "as", "dos", "mas", "ele", "na", "ao", "para", "bem", "seu", "isso", "pela", "lhe", "qual", "já"],
297
+ nl: ["de", "van", "een", "het", "dat", "in", "en", "is", "niet", "op", "te", "voor", "zijn", "met", "die", "aan", "er", "als", "ook", "om", "dan", "uit", "bij", "of", "kan", "nog", "naar", "al", "worden", "door"],
298
+ };
299
+ /**
300
+ * Detect language using word-frequency profiles of common function words.
301
+ * Returns the ISO 639-1 language code of the best match, or null if confidence is too low.
302
+ */
303
+ export function detectLanguage(text) {
304
+ const words = text.toLowerCase().split(/[^a-zà-ÿ]+/).filter(w => w.length >= 2);
305
+ const scores = {};
306
+ for (const [lang, signalWords] of Object.entries(LANGUAGE_PROFILES)) {
307
+ const signalSet = new Set(signalWords);
308
+ let count = 0;
309
+ for (const word of words) {
310
+ if (signalSet.has(word))
311
+ count++;
312
+ }
313
+ scores[lang] = count;
314
+ }
315
+ let bestLang = null;
316
+ let bestScore = 0;
317
+ let secondBest = 0;
318
+ for (const [lang, score] of Object.entries(scores)) {
319
+ if (score > bestScore) {
320
+ secondBest = bestScore;
321
+ bestScore = score;
322
+ bestLang = lang;
323
+ }
324
+ else if (score > secondBest) {
325
+ secondBest = score;
326
+ }
327
+ }
328
+ if (bestScore < 3 || (secondBest > 0 && bestScore < secondBest * 2)) {
329
+ return null;
330
+ }
331
+ return bestLang;
332
+ }
333
+ export function analyzeContentQuality(text, url, pageData, customStopWords) {
334
+ const effectiveStopWords = new Set([...STOP_WORDS, ...((customStopWords ?? []).map(w => w.toLowerCase()))]);
335
+ const tokens = tokenize(text, effectiveStopWords);
130
336
  const wordCount = text.split(/\s+/).filter(Boolean).length;
131
337
  const uniqueTokens = new Set(tokens).size;
132
338
  // Filler phrase detection.
@@ -157,7 +363,7 @@ export function analyzeContentQuality(text, url) {
157
363
  flags.push("low-information-density");
158
364
  if (wordCount < 300)
159
365
  flags.push("thin-content");
160
- return {
366
+ const result = {
161
367
  url,
162
368
  wordCount,
163
369
  tokens: tokens.length,
@@ -170,5 +376,34 @@ export function analyzeContentQuality(text, url) {
170
376
  flags,
171
377
  fillerMatches: filler.matches,
172
378
  aiPatternMatches: aiPatterns.matches,
379
+ disclaimer: "Note: This analysis measures content quality heuristics only. It does not measure search rankings, traffic, or search volume data.",
173
380
  };
381
+ // Enriched analysis when pageData is available.
382
+ if (pageData) {
383
+ // Readability.
384
+ const readabilityResult = analyzeReadability(text, url);
385
+ result.readability = readabilityResult;
386
+ result.sentenceCount = readabilityResult.sentenceCount;
387
+ result.avgSentenceLength = readabilityResult.sentenceCount > 0
388
+ ? Math.round((result.wordCount / readabilityResult.sentenceCount) * 10) / 10
389
+ : 0;
390
+ const totalLetters = text.replace(/[^a-zA-Z]/g, "").length;
391
+ result.avgWordLength = result.wordCount > 0
392
+ ? Math.round((totalLetters / result.wordCount) * 10) / 10
393
+ : 0;
394
+ result.readabilityFleschKincaid = readabilityResult.fleschKincaid;
395
+ // Heading-content alignment.
396
+ if (pageData.headings && pageData.headings.length > 0) {
397
+ result.headingAlignment = headingContentAlignment(pageData.headings, text, effectiveStopWords);
398
+ }
399
+ // Semantic depth.
400
+ if (pageData.headings) {
401
+ result.semanticDepth = calculateSemanticDepth(pageData.headings);
402
+ }
403
+ // Entity extraction.
404
+ result.entities = extractEntities(text, pageData.schemaBlocks);
405
+ // Language.
406
+ result.language = pageData.language ?? detectLanguage(text) ?? undefined;
407
+ }
408
+ return result;
174
409
  }
@@ -1 +1 @@
1
- {"version":3,"file":"crawler.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/crawler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAEV,WAAW,EACX,WAAW,EAGZ,MAAM,aAAa,CAAC;AAgHrB,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,SAAS,EACjB,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,WAAW,CAAC,CA+LtB"}
1
+ {"version":3,"file":"crawler.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/crawler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAEV,WAAW,EACX,WAAW,EAGZ,MAAM,aAAa,CAAC;AAsKrB,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,SAAS,EACjB,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,WAAW,CAAC,CAkOtB"}
@@ -30,12 +30,46 @@ function urlIdentity(url) {
30
30
  }
31
31
  }
32
32
  function extractInternalLinks(html, baseUrl) {
33
- const links = [];
33
+ const internalLinks = [];
34
+ const canonicalUrls = [];
35
+ const paginationUrls = [];
36
+ let nofollowCount = 0;
37
+ let sponsoredCount = 0;
38
+ let ugcCount = 0;
39
+ // Extract <link rel="canonical"> and <link rel="next">/<link rel="prev">
40
+ const linkTagRegex = /<link\s[^>]*\/?>/gi;
41
+ let linkMatch;
42
+ while ((linkMatch = linkTagRegex.exec(html)) !== null) {
43
+ const tag = linkMatch[0];
44
+ const relMatch = /rel=["']([^"']+)["']/i.exec(tag);
45
+ const hrefMatch = /href=["']([^"']+)["']/i.exec(tag);
46
+ if (!relMatch || !hrefMatch)
47
+ continue;
48
+ const rel = relMatch[1].toLowerCase();
49
+ const href = hrefMatch[1];
50
+ try {
51
+ const resolved = new URL(href, baseUrl).href;
52
+ const base = new URL(baseUrl);
53
+ const resolvedUrl = new URL(resolved);
54
+ if (resolvedUrl.hostname === base.hostname && resolvedUrl.protocol.startsWith("http")) {
55
+ const normalized = normalizeUrl(resolved);
56
+ if (rel === "canonical")
57
+ canonicalUrls.push(normalized);
58
+ if (rel === "next" || rel === "prev")
59
+ paginationUrls.push(normalized);
60
+ }
61
+ }
62
+ catch {
63
+ }
64
+ }
65
+ // Extract <a href="..."> links with rel attributes.
34
66
  const hrefRegex = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
35
67
  let match;
36
68
  while ((match = hrefRegex.exec(html)) !== null) {
69
+ const tag = match[0];
70
+ const href = match[1];
37
71
  try {
38
- const resolved = new URL(match[1], baseUrl).href;
72
+ const resolved = new URL(href, baseUrl).href;
39
73
  const base = new URL(baseUrl);
40
74
  const resolvedUrl = new URL(resolved);
41
75
  if (resolvedUrl.hostname === base.hostname &&
@@ -45,14 +79,30 @@ function extractInternalLinks(html, baseUrl) {
45
79
  !resolved.includes("mailto:") &&
46
80
  !resolved.includes("/cdn-cgi/")) {
47
81
  const normalized = normalizeUrl(resolved);
48
- links.push(normalized);
82
+ const relMatch = /rel=["']([^"']+)["']/i.exec(tag);
83
+ const relVals = relMatch ? relMatch[1].toLowerCase().split(/\s+/) : [];
84
+ if (relVals.includes("nofollow"))
85
+ nofollowCount++;
86
+ if (relVals.includes("sponsored"))
87
+ sponsoredCount++;
88
+ if (relVals.includes("ugc"))
89
+ ugcCount++;
90
+ if (!relVals.includes("nofollow")) {
91
+ internalLinks.push(normalized);
92
+ }
49
93
  }
50
94
  }
51
95
  catch {
52
- // skip malformed hrefs
53
96
  }
54
97
  }
55
- return [...new Set(links)];
98
+ return {
99
+ internalLinks: [...new Set(internalLinks)],
100
+ canonicalUrls: [...new Set(canonicalUrls)],
101
+ paginationUrls: [...new Set(paginationUrls)],
102
+ nofollowCount,
103
+ sponsoredCount,
104
+ ugcCount,
105
+ };
56
106
  }
57
107
  function parseRobotsTxtForDisallowed(content, userAgent) {
58
108
  const lines = content.split("\n").map((l) => l.trim());
@@ -138,6 +188,12 @@ export async function crawlSite(startUrl, config, crawlConfig) {
138
188
  const brokenLinks = [];
139
189
  const errors = [];
140
190
  const allDiscoveredLinks = new Set();
191
+ const canonicalUrlSet = new Set();
192
+ const redirectingInternalLinks = [];
193
+ let nofollowLinks = 0;
194
+ let sponsoredLinks = 0;
195
+ let ugcLinks = 0;
196
+ let paginationLinks = 0;
141
197
  while (queue.length > 0 &&
142
198
  visitedIdentities.size < cfg.maxPages) {
143
199
  const batch = [];
@@ -159,15 +215,27 @@ export async function crawlSite(startUrl, config, crawlConfig) {
159
215
  try {
160
216
  const fetchResult = await fetchPage(url, config);
161
217
  const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
162
- const internalLinks = extractInternalLinks(fetchResult.content, fetchResult.finalUrl);
218
+ const extracted = extractInternalLinks(fetchResult.content, fetchResult.finalUrl);
219
+ // Detect 3xx redirects to internal targets.
220
+ if (fetchResult.statusCode >= 300 && fetchResult.statusCode < 400 && fetchResult.redirectChain.length > 0) {
221
+ const lastHop = fetchResult.redirectChain[fetchResult.redirectChain.length - 1];
222
+ try {
223
+ if (new URL(lastHop.url).hostname === baseHost) {
224
+ redirectingInternalLinks.push({ source: fetchResult.url, target: fetchResult.finalUrl });
225
+ }
226
+ }
227
+ catch {
228
+ }
229
+ }
163
230
  return {
164
231
  url: fetchResult.finalUrl,
165
232
  depth,
166
233
  statusCode: fetchResult.statusCode,
167
234
  fetchResult,
168
235
  pageData,
169
- linksFound: internalLinks,
236
+ linksFound: [...extracted.internalLinks, ...extracted.paginationUrls, ...extracted.canonicalUrls],
170
237
  crawlDuration: Date.now() - pageStart,
238
+ _extracted: extracted,
171
239
  };
172
240
  }
173
241
  catch (err) {
@@ -194,6 +262,16 @@ export async function crawlSite(startUrl, config, crawlConfig) {
194
262
  const identity = urlIdentity(page.url);
195
263
  visitedIdentities.add(identity);
196
264
  pages.push(page);
265
+ // Aggregate link counters from the extracted metadata.
266
+ const ext = page._extracted;
267
+ if (ext) {
268
+ nofollowLinks += ext.nofollowCount;
269
+ sponsoredLinks += ext.sponsoredCount;
270
+ ugcLinks += ext.ugcCount;
271
+ paginationLinks += ext.paginationUrls.length;
272
+ for (const c of ext.canonicalUrls)
273
+ canonicalUrlSet.add(c);
274
+ }
197
275
  if (page.pageData) {
198
276
  for (const link of page.pageData.links) {
199
277
  if (link.type === "external")
@@ -255,6 +333,7 @@ export async function crawlSite(startUrl, config, crawlConfig) {
255
333
  depth: p.depth,
256
334
  statusCode: p.statusCode,
257
335
  }));
336
+ const keywordCannibalization = detectKeywordCannibalization(pages);
258
337
  return {
259
338
  baseUrl: normalizedStart,
260
339
  pagesCrawled: pages.length,
@@ -266,6 +345,13 @@ export async function crawlSite(startUrl, config, crawlConfig) {
266
345
  pages,
267
346
  linkGraph,
268
347
  errors,
348
+ canonicalUrls: [...canonicalUrlSet],
349
+ nofollowLinks,
350
+ sponsoredLinks,
351
+ ugcLinks,
352
+ paginationLinks,
353
+ redirectingInternalLinks,
354
+ keywordCannibalization,
269
355
  };
270
356
  }
271
357
  function detectDuplicates(pages) {
@@ -322,6 +408,46 @@ function detectDuplicates(pages) {
322
408
  }
323
409
  return groups;
324
410
  }
411
+ function detectKeywordCannibalization(pages) {
412
+ const NGRAM_MIN = 3;
413
+ const wordRe = /\b[a-z]{3,}\b/g;
414
+ const phraseMap = new Map();
415
+ for (const page of pages) {
416
+ if (!page.pageData)
417
+ continue;
418
+ const title = page.pageData.title?.toLowerCase() ?? "";
419
+ const h1Texts = page.pageData.headings
420
+ .filter((h) => h.level === 1)
421
+ .map((h) => h.text.toLowerCase());
422
+ const combined = `${title} ${h1Texts.join(" ")}`;
423
+ const words = combined.match(wordRe);
424
+ if (!words || words.length < NGRAM_MIN)
425
+ continue;
426
+ for (let i = 0; i <= words.length - NGRAM_MIN; i++) {
427
+ const ngram = words.slice(i, i + NGRAM_MIN).join(" ");
428
+ if (!phraseMap.has(ngram))
429
+ phraseMap.set(ngram, []);
430
+ phraseMap.get(ngram).push(page.url);
431
+ }
432
+ }
433
+ const results = [];
434
+ const dedupe = new Map();
435
+ for (const [ngram, urls] of phraseMap) {
436
+ const uniquePages = [...new Set(urls)];
437
+ if (uniquePages.length >= 2) {
438
+ uniquePages.sort();
439
+ const sig = ngram + "|" + uniquePages.join("|");
440
+ if (!dedupe.has(sig)) {
441
+ dedupe.set(sig, uniquePages);
442
+ }
443
+ }
444
+ }
445
+ for (const [sig, urls] of dedupe) {
446
+ const keyword = sig.split("|")[0];
447
+ results.push({ keyword, pages: urls });
448
+ }
449
+ return results.sort((a, b) => b.pages.length - a.pages.length).slice(0, 20);
450
+ }
325
451
  function sleep(ms) {
326
452
  return new Promise((resolve) => setTimeout(resolve, ms));
327
453
  }
@@ -1 +1 @@
1
- {"version":3,"file":"drift.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/drift.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAqC9C,kEAAkE;AAClE,wBAAgB,YAAY,IAAI,IAAI,CASnC;AAMD,6CAA6C;AAC7C,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,aAAa,CAAC,CAwDxB;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAWrG;AAuBD,qDAAqD;AACrD,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,CAAC,CAuE7B;AAED,sCAAsC;AACtC,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,SAAK,GAAG,aAAa,EAAE,CAK9F"}
1
+ {"version":3,"file":"drift.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/drift.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AA4D9C,kEAAkE;AAClE,wBAAgB,YAAY,IAAI,IAAI,CASnC;AAMD,6CAA6C;AAC7C,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,aAAa,CAAC,CAmExB;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAWrG;AAyBD,qDAAqD;AACrD,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,CAAC,CAuG7B;AAED,sCAAsC;AACtC,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,SAAK,GAAG,aAAa,EAAE,CAK9F"}