@aexol/spectral 0.9.41 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-helper.d.ts.map +1 -1
- package/dist/auth-helper.js +49 -11
- package/dist/extensions/aexol-mcp.d.ts.map +1 -1
- package/dist/extensions/aexol-mcp.js +44 -9
- package/dist/extensions/browser/browser-service.d.ts +4 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +34 -7
- package/dist/extensions/browser/config.d.ts +3 -0
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +1 -0
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +3 -0
- package/dist/extensions/browser/record/record-tool.d.ts.map +1 -1
- package/dist/extensions/browser/record/record-tool.js +27 -4
- package/dist/extensions/browser/scenario/markdown-parser.d.ts +44 -0
- package/dist/extensions/browser/scenario/markdown-parser.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/markdown-parser.js +159 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts +4 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/scenario-tool.js +381 -0
- package/dist/extensions/browser/tools/core.js +1 -1
- package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
- package/dist/extensions/browser/tools/screenshot.js +2 -1
- package/dist/extensions/seo/analyzers/agent-ux.d.ts +5 -0
- package/dist/extensions/seo/analyzers/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/agent-ux.js +95 -0
- package/dist/extensions/seo/analyzers/content.d.ts +16 -0
- package/dist/extensions/seo/analyzers/content.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/content.js +174 -0
- package/dist/extensions/seo/analyzers/drift.d.ts +13 -0
- package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/drift.js +204 -0
- package/dist/extensions/seo/analyzers/images.d.ts +10 -0
- package/dist/extensions/seo/analyzers/images.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/images.js +49 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts +18 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/parasite.js +142 -0
- package/dist/extensions/seo/analyzers/preload.d.ts +12 -0
- package/dist/extensions/seo/analyzers/preload.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/preload.js +108 -0
- package/dist/extensions/seo/analyzers/schema.d.ts +21 -0
- package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/schema.js +324 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts +20 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/sitemap.js +121 -0
- package/dist/extensions/seo/analyzers/technical.d.ts +11 -0
- package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/technical.js +268 -0
- package/dist/extensions/seo/config.d.ts +24 -0
- package/dist/extensions/seo/config.d.ts.map +1 -0
- package/dist/extensions/seo/config.js +48 -0
- package/dist/extensions/seo/index.d.ts +16 -0
- package/dist/extensions/seo/index.d.ts.map +1 -0
- package/dist/extensions/seo/index.js +88 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts +7 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/tools/agent-ux.js +63 -0
- package/dist/extensions/seo/tools/content.d.ts +7 -0
- package/dist/extensions/seo/tools/content.d.ts.map +1 -0
- package/dist/extensions/seo/tools/content.js +55 -0
- package/dist/extensions/seo/tools/drift.d.ts +7 -0
- package/dist/extensions/seo/tools/drift.d.ts.map +1 -0
- package/dist/extensions/seo/tools/drift.js +104 -0
- package/dist/extensions/seo/tools/fetch.d.ts +7 -0
- package/dist/extensions/seo/tools/fetch.d.ts.map +1 -0
- package/dist/extensions/seo/tools/fetch.js +123 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +10 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -0
- package/dist/extensions/seo/tools/full-audit.js +191 -0
- package/dist/extensions/seo/tools/helpers.d.ts +13 -0
- package/dist/extensions/seo/tools/helpers.d.ts.map +1 -0
- package/dist/extensions/seo/tools/helpers.js +24 -0
- package/dist/extensions/seo/tools/images.d.ts +7 -0
- package/dist/extensions/seo/tools/images.d.ts.map +1 -0
- package/dist/extensions/seo/tools/images.js +46 -0
- package/dist/extensions/seo/tools/parasite.d.ts +7 -0
- package/dist/extensions/seo/tools/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parasite.js +53 -0
- package/dist/extensions/seo/tools/parse.d.ts +7 -0
- package/dist/extensions/seo/tools/parse.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parse.js +66 -0
- package/dist/extensions/seo/tools/preload.d.ts +7 -0
- package/dist/extensions/seo/tools/preload.d.ts.map +1 -0
- package/dist/extensions/seo/tools/preload.js +52 -0
- package/dist/extensions/seo/tools/report.d.ts +4 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -0
- package/dist/extensions/seo/tools/report.js +167 -0
- package/dist/extensions/seo/tools/schema.d.ts +7 -0
- package/dist/extensions/seo/tools/schema.d.ts.map +1 -0
- package/dist/extensions/seo/tools/schema.js +105 -0
- package/dist/extensions/seo/tools/sitemap.d.ts +7 -0
- package/dist/extensions/seo/tools/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/tools/sitemap.js +85 -0
- package/dist/extensions/seo/tools/technical.d.ts +7 -0
- package/dist/extensions/seo/tools/technical.d.ts.map +1 -0
- package/dist/extensions/seo/tools/technical.js +62 -0
- package/dist/extensions/seo/types.d.ts +289 -0
- package/dist/extensions/seo/types.d.ts.map +1 -0
- package/dist/extensions/seo/types.js +7 -0
- package/dist/extensions/seo/utils/fetcher.d.ts +28 -0
- package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -0
- package/dist/extensions/seo/utils/fetcher.js +118 -0
- package/dist/extensions/seo/utils/parser.d.ts +8 -0
- package/dist/extensions/seo/utils/parser.d.ts.map +1 -0
- package/dist/extensions/seo/utils/parser.js +265 -0
- package/dist/extensions/seo/utils/renderer.d.ts +39 -0
- package/dist/extensions/seo/utils/renderer.d.ts.map +1 -0
- package/dist/extensions/seo/utils/renderer.js +190 -0
- package/dist/extensions/seo/utils/url-safety.d.ts +31 -0
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -0
- package/dist/extensions/seo/utils/url-safety.js +200 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
- package/package.json +2 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Common filler phrases that add no information value. */
|
|
2
|
+
const FILLER_PHRASES = [
|
|
3
|
+
"in today's fast-paced world",
|
|
4
|
+
"in the ever-evolving",
|
|
5
|
+
"it's important to note that",
|
|
6
|
+
"it's worth noting that",
|
|
7
|
+
"it goes without saying",
|
|
8
|
+
"needless to say",
|
|
9
|
+
"at the end of the day",
|
|
10
|
+
"when it comes to",
|
|
11
|
+
"in conclusion",
|
|
12
|
+
"in summary",
|
|
13
|
+
"as previously mentioned",
|
|
14
|
+
"as we all know",
|
|
15
|
+
"generally speaking",
|
|
16
|
+
"for the most part",
|
|
17
|
+
"in most cases",
|
|
18
|
+
"more often than not",
|
|
19
|
+
"it should be noted",
|
|
20
|
+
"keep in mind that",
|
|
21
|
+
"with that in mind",
|
|
22
|
+
"on the other hand",
|
|
23
|
+
"let's dive in",
|
|
24
|
+
"let's get started",
|
|
25
|
+
"without further ado",
|
|
26
|
+
"the bottom line is",
|
|
27
|
+
"you might be wondering",
|
|
28
|
+
"you're probably wondering",
|
|
29
|
+
"let's face it",
|
|
30
|
+
"here's the thing",
|
|
31
|
+
"long story short",
|
|
32
|
+
"make no mistake",
|
|
33
|
+
"the fact of the matter is",
|
|
34
|
+
];
|
|
35
|
+
/** Patterns characteristic of AI-generated text. */
|
|
36
|
+
const AI_PATTERNS = [
|
|
37
|
+
"delve into",
|
|
38
|
+
"navigating the",
|
|
39
|
+
"in the realm of",
|
|
40
|
+
"unleash the power of",
|
|
41
|
+
"harness the power of",
|
|
42
|
+
"a testament to",
|
|
43
|
+
"a game-changer",
|
|
44
|
+
"paradigm shift",
|
|
45
|
+
"cutting-edge",
|
|
46
|
+
"revolutionary",
|
|
47
|
+
"groundbreaking",
|
|
48
|
+
"state-of-the-art",
|
|
49
|
+
"seamless integration",
|
|
50
|
+
"robust solution",
|
|
51
|
+
"comprehensive guide",
|
|
52
|
+
"ultimate guide",
|
|
53
|
+
"definitive guide",
|
|
54
|
+
"everything you need to know",
|
|
55
|
+
"you're not alone",
|
|
56
|
+
"picture this",
|
|
57
|
+
"imagine a world",
|
|
58
|
+
"fast-paced digital landscape",
|
|
59
|
+
"ever-changing landscape",
|
|
60
|
+
"in the world of",
|
|
61
|
+
"plays a crucial role",
|
|
62
|
+
"plays a vital role",
|
|
63
|
+
"plays a pivotal role",
|
|
64
|
+
"it's not just about",
|
|
65
|
+
"it's not just a",
|
|
66
|
+
"tapestry of",
|
|
67
|
+
"myriad of",
|
|
68
|
+
"plethora of",
|
|
69
|
+
"embark on a journey",
|
|
70
|
+
"unlock the potential",
|
|
71
|
+
"elevate your",
|
|
72
|
+
"transform your",
|
|
73
|
+
"redefine the way",
|
|
74
|
+
];
|
|
75
|
+
function tokenize(text) {
|
|
76
|
+
return text
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.split(/[^a-z0-9]+/)
|
|
79
|
+
.filter((t) => t.length > 0);
|
|
80
|
+
}
|
|
81
|
+
function countPatternMatches(text, patterns) {
|
|
82
|
+
const lower = text.toLowerCase();
|
|
83
|
+
let count = 0;
|
|
84
|
+
const matches = [];
|
|
85
|
+
for (const pattern of patterns) {
|
|
86
|
+
const regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
87
|
+
const found = lower.match(regex);
|
|
88
|
+
if (found) {
|
|
89
|
+
count += found.length;
|
|
90
|
+
matches.push(...found);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { count, matches };
|
|
94
|
+
}
|
|
95
|
+
function calculateRepetition(tokens) {
|
|
96
|
+
if (tokens.length === 0)
|
|
97
|
+
return 0;
|
|
98
|
+
// Count trigram repetitions.
|
|
99
|
+
const trigrams = new Map();
|
|
100
|
+
for (let i = 0; i < tokens.length - 2; i++) {
|
|
101
|
+
const trigram = `${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`;
|
|
102
|
+
trigrams.set(trigram, (trigrams.get(trigram) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
let repeated = 0;
|
|
105
|
+
for (const count of trigrams.values()) {
|
|
106
|
+
if (count > 1)
|
|
107
|
+
repeated += count - 1;
|
|
108
|
+
}
|
|
109
|
+
const maxRepetition = trigrams.size > 0 ? repeated / trigrams.size : 0;
|
|
110
|
+
return Math.min(100, Math.round(maxRepetition * 100));
|
|
111
|
+
}
|
|
112
|
+
function calculateInformationDensity(text, tokens) {
|
|
113
|
+
if (tokens.length === 0)
|
|
114
|
+
return 0;
|
|
115
|
+
// Count entities (capitalized words) and numbers.
|
|
116
|
+
const entities = (text.match(/\b[A-Z][a-z]+\b/g) ?? []).length;
|
|
117
|
+
const numbers = (text.match(/\b\d+([.,]\d+)?\b/g) ?? []).length;
|
|
118
|
+
const informative = entities + numbers;
|
|
119
|
+
// Density = informative tokens / total tokens, scaled 0-100.
|
|
120
|
+
return Math.min(100, Math.round((informative / tokens.length) * 1000));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Analyze content quality of a text string.
|
|
124
|
+
*
|
|
125
|
+
* All scores are 0-100 where higher is better (except filler and AI pattern
|
|
126
|
+
* scores, where higher means MORE problematic content).
|
|
127
|
+
*/
|
|
128
|
+
export function analyzeContentQuality(text, url) {
|
|
129
|
+
const tokens = tokenize(text);
|
|
130
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
131
|
+
const uniqueTokens = new Set(tokens).size;
|
|
132
|
+
// Filler phrase detection.
|
|
133
|
+
const filler = countPatternMatches(text, FILLER_PHRASES);
|
|
134
|
+
const fillerScore = Math.min(100, Math.round((filler.count / Math.max(1, wordCount / 100)) * 20));
|
|
135
|
+
// AI pattern detection.
|
|
136
|
+
const aiPatterns = countPatternMatches(text, AI_PATTERNS);
|
|
137
|
+
const aiPatternScore = Math.min(100, Math.round((aiPatterns.count / Math.max(1, wordCount / 100)) * 20));
|
|
138
|
+
// Information density (higher is better).
|
|
139
|
+
const informationDensity = calculateInformationDensity(text, tokens);
|
|
140
|
+
// Repetition score (higher means MORE repetition, worse).
|
|
141
|
+
const repetitionScore = calculateRepetition(tokens);
|
|
142
|
+
// Overall quality: start at 100, subtract penalties.
|
|
143
|
+
let overall = 100;
|
|
144
|
+
overall -= fillerScore * 0.3;
|
|
145
|
+
overall -= aiPatternScore * 0.3;
|
|
146
|
+
overall -= repetitionScore * 0.2;
|
|
147
|
+
overall -= Math.max(0, 30 - informationDensity) * 0.2; // penalize low density
|
|
148
|
+
overall = Math.max(0, Math.min(100, Math.round(overall)));
|
|
149
|
+
const flags = [];
|
|
150
|
+
if (fillerScore > 30)
|
|
151
|
+
flags.push("high-filler-density");
|
|
152
|
+
if (aiPatternScore > 30)
|
|
153
|
+
flags.push("likely-ai-generated");
|
|
154
|
+
if (repetitionScore > 40)
|
|
155
|
+
flags.push("excessive-repetition");
|
|
156
|
+
if (informationDensity < 10)
|
|
157
|
+
flags.push("low-information-density");
|
|
158
|
+
if (wordCount < 300)
|
|
159
|
+
flags.push("thin-content");
|
|
160
|
+
return {
|
|
161
|
+
url,
|
|
162
|
+
wordCount,
|
|
163
|
+
tokens: tokens.length,
|
|
164
|
+
uniqueTokens,
|
|
165
|
+
fillerScore,
|
|
166
|
+
aiPatternScore,
|
|
167
|
+
informationDensity,
|
|
168
|
+
repetitionScore,
|
|
169
|
+
overallQuality: overall,
|
|
170
|
+
flags,
|
|
171
|
+
fillerMatches: filler.matches,
|
|
172
|
+
aiPatternMatches: aiPatterns.matches,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -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,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,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preload / Speculation Rules / bfcache analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `preload_check.py` in claude-seo. Audits static HTML
|
|
5
|
+
* and response headers for the four preload mechanisms Google rewards in
|
|
6
|
+
* 2025-2026: speculation rules, preload hints, deprecated prerender, and
|
|
7
|
+
* bfcache signals.
|
|
8
|
+
*/
|
|
9
|
+
import type { PreloadAuditResult } from "../types.js";
|
|
10
|
+
import type { FetchResult } from "../types.js";
|
|
11
|
+
export declare function auditPreload(fetchResult: FetchResult): PreloadAuditResult;
|
|
12
|
+
//# sourceMappingURL=preload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/preload.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAsB/C,wBAAgB,YAAY,CAAC,WAAW,EAAE,WAAW,GAAG,kBAAkB,CAsGzE"}
|