@aexol/spectral 0.9.42 → 0.9.44

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 (144) hide show
  1. package/dist/extensions/aexol-mcp.d.ts.map +1 -1
  2. package/dist/extensions/aexol-mcp.js +44 -9
  3. package/dist/extensions/browser/browser-service.d.ts +4 -1
  4. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  5. package/dist/extensions/browser/browser-service.js +34 -7
  6. package/dist/extensions/browser/config.d.ts +3 -0
  7. package/dist/extensions/browser/config.d.ts.map +1 -1
  8. package/dist/extensions/browser/config.js +1 -0
  9. package/dist/extensions/browser/index.d.ts.map +1 -1
  10. package/dist/extensions/browser/index.js +3 -0
  11. package/dist/extensions/browser/record/record-tool.d.ts.map +1 -1
  12. package/dist/extensions/browser/record/record-tool.js +27 -4
  13. package/dist/extensions/browser/scenario/markdown-parser.d.ts +44 -0
  14. package/dist/extensions/browser/scenario/markdown-parser.d.ts.map +1 -0
  15. package/dist/extensions/browser/scenario/markdown-parser.js +159 -0
  16. package/dist/extensions/browser/scenario/scenario-tool.d.ts +4 -0
  17. package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -0
  18. package/dist/extensions/browser/scenario/scenario-tool.js +381 -0
  19. package/dist/extensions/browser/tools/core.js +1 -1
  20. package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/screenshot.js +2 -1
  22. package/dist/extensions/seo/analyzers/agent-ux.d.ts +5 -0
  23. package/dist/extensions/seo/analyzers/agent-ux.d.ts.map +1 -0
  24. package/dist/extensions/seo/analyzers/agent-ux.js +95 -0
  25. package/dist/extensions/seo/analyzers/content-optimize.d.ts +3 -0
  26. package/dist/extensions/seo/analyzers/content-optimize.d.ts.map +1 -0
  27. package/dist/extensions/seo/analyzers/content-optimize.js +190 -0
  28. package/dist/extensions/seo/analyzers/content.d.ts +16 -0
  29. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -0
  30. package/dist/extensions/seo/analyzers/content.js +174 -0
  31. package/dist/extensions/seo/analyzers/crawler.d.ts +4 -0
  32. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -0
  33. package/dist/extensions/seo/analyzers/crawler.js +288 -0
  34. package/dist/extensions/seo/analyzers/drift.d.ts +13 -0
  35. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -0
  36. package/dist/extensions/seo/analyzers/drift.js +204 -0
  37. package/dist/extensions/seo/analyzers/images.d.ts +10 -0
  38. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -0
  39. package/dist/extensions/seo/analyzers/images.js +49 -0
  40. package/dist/extensions/seo/analyzers/keywords.d.ts +9 -0
  41. package/dist/extensions/seo/analyzers/keywords.d.ts.map +1 -0
  42. package/dist/extensions/seo/analyzers/keywords.js +183 -0
  43. package/dist/extensions/seo/analyzers/parasite.d.ts +18 -0
  44. package/dist/extensions/seo/analyzers/parasite.d.ts.map +1 -0
  45. package/dist/extensions/seo/analyzers/parasite.js +142 -0
  46. package/dist/extensions/seo/analyzers/performance.d.ts +6 -0
  47. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -0
  48. package/dist/extensions/seo/analyzers/performance.js +257 -0
  49. package/dist/extensions/seo/analyzers/preload.d.ts +12 -0
  50. package/dist/extensions/seo/analyzers/preload.d.ts.map +1 -0
  51. package/dist/extensions/seo/analyzers/preload.js +108 -0
  52. package/dist/extensions/seo/analyzers/readability.d.ts +4 -0
  53. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -0
  54. package/dist/extensions/seo/analyzers/readability.js +121 -0
  55. package/dist/extensions/seo/analyzers/schema.d.ts +21 -0
  56. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -0
  57. package/dist/extensions/seo/analyzers/schema.js +324 -0
  58. package/dist/extensions/seo/analyzers/sitemap.d.ts +20 -0
  59. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -0
  60. package/dist/extensions/seo/analyzers/sitemap.js +121 -0
  61. package/dist/extensions/seo/analyzers/technical.d.ts +11 -0
  62. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -0
  63. package/dist/extensions/seo/analyzers/technical.js +268 -0
  64. package/dist/extensions/seo/config.d.ts +24 -0
  65. package/dist/extensions/seo/config.d.ts.map +1 -0
  66. package/dist/extensions/seo/config.js +48 -0
  67. package/dist/extensions/seo/index.d.ts +16 -0
  68. package/dist/extensions/seo/index.d.ts.map +1 -0
  69. package/dist/extensions/seo/index.js +126 -0
  70. package/dist/extensions/seo/tools/agent-ux.d.ts +7 -0
  71. package/dist/extensions/seo/tools/agent-ux.d.ts.map +1 -0
  72. package/dist/extensions/seo/tools/agent-ux.js +63 -0
  73. package/dist/extensions/seo/tools/batch.d.ts +4 -0
  74. package/dist/extensions/seo/tools/batch.d.ts.map +1 -0
  75. package/dist/extensions/seo/tools/batch.js +193 -0
  76. package/dist/extensions/seo/tools/content.d.ts +7 -0
  77. package/dist/extensions/seo/tools/content.d.ts.map +1 -0
  78. package/dist/extensions/seo/tools/content.js +55 -0
  79. package/dist/extensions/seo/tools/crawl.d.ts +4 -0
  80. package/dist/extensions/seo/tools/crawl.d.ts.map +1 -0
  81. package/dist/extensions/seo/tools/crawl.js +166 -0
  82. package/dist/extensions/seo/tools/drift.d.ts +7 -0
  83. package/dist/extensions/seo/tools/drift.d.ts.map +1 -0
  84. package/dist/extensions/seo/tools/drift.js +104 -0
  85. package/dist/extensions/seo/tools/fetch.d.ts +7 -0
  86. package/dist/extensions/seo/tools/fetch.d.ts.map +1 -0
  87. package/dist/extensions/seo/tools/fetch.js +123 -0
  88. package/dist/extensions/seo/tools/full-audit.d.ts +11 -0
  89. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -0
  90. package/dist/extensions/seo/tools/full-audit.js +191 -0
  91. package/dist/extensions/seo/tools/helpers.d.ts +13 -0
  92. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -0
  93. package/dist/extensions/seo/tools/helpers.js +24 -0
  94. package/dist/extensions/seo/tools/images.d.ts +7 -0
  95. package/dist/extensions/seo/tools/images.d.ts.map +1 -0
  96. package/dist/extensions/seo/tools/images.js +46 -0
  97. package/dist/extensions/seo/tools/keywords.d.ts +4 -0
  98. package/dist/extensions/seo/tools/keywords.d.ts.map +1 -0
  99. package/dist/extensions/seo/tools/keywords.js +234 -0
  100. package/dist/extensions/seo/tools/parasite.d.ts +7 -0
  101. package/dist/extensions/seo/tools/parasite.d.ts.map +1 -0
  102. package/dist/extensions/seo/tools/parasite.js +53 -0
  103. package/dist/extensions/seo/tools/parse.d.ts +7 -0
  104. package/dist/extensions/seo/tools/parse.d.ts.map +1 -0
  105. package/dist/extensions/seo/tools/parse.js +66 -0
  106. package/dist/extensions/seo/tools/performance.d.ts +4 -0
  107. package/dist/extensions/seo/tools/performance.d.ts.map +1 -0
  108. package/dist/extensions/seo/tools/performance.js +201 -0
  109. package/dist/extensions/seo/tools/preload.d.ts +7 -0
  110. package/dist/extensions/seo/tools/preload.d.ts.map +1 -0
  111. package/dist/extensions/seo/tools/preload.js +52 -0
  112. package/dist/extensions/seo/tools/report.d.ts +4 -0
  113. package/dist/extensions/seo/tools/report.d.ts.map +1 -0
  114. package/dist/extensions/seo/tools/report.js +167 -0
  115. package/dist/extensions/seo/tools/schema.d.ts +7 -0
  116. package/dist/extensions/seo/tools/schema.d.ts.map +1 -0
  117. package/dist/extensions/seo/tools/schema.js +105 -0
  118. package/dist/extensions/seo/tools/sitemap.d.ts +7 -0
  119. package/dist/extensions/seo/tools/sitemap.d.ts.map +1 -0
  120. package/dist/extensions/seo/tools/sitemap.js +85 -0
  121. package/dist/extensions/seo/tools/technical.d.ts +7 -0
  122. package/dist/extensions/seo/tools/technical.d.ts.map +1 -0
  123. package/dist/extensions/seo/tools/technical.js +62 -0
  124. package/dist/extensions/seo/types.d.ts +445 -0
  125. package/dist/extensions/seo/types.d.ts.map +1 -0
  126. package/dist/extensions/seo/types.js +7 -0
  127. package/dist/extensions/seo/utils/fetcher.d.ts +28 -0
  128. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -0
  129. package/dist/extensions/seo/utils/fetcher.js +118 -0
  130. package/dist/extensions/seo/utils/parser.d.ts +8 -0
  131. package/dist/extensions/seo/utils/parser.d.ts.map +1 -0
  132. package/dist/extensions/seo/utils/parser.js +265 -0
  133. package/dist/extensions/seo/utils/renderer.d.ts +39 -0
  134. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -0
  135. package/dist/extensions/seo/utils/renderer.js +190 -0
  136. package/dist/extensions/seo/utils/url-safety.d.ts +31 -0
  137. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -0
  138. package/dist/extensions/seo/utils/url-safety.js +202 -0
  139. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  140. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
  141. package/dist/server/handlers/paths-pick-directory.d.ts +0 -27
  142. package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
  143. package/dist/server/handlers/paths-pick-directory.js +86 -92
  144. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ import type { DriftBaseline, DriftCompareResult } from "../types.js";
2
+ import type { SeoConfig } from "../config.js";
3
+ /** Close the database connection (called on session shutdown). */
4
+ export declare function closeDriftDb(): void;
5
+ /** Capture a baseline snapshot of a page. */
6
+ export declare function captureBaseline(url: string, config: SeoConfig): Promise<DriftBaseline>;
7
+ /** Get a baseline by ID or the latest baseline for a URL. */
8
+ export declare function getBaseline(url: string, config: SeoConfig, baselineId?: number): DriftBaseline | null;
9
+ /** Compare current page state against a baseline. */
10
+ export declare function compareBaseline(url: string, config: SeoConfig, baselineId?: number): Promise<DriftCompareResult>;
11
+ /** Get baseline history for a URL. */
12
+ export declare function getBaselineHistory(url: string, config: SeoConfig, limit?: number): DriftBaseline[];
13
+ //# sourceMappingURL=drift.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,204 @@
1
+ /**
2
+ * SEO drift monitoring — baseline capture and comparison.
3
+ *
4
+ * Ported from the Python `drift_baseline.py` and `drift_compare.py` in
5
+ * claude-seo. Stores snapshots in a local SQLite database and compares
6
+ * current page state against a known-good baseline.
7
+ */
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import Database from "better-sqlite3";
12
+ import { parsePage } from "../utils/parser.js";
13
+ import { fetchPage } from "../utils/fetcher.js";
14
+ import { normalizeUrl, urlHash } from "../utils/url-safety.js";
15
+ let dbInstance = null;
16
+ function getDb(config) {
17
+ if (dbInstance)
18
+ return dbInstance;
19
+ mkdirSync(dirname(config.dbPath), { recursive: true });
20
+ dbInstance = new Database(config.dbPath);
21
+ dbInstance.pragma("journal_mode = WAL");
22
+ dbInstance.exec(`
23
+ CREATE TABLE IF NOT EXISTS baselines (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ url TEXT NOT NULL,
26
+ url_hash TEXT NOT NULL,
27
+ timestamp TEXT NOT NULL,
28
+ title TEXT,
29
+ meta_description TEXT,
30
+ canonical TEXT,
31
+ robots TEXT,
32
+ h1 TEXT,
33
+ h2_json TEXT,
34
+ h3_json TEXT,
35
+ schema_json TEXT,
36
+ og_json TEXT,
37
+ html_hash TEXT,
38
+ schema_hash TEXT,
39
+ status_code INTEGER
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_url_hash ON baselines(url_hash);
42
+ `);
43
+ return dbInstance;
44
+ }
45
+ /** Close the database connection (called on session shutdown). */
46
+ export function closeDriftDb() {
47
+ if (dbInstance) {
48
+ try {
49
+ dbInstance.close();
50
+ }
51
+ catch {
52
+ // ignore
53
+ }
54
+ dbInstance = null;
55
+ }
56
+ }
57
+ function hashContent(content) {
58
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
59
+ }
60
+ /** Capture a baseline snapshot of a page. */
61
+ export async function captureBaseline(url, config) {
62
+ const fetchResult = await fetchPage(url, config);
63
+ const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
64
+ const hash = urlHash(url);
65
+ const timestamp = new Date().toISOString();
66
+ const h2Json = JSON.stringify(pageData.headings.filter((h) => h.level === 2).map((h) => h.text));
67
+ const h3Json = JSON.stringify(pageData.headings.filter((h) => h.level === 3).map((h) => h.text));
68
+ const schemaJson = JSON.stringify(pageData.schemaBlocks.map((s) => ({ type: s.type, format: s.format })));
69
+ const ogJson = JSON.stringify(pageData.ogTags);
70
+ const htmlHash = hashContent(fetchResult.content);
71
+ const schemaHash = hashContent(schemaJson);
72
+ const db = getDb(config);
73
+ const stmt = db.prepare(`
74
+ INSERT INTO baselines
75
+ (url, url_hash, timestamp, title, meta_description, canonical, robots,
76
+ h1, h2_json, h3_json, schema_json, og_json, html_hash, schema_hash, status_code)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
+ `);
79
+ const info = stmt.run(normalizeUrl(url), hash, timestamp, pageData.title, pageData.metaDescription, pageData.canonical, pageData.metaRobots, pageData.headings.find((h) => h.level === 1)?.text ?? null, h2Json, h3Json, schemaJson, ogJson, htmlHash, schemaHash, fetchResult.statusCode);
80
+ return {
81
+ id: Number(info.lastInsertRowid),
82
+ url: normalizeUrl(url),
83
+ urlHash: hash,
84
+ timestamp,
85
+ title: pageData.title,
86
+ metaDescription: pageData.metaDescription,
87
+ canonical: pageData.canonical,
88
+ robots: pageData.metaRobots,
89
+ h1: pageData.headings.find((h) => h.level === 1)?.text ?? null,
90
+ h2Json,
91
+ h3Json,
92
+ schemaJson,
93
+ ogJson,
94
+ htmlHash,
95
+ schemaHash,
96
+ statusCode: fetchResult.statusCode,
97
+ };
98
+ }
99
+ /** Get a baseline by ID or the latest baseline for a URL. */
100
+ export function getBaseline(url, config, baselineId) {
101
+ const db = getDb(config);
102
+ const hash = urlHash(url);
103
+ if (baselineId !== undefined) {
104
+ const row = db.prepare("SELECT * FROM baselines WHERE id = ?").get(baselineId);
105
+ return row ? rowToBaseline(row) : null;
106
+ }
107
+ const row = db.prepare("SELECT * FROM baselines WHERE url_hash = ? ORDER BY id DESC LIMIT 1").get(hash);
108
+ return row ? rowToBaseline(row) : null;
109
+ }
110
+ function rowToBaseline(row) {
111
+ return {
112
+ id: row.id,
113
+ url: row.url,
114
+ urlHash: row.url_hash,
115
+ timestamp: row.timestamp,
116
+ title: row.title ?? null,
117
+ metaDescription: row.meta_description ?? null,
118
+ canonical: row.canonical ?? null,
119
+ robots: row.robots ?? null,
120
+ h1: row.h1 ?? null,
121
+ h2Json: row.h2_json ?? "[]",
122
+ h3Json: row.h3_json ?? "[]",
123
+ schemaJson: row.schema_json ?? "[]",
124
+ ogJson: row.og_json ?? "[]",
125
+ htmlHash: row.html_hash ?? "",
126
+ schemaHash: row.schema_hash ?? "",
127
+ statusCode: row.status_code ?? 0,
128
+ };
129
+ }
130
+ /** Compare current page state against a baseline. */
131
+ export async function compareBaseline(url, config, baselineId) {
132
+ const baseline = getBaseline(url, config, baselineId);
133
+ if (!baseline) {
134
+ throw new Error(`No baseline found for ${url}${baselineId ? ` (id=${baselineId})` : ""}.`);
135
+ }
136
+ const fetchResult = await fetchPage(url, config);
137
+ const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
138
+ const changedFields = [];
139
+ const details = {};
140
+ const comparisons = [
141
+ { field: "title", before: baseline.title, after: pageData.title },
142
+ { field: "metaDescription", before: baseline.metaDescription, after: pageData.metaDescription },
143
+ { field: "canonical", before: baseline.canonical, after: pageData.canonical },
144
+ { field: "robots", before: baseline.robots, after: pageData.metaRobots },
145
+ { field: "h1", before: baseline.h1, after: pageData.headings.find((h) => h.level === 1)?.text ?? null },
146
+ {
147
+ field: "h2",
148
+ before: JSON.parse(baseline.h2Json),
149
+ after: pageData.headings.filter((h) => h.level === 2).map((h) => h.text),
150
+ },
151
+ {
152
+ field: "h3",
153
+ before: JSON.parse(baseline.h3Json),
154
+ after: pageData.headings.filter((h) => h.level === 3).map((h) => h.text),
155
+ },
156
+ {
157
+ field: "schema",
158
+ before: JSON.parse(baseline.schemaJson),
159
+ after: pageData.schemaBlocks.map((s) => ({ type: s.type, format: s.format })),
160
+ },
161
+ { field: "statusCode", before: baseline.statusCode, after: fetchResult.statusCode },
162
+ ];
163
+ for (const cmp of comparisons) {
164
+ const beforeStr = JSON.stringify(cmp.before);
165
+ const afterStr = JSON.stringify(cmp.after);
166
+ if (beforeStr !== afterStr) {
167
+ changedFields.push(cmp.field);
168
+ details[cmp.field] = { before: cmp.before, after: cmp.after };
169
+ }
170
+ }
171
+ // HTML hash comparison.
172
+ const currentHtmlHash = hashContent(fetchResult.content);
173
+ if (currentHtmlHash !== baseline.htmlHash) {
174
+ changedFields.push("htmlHash");
175
+ details.htmlHash = { before: baseline.htmlHash, after: currentHtmlHash };
176
+ }
177
+ // Determine severity.
178
+ let severity = "none";
179
+ if (changedFields.includes("title") || changedFields.includes("canonical") || changedFields.includes("statusCode")) {
180
+ severity = "high";
181
+ }
182
+ else if (changedFields.includes("metaDescription") || changedFields.includes("h1") || changedFields.includes("schema")) {
183
+ severity = "medium";
184
+ }
185
+ else if (changedFields.length > 0) {
186
+ severity = "low";
187
+ }
188
+ return {
189
+ url: fetchResult.finalUrl,
190
+ baselineId: baseline.id,
191
+ baselineTimestamp: baseline.timestamp,
192
+ comparedAt: new Date().toISOString(),
193
+ changedFields,
194
+ details,
195
+ severity,
196
+ };
197
+ }
198
+ /** Get baseline history for a URL. */
199
+ export function getBaselineHistory(url, config, limit = 20) {
200
+ const db = getDb(config);
201
+ const hash = urlHash(url);
202
+ const rows = db.prepare("SELECT * FROM baselines WHERE url_hash = ? ORDER BY id DESC LIMIT ?").all(hash, limit);
203
+ return rows.map(rowToBaseline);
204
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Image SEO analyzer.
3
+ *
4
+ * Extracts image data from parsed HTML and flags common SEO issues:
5
+ * missing alt text, missing dimensions, lazy-loading method, and modern
6
+ * format usage.
7
+ */
8
+ import type { ImageAuditResult } from "../types.js";
9
+ export declare function auditImages(html: string, url?: string): ImageAuditResult;
10
+ //# sourceMappingURL=images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/images.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAuB,MAAM,aAAa,CAAC;AAiBzE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAqCxE"}
@@ -0,0 +1,49 @@
1
+ import { parsePage } from "../utils/parser.js";
2
+ const MODERN_FORMATS = [".webp", ".avif", ".jxl"];
3
+ function getExtension(src) {
4
+ try {
5
+ const url = new URL(src);
6
+ const path = url.pathname;
7
+ const dotIdx = path.lastIndexOf(".");
8
+ return dotIdx >= 0 ? path.slice(dotIdx).toLowerCase() : "";
9
+ }
10
+ catch {
11
+ const dotIdx = src.lastIndexOf(".");
12
+ return dotIdx >= 0 ? src.slice(dotIdx).toLowerCase() : "";
13
+ }
14
+ }
15
+ export function auditImages(html, url) {
16
+ const pageData = parsePage(html, url);
17
+ const images = pageData.images;
18
+ let missingAlt = 0;
19
+ let missingDimensions = 0;
20
+ const lazyCounts = { native: 0, dataSrc: 0, dataLazy: 0, none: 0 };
21
+ let modernFormats = 0;
22
+ const oversized = [];
23
+ for (const img of images) {
24
+ if (!img.alt)
25
+ missingAlt++;
26
+ if (!img.width || !img.height)
27
+ missingDimensions++;
28
+ lazyCounts[img.lazyMethod]++;
29
+ const ext = getExtension(img.src);
30
+ if (MODERN_FORMATS.includes(ext))
31
+ modernFormats++;
32
+ // Flag potential oversized images (large numeric dimensions).
33
+ const w = img.width ? Number.parseInt(img.width, 10) : 0;
34
+ const h = img.height ? Number.parseInt(img.height, 10) : 0;
35
+ if (w > 2000 || h > 2000) {
36
+ oversized.push(img.src);
37
+ }
38
+ }
39
+ return {
40
+ url: url ?? pageData.url,
41
+ totalImages: images.length,
42
+ missingAlt,
43
+ missingDimensions,
44
+ lazyLoading: lazyCounts,
45
+ modernFormats,
46
+ oversized,
47
+ images,
48
+ };
49
+ }
@@ -0,0 +1,9 @@
1
+ import type { KeywordAnalysisResult } from "../types.js";
2
+ export declare function analyzeKeywords(text: string, url?: string): KeywordAnalysisResult;
3
+ export declare function analyzeKeywordDensity(text: string, targetKeywords: string[]): Array<{
4
+ keyword: string;
5
+ count: number;
6
+ density: number;
7
+ verdict: string;
8
+ }>;
9
+ //# sourceMappingURL=keywords.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keywords.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/keywords.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAe,MAAM,aAAa,CAAC;AA4GtE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,qBAAqB,CAgEjF;AAeD,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,MAAM,EAAE,GACvB,KAAK,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAkB7E"}
@@ -0,0 +1,183 @@
1
+ const STOP_WORDS = new Set([
2
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
3
+ "of", "with", "by", "from", "up", "about", "into", "through", "during",
4
+ "before", "after", "above", "below", "between", "out", "off", "over",
5
+ "under", "again", "further", "then", "once", "here", "there", "when",
6
+ "where", "why", "how", "all", "both", "each", "few", "more", "most",
7
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
8
+ "so", "than", "too", "very", "s", "t", "can", "will", "just", "don",
9
+ "should", "now", "is", "are", "was", "were", "be", "been", "being",
10
+ "have", "has", "had", "having", "do", "does", "did", "doing", "it",
11
+ "its", "this", "that", "these", "those", "i", "me", "my", "we", "our",
12
+ "you", "your", "he", "she", "they", "them", "their", "what", "which",
13
+ "who", "whom", "would", "could", "may", "might", "shall", "must",
14
+ ]);
15
+ const COMMERCIAL_INTENT_WORDS = new Set([
16
+ "buy", "price", "pricing", "cost", "cheap", "discount", "coupon", "deal",
17
+ "best", "top", "review", "comparison", "vs", "versus", "alternative",
18
+ "sale", "shop", "order", "purchase", "subscription", "plan", "premium",
19
+ "pro", "enterprise", "trial", "demo", "free", "signup", "register",
20
+ ]);
21
+ const TRANSACTIONAL_INTENT_WORDS = new Set([
22
+ "download", "install", "sign up", "register", "subscribe", "join",
23
+ "try", "start", "get started", "book", "reserve", "schedule",
24
+ ]);
25
+ const INFORMATIONAL_INTENT_WORDS = new Set([
26
+ "what is", "how to", "guide", "tutorial", "learn", "definition",
27
+ "meaning", "example", "documentation", "docs", "reference", "api",
28
+ "specification", "language", "framework", "concept",
29
+ ]);
30
+ function tokenize(text) {
31
+ return text
32
+ .toLowerCase()
33
+ .split(/[^a-z0-9]+/)
34
+ .filter((t) => t.length > 1 && !STOP_WORDS.has(t));
35
+ }
36
+ function calculateTF(tokens) {
37
+ const tf = new Map();
38
+ for (const token of tokens) {
39
+ tf.set(token, (tf.get(token) ?? 0) + 1);
40
+ }
41
+ return tf;
42
+ }
43
+ function extractNgrams(tokens, n) {
44
+ const ngrams = new Map();
45
+ for (let i = 0; i <= tokens.length - n; i++) {
46
+ const ngram = tokens.slice(i, i + n).join(" ");
47
+ ngrams.set(ngram, (ngrams.get(ngram) ?? 0) + 1);
48
+ }
49
+ return ngrams;
50
+ }
51
+ function calculateKeywordDensity(tf, totalTokens) {
52
+ const density = new Map();
53
+ for (const [word, count] of tf) {
54
+ density.set(word, Number(((count / totalTokens) * 100).toFixed(2)));
55
+ }
56
+ return density;
57
+ }
58
+ function classifyIntent(text) {
59
+ const lower = text.toLowerCase();
60
+ let informational = 0;
61
+ let commercial = 0;
62
+ let transactional = 0;
63
+ for (const word of INFORMATIONAL_INTENT_WORDS) {
64
+ const regex = new RegExp(`\\b${word.replace(/\s+/g, "\\s+")}\\b`, "gi");
65
+ const matches = lower.match(regex);
66
+ if (matches)
67
+ informational += matches.length;
68
+ }
69
+ for (const word of COMMERCIAL_INTENT_WORDS) {
70
+ const regex = new RegExp(`\\b${word.replace(/\s+/g, "\\s+")}\\b`, "gi");
71
+ const matches = lower.match(regex);
72
+ if (matches)
73
+ commercial += matches.length;
74
+ }
75
+ for (const word of TRANSACTIONAL_INTENT_WORDS) {
76
+ const regex = new RegExp(`\\b${word.replace(/\s+/g, "\\s+")}\\b`, "gi");
77
+ const matches = lower.match(regex);
78
+ if (matches)
79
+ transactional += matches.length;
80
+ }
81
+ const total = informational + commercial + transactional;
82
+ if (total === 0)
83
+ return { intent: "informational", confidence: 0.3 };
84
+ const infPct = informational / total;
85
+ const comPct = commercial / total;
86
+ const transPct = transactional / total;
87
+ if (infPct > 0.5)
88
+ return { intent: "informational", confidence: infPct };
89
+ if (transPct > 0.4)
90
+ return { intent: "transactional", confidence: transPct };
91
+ if (comPct > 0.4)
92
+ return { intent: "commercial", confidence: comPct };
93
+ return { intent: "mixed", confidence: Math.max(infPct, comPct, transPct) };
94
+ }
95
+ export function analyzeKeywords(text, url) {
96
+ const tokens = tokenize(text);
97
+ const totalWords = text.split(/\s+/).filter(Boolean).length;
98
+ const tf = calculateTF(tokens);
99
+ const density = calculateKeywordDensity(tf, tokens.length);
100
+ // TF-IDF approximation (single-document IDF based on distribution)
101
+ const maxCount = Math.max(1, ...tf.values());
102
+ const keywords = [];
103
+ const seen = new Set();
104
+ for (const [word, count] of tf) {
105
+ if (seen.has(word))
106
+ continue;
107
+ seen.add(word);
108
+ // TF-IDF: normalize count and apply log-weight for rarity
109
+ const tfidf = (count / maxCount) * Math.log(tokens.length / count);
110
+ const variants = findVariants(word, tokens);
111
+ if (count >= 2 || variants.length > 1) {
112
+ keywords.push({
113
+ keyword: word,
114
+ count,
115
+ density: density.get(word) ?? 0,
116
+ tfidf: Number(tfidf.toFixed(3)),
117
+ variants: variants.filter((v) => v !== word).slice(0, 5),
118
+ });
119
+ }
120
+ }
121
+ // Sort by TF-IDF score
122
+ keywords.sort((a, b) => b.tfidf - a.tfidf);
123
+ // Extract bigrams and trigrams
124
+ const bigrams = extractNgrams(tokens, 2);
125
+ const trigrams = extractNgrams(tokens, 3);
126
+ const topNgrams = [];
127
+ for (const [ngram, count] of [...bigrams, ...trigrams]) {
128
+ if (count >= 2)
129
+ topNgrams.push({ ngram, count });
130
+ }
131
+ topNgrams.sort((a, b) => b.count - a.count);
132
+ // Intent classification
133
+ const { intent, confidence } = classifyIntent(text);
134
+ // Keyword stuffing detection
135
+ const keywordStuffingFlags = [];
136
+ for (const kw of keywords) {
137
+ if (kw.density > 3.0) {
138
+ keywordStuffingFlags.push(`"${kw.keyword}" at ${kw.density}% density — exceeds 3% threshold (possible keyword stuffing)`);
139
+ }
140
+ }
141
+ return {
142
+ url: url ?? "",
143
+ totalWords,
144
+ keywords: keywords.slice(0, 30),
145
+ topNgrams: topNgrams.slice(0, 15),
146
+ searchIntent: intent,
147
+ intentConfidence: confidence,
148
+ keywordStuffingFlags,
149
+ };
150
+ }
151
+ function findVariants(baseWord, tokens) {
152
+ const variants = new Set();
153
+ const lower = baseWord.toLowerCase();
154
+ for (const token of tokens) {
155
+ if (token === lower)
156
+ continue;
157
+ // Simple stemming: check if token starts with or contains the base word
158
+ if (token.startsWith(lower) || lower.startsWith(token)) {
159
+ variants.add(token);
160
+ }
161
+ }
162
+ return [...variants];
163
+ }
164
+ export function analyzeKeywordDensity(text, targetKeywords) {
165
+ const tokens = tokenize(text);
166
+ const lower = text.toLowerCase();
167
+ return targetKeywords.map((kw) => {
168
+ const regex = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi");
169
+ const matches = lower.match(regex);
170
+ const count = matches ? matches.length : 0;
171
+ const density = tokens.length > 0 ? Number(((count / tokens.length) * 100).toFixed(2)) : 0;
172
+ let verdict;
173
+ if (density < 0.5)
174
+ verdict = "too low — consider adding more mentions";
175
+ else if (density > 3.0)
176
+ verdict = "too high — possible keyword stuffing";
177
+ else if (density > 1.5)
178
+ verdict = "optimal";
179
+ else
180
+ verdict = "acceptable but could be improved";
181
+ return { keyword: kw, count, density, verdict };
182
+ });
183
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Parasite-SEO risk scanner.
3
+ *
4
+ * Ported from the Python `parasite_risk.py` in claude-seo. Scans a set of
5
+ * URLs from one host, classifies content by subfolder, and flags section-level
6
+ * parasite-SEO risk via third-party byline patterns, commercial-intent
7
+ * patterns, and affiliate link markers.
8
+ */
9
+ import type { ParasiteRiskResult } from "../types.js";
10
+ import type { SeoConfig } from "../config.js";
11
+ /**
12
+ * Scan a list of URLs from one host for parasite-SEO risk.
13
+ *
14
+ * Each URL is fetched and audited individually. Results are grouped by
15
+ * subfolder (first path segment) and scored.
16
+ */
17
+ export declare function scanParasiteRisk(urls: string[], config: SeoConfig): Promise<ParasiteRiskResult>;
18
+ //# sourceMappingURL=parasite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parasite.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/parasite.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAqB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAyE9C;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,kBAAkB,CAAC,CAuE7B"}
@@ -0,0 +1,142 @@
1
+ import { fetchPage } from "../utils/fetcher.js";
2
+ import { parsePage } from "../utils/parser.js";
3
+ const THIRD_PARTY_BYLINE_PATTERNS = [
4
+ /\bPartner\s+Content\b/gi,
5
+ /\bSponsored\s+Content\b/gi,
6
+ /\bSponsored\s+by\b/gi,
7
+ /\bBrand\s+Studio\b/gi,
8
+ /\bIn\s+Partnership\s+With\b/gi,
9
+ /\bAdvertisement\b/gi,
10
+ /\bAdvertorial\b/gi,
11
+ /\bPaid\s+Post\b/gi,
12
+ /\bPromoted\b/gi,
13
+ /\bPaid\s+Content\b/gi,
14
+ ];
15
+ const COMMERCE_PATTERNS = [
16
+ /\bBuy\s+Now\b/gi,
17
+ /\bShop\s+Now\b/gi,
18
+ /\bAdd\s+to\s+Cart\b/gi,
19
+ /\bCompare\s+Prices\b/gi,
20
+ /\bBest\s+\w+\s+Deals?\b/gi,
21
+ /\bPromo\s+Code\b/gi,
22
+ /\bCoupon\b/gi,
23
+ /\bDiscount\s+Code\b/gi,
24
+ /\bAffiliate\s+Disclosure\b/gi,
25
+ ];
26
+ const AFFILIATE_LINK_RE = /\b(?:tag=|aff_id=|affid=|partnerid=|ref_=|utm_source=|utm_campaign=)/gi;
27
+ function getSubfolder(url) {
28
+ try {
29
+ const parsed = new URL(url);
30
+ const path = parsed.pathname || "/";
31
+ const parts = path.split("/").filter(Boolean);
32
+ return parts.length > 0 ? `/${parts[0]}/` : "/";
33
+ }
34
+ catch {
35
+ return "/";
36
+ }
37
+ }
38
+ function auditPageHtml(html, url) {
39
+ let thirdPartyHits = 0;
40
+ let commerceHits = 0;
41
+ for (const pattern of THIRD_PARTY_BYLINE_PATTERNS) {
42
+ const matches = html.match(pattern);
43
+ if (matches)
44
+ thirdPartyHits += matches.length;
45
+ }
46
+ for (const pattern of COMMERCE_PATTERNS) {
47
+ const matches = html.match(pattern);
48
+ if (matches)
49
+ commerceHits += matches.length;
50
+ }
51
+ // Count affiliate links from parsed links.
52
+ const pageData = parsePage(html, url);
53
+ let affiliateLinkHits = 0;
54
+ for (const link of pageData.links) {
55
+ if (AFFILIATE_LINK_RE.test(link.href)) {
56
+ affiliateLinkHits++;
57
+ }
58
+ }
59
+ return {
60
+ url,
61
+ thirdPartyHits,
62
+ commerceHits,
63
+ affiliateLinkHits,
64
+ };
65
+ }
66
+ /**
67
+ * Scan a list of URLs from one host for parasite-SEO risk.
68
+ *
69
+ * Each URL is fetched and audited individually. Results are grouped by
70
+ * subfolder (first path segment) and scored.
71
+ */
72
+ export async function scanParasiteRisk(urls, config) {
73
+ // Determine host from first URL.
74
+ let host = "";
75
+ try {
76
+ host = new URL(urls[0]).hostname;
77
+ }
78
+ catch {
79
+ host = "unknown";
80
+ }
81
+ const audits = [];
82
+ for (const url of urls) {
83
+ try {
84
+ const result = await fetchPage(url, config);
85
+ if (result.statusCode === 200 && result.content) {
86
+ audits.push(auditPageHtml(result.content, result.finalUrl));
87
+ }
88
+ else {
89
+ audits.push({ url, thirdPartyHits: 0, commerceHits: 0, affiliateLinkHits: 0 });
90
+ }
91
+ }
92
+ catch {
93
+ audits.push({ url, thirdPartyHits: 0, commerceHits: 0, affiliateLinkHits: 0 });
94
+ }
95
+ }
96
+ // Group by subfolder.
97
+ const bySection = new Map();
98
+ for (const audit of audits) {
99
+ const section = getSubfolder(audit.url);
100
+ if (!bySection.has(section)) {
101
+ bySection.set(section, []);
102
+ }
103
+ bySection.get(section).push(audit);
104
+ }
105
+ const sections = [...bySection.entries()].map(([section, pages]) => {
106
+ const n = pages.length;
107
+ const thirdPartyRate = pages.reduce((sum, p) => sum + p.thirdPartyHits, 0) / n;
108
+ const commerceRate = pages.reduce((sum, p) => sum + p.commerceHits, 0) / n;
109
+ const affiliateRate = pages.reduce((sum, p) => sum + p.affiliateLinkHits, 0) / n;
110
+ const flags = [];
111
+ let risk = "low";
112
+ if (thirdPartyRate >= 1.0)
113
+ flags.push("third-party-authorship-density");
114
+ if (commerceRate >= 2.0)
115
+ flags.push("commercial-intent-skew");
116
+ if (affiliateRate >= 3.0)
117
+ flags.push("affiliate-density");
118
+ if (flags.includes("third-party-authorship-density")) {
119
+ risk = "high";
120
+ }
121
+ else if (flags.includes("commercial-intent-skew") && flags.includes("affiliate-density")) {
122
+ risk = "high";
123
+ }
124
+ else if (flags.length > 0) {
125
+ risk = "medium";
126
+ }
127
+ return {
128
+ section,
129
+ pageCount: n,
130
+ thirdPartyHitsPerPage: Math.round(thirdPartyRate * 100) / 100,
131
+ commerceHitsPerPage: Math.round(commerceRate * 100) / 100,
132
+ affiliateLinkHitsPerPage: Math.round(affiliateRate * 100) / 100,
133
+ flags,
134
+ risk,
135
+ };
136
+ });
137
+ return {
138
+ host,
139
+ totalPages: urls.length,
140
+ sections,
141
+ };
142
+ }
@@ -0,0 +1,6 @@
1
+ import type { PerformanceResult, PageWeightResult, RenderBlockingResult, FontAuditResult, FetchResult } from "../types.js";
2
+ export declare function calculatePerformanceFromFetch(fetchResult: FetchResult, url: string): PerformanceResult;
3
+ export declare function analyzePageWeight(fetchResult: FetchResult, url: string): PageWeightResult;
4
+ export declare function analyzeRenderBlocking(fetchResult: FetchResult, url: string): RenderBlockingResult;
5
+ export declare function analyzeFonts(fetchResult: FetchResult, url: string): FontAuditResult;
6
+ //# sourceMappingURL=performance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"performance.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/performance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS3H,wBAAgB,6BAA6B,CAC3C,WAAW,EAAE,WAAW,EACxB,GAAG,EAAE,MAAM,GACV,iBAAiB,CA0EnB;AAiCD,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,CA6CzF;AAED,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,oBAAoB,CAgDjG;AAED,wBAAgB,YAAY,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,CAyEnF"}