@aexol/spectral 0.9.42 → 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/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 +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation and SSRF protection.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `url_safety.py` in claude-seo. Blocks requests to
|
|
5
|
+
* private/internal IP ranges to prevent server-side request forgery.
|
|
6
|
+
*/
|
|
7
|
+
import { lookup } from "node:dns/promises";
|
|
8
|
+
import { isIP } from "node:net";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
export class UrlSafetyError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "UrlSafetyError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Private/loopback IPv4 CIDR prefixes. */
|
|
17
|
+
const PRIVATE_IPV4_PREFIXES = [
|
|
18
|
+
"10.",
|
|
19
|
+
"172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
|
20
|
+
"192.168.",
|
|
21
|
+
"169.254.",
|
|
22
|
+
"127.",
|
|
23
|
+
"0.",
|
|
24
|
+
"100.64.", "100.65.", "100.66.", "100.67.", "100.68.", "100.69.", "100.70.", "100.71.", "100.72.", "100.73.", "100.74.", "100.75.", "100.76.", "100.77.", "100.78.", "100.79.", "100.80.", "100.81.", "100.82.", "100.83.", "100.84.", "100.85.", "100.86.", "100.87.", "100.88.", "100.89.", "100.90.", "100.91.", "100.92.", "100.93.", "100.94.", "100.95.", "100.96.", "100.97.", "100.98.", "100.99.", "100.100.", "100.101.", "100.102.", "100.103.", "100.104.", "100.105.", "100.106.", "100.107.", "100.108.", "100.109.", "100.110.", "100.111.", "100.112.", "100.113.", "100.114.", "100.115.", "100.116.", "100.117.", "100.118.", "100.119.", "100.120.", "100.121.", "100.122.", "100.123.", "100.124.", "100.125.", "100.126.", "100.127.",
|
|
25
|
+
];
|
|
26
|
+
/** Blocked hostnames that resolve to metadata endpoints or local services. */
|
|
27
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
28
|
+
"localhost",
|
|
29
|
+
"metadata.google.internal",
|
|
30
|
+
"metadata",
|
|
31
|
+
"169.254.169.254",
|
|
32
|
+
"fd00.local",
|
|
33
|
+
"metadata.aws.internal",
|
|
34
|
+
]);
|
|
35
|
+
function isPrivateIPv4(ip) {
|
|
36
|
+
for (const prefix of PRIVATE_IPV4_PREFIXES) {
|
|
37
|
+
if (ip.startsWith(prefix))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function isPrivateIPv6(ip) {
|
|
43
|
+
const lower = ip.toLowerCase();
|
|
44
|
+
return (lower === "::1" ||
|
|
45
|
+
lower.startsWith("fe80:") ||
|
|
46
|
+
lower.startsWith("fc00:") ||
|
|
47
|
+
lower.startsWith("fd00:") ||
|
|
48
|
+
lower.startsWith("fd01:") ||
|
|
49
|
+
lower.startsWith("::ffff:") && isPrivateIPv4(lower.replace("::ffff:", "")));
|
|
50
|
+
}
|
|
51
|
+
function isPrivateIp(ip) {
|
|
52
|
+
const family = isIP(ip);
|
|
53
|
+
if (family === 4)
|
|
54
|
+
return isPrivateIPv4(ip);
|
|
55
|
+
if (family === 6)
|
|
56
|
+
return isPrivateIPv6(ip);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validate a URL string with optional SSRF protection.
|
|
61
|
+
*
|
|
62
|
+
* Throws UrlSafetyError if the URL is invalid, uses a non-http(s) protocol,
|
|
63
|
+
* points to a blocked hostname, or (when ssrfProtection=true) resolves to a
|
|
64
|
+
* private IP address.
|
|
65
|
+
*/
|
|
66
|
+
export async function validateUrl(urlStr, opts = {}) {
|
|
67
|
+
const ssrfProtection = opts.ssrfProtection ?? true;
|
|
68
|
+
const resolveDns = opts.resolveDns ?? ssrfProtection;
|
|
69
|
+
if (!urlStr || typeof urlStr !== "string") {
|
|
70
|
+
throw new UrlSafetyError("URL is required.");
|
|
71
|
+
}
|
|
72
|
+
// Reject file:// and other local protocol schemes.
|
|
73
|
+
const trimmed = urlStr.trim();
|
|
74
|
+
if (/^(file|ftp|gopher|dict|ldap|data|blob):/i.test(trimmed)) {
|
|
75
|
+
throw new UrlSafetyError(`Protocol not allowed for SEO fetch: ${trimmed}`);
|
|
76
|
+
}
|
|
77
|
+
// Auto-prepend https:// if missing a protocol.
|
|
78
|
+
let normalized = trimmed;
|
|
79
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
80
|
+
normalized = `https://${normalized}`;
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(normalized);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new UrlSafetyError(`Invalid URL: ${urlStr}`);
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
90
|
+
throw new UrlSafetyError(`Only http and https protocols are allowed, got: ${parsed.protocol}`);
|
|
91
|
+
}
|
|
92
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
93
|
+
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
|
94
|
+
throw new UrlSafetyError(`Blocked hostname: ${hostname}`);
|
|
95
|
+
}
|
|
96
|
+
// Check for direct IP literals.
|
|
97
|
+
const ipFamily = isIP(hostname.replace(/^\[|\]$/g, ""));
|
|
98
|
+
if (ipFamily && isPrivateIp(hostname.replace(/^\[|\]$/g, ""))) {
|
|
99
|
+
throw new UrlSafetyError(`URL points to a private IP address: ${hostname}`);
|
|
100
|
+
}
|
|
101
|
+
let ips = [];
|
|
102
|
+
if (resolveDns && !ipFamily) {
|
|
103
|
+
try {
|
|
104
|
+
const records = await lookup(hostname, { all: true });
|
|
105
|
+
ips = records.map((r) => r.address);
|
|
106
|
+
if (ssrfProtection) {
|
|
107
|
+
for (const ip of ips) {
|
|
108
|
+
if (isPrivateIp(ip)) {
|
|
109
|
+
throw new UrlSafetyError(`Hostname ${hostname} resolves to private IP ${ip}. Blocked by SSRF protection.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof UrlSafetyError)
|
|
116
|
+
throw err;
|
|
117
|
+
// DNS resolution failure is not necessarily an error for SSRF — the fetch
|
|
118
|
+
// will fail naturally. But we surface it if the caller wanted DNS.
|
|
119
|
+
if (resolveDns && !ssrfProtection) {
|
|
120
|
+
// non-fatal: continue without IPs
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
href: parsed.href,
|
|
126
|
+
protocol: parsed.protocol,
|
|
127
|
+
hostname: parsed.hostname,
|
|
128
|
+
port: parsed.port,
|
|
129
|
+
pathname: parsed.pathname,
|
|
130
|
+
search: parsed.search,
|
|
131
|
+
ips,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Quick synchronous URL validation without DNS resolution. */
|
|
135
|
+
export function validateUrlBasic(urlStr) {
|
|
136
|
+
if (!urlStr || typeof urlStr !== "string") {
|
|
137
|
+
throw new UrlSafetyError("URL is required.");
|
|
138
|
+
}
|
|
139
|
+
const trimmed = urlStr.trim();
|
|
140
|
+
if (/^(file|ftp|gopher|dict|ldap|data|blob):/i.test(trimmed)) {
|
|
141
|
+
throw new UrlSafetyError(`Protocol not allowed: ${trimmed}`);
|
|
142
|
+
}
|
|
143
|
+
let normalized = trimmed;
|
|
144
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
145
|
+
normalized = `https://${normalized}`;
|
|
146
|
+
}
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = new URL(normalized);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new UrlSafetyError(`Invalid URL: ${urlStr}`);
|
|
153
|
+
}
|
|
154
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
155
|
+
throw new UrlSafetyError(`Only http/https allowed: ${parsed.protocol}`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
href: parsed.href,
|
|
159
|
+
protocol: parsed.protocol,
|
|
160
|
+
hostname: parsed.hostname,
|
|
161
|
+
port: parsed.port,
|
|
162
|
+
pathname: parsed.pathname,
|
|
163
|
+
search: parsed.search,
|
|
164
|
+
ips: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Normalize a URL for baseline matching (strip UTM, sort params, lowercase host). */
|
|
168
|
+
export function normalizeUrl(url) {
|
|
169
|
+
const UTM_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]);
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = new URL(url);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return url;
|
|
176
|
+
}
|
|
177
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
178
|
+
// Strip default ports.
|
|
179
|
+
if ((parsed.protocol === "http:" && parsed.port === "80") || (parsed.protocol === "https:" && parsed.port === "443")) {
|
|
180
|
+
parsed.port = "";
|
|
181
|
+
}
|
|
182
|
+
// Remove UTM params.
|
|
183
|
+
const params = new URLSearchParams(parsed.search);
|
|
184
|
+
for (const key of [...params.keys()]) {
|
|
185
|
+
if (UTM_PARAMS.has(key.toLowerCase())) {
|
|
186
|
+
params.delete(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
params.sort();
|
|
190
|
+
parsed.search = params.toString();
|
|
191
|
+
// Strip trailing slash (except bare domain).
|
|
192
|
+
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
|
|
193
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
194
|
+
}
|
|
195
|
+
return parsed.href;
|
|
196
|
+
}
|
|
197
|
+
/** Hash a normalized URL for baseline deduplication. */
|
|
198
|
+
export function urlHash(url) {
|
|
199
|
+
return createHash("sha256").update(normalizeUrl(url)).digest("hex").slice(0, 16);
|
|
200
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,
|
|
1
|
+
{"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,EA8EtD,CAAC;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAIjF;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC,CASD"}
|
|
@@ -77,6 +77,17 @@ export const NATIVE_EXTENSIONS = [
|
|
|
77
77
|
defaultEnabled: true,
|
|
78
78
|
tags: ["vision", "images"],
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
id: "seo",
|
|
82
|
+
label: "SEO Analysis",
|
|
83
|
+
description: "Native SEO audit tools: on-page parsing, technical checks, content quality, " +
|
|
84
|
+
"Schema.org detection/generation, sitemap, images, preload/bfcache, agent UX, " +
|
|
85
|
+
"parasite risk, drift monitoring, and full audit orchestration. No external API keys required.",
|
|
86
|
+
category: "automation",
|
|
87
|
+
entryPath: "src/extensions/seo/index.ts",
|
|
88
|
+
defaultEnabled: true,
|
|
89
|
+
tags: ["seo", "audit", "marketing"],
|
|
90
|
+
},
|
|
80
91
|
];
|
|
81
92
|
/**
|
|
82
93
|
* Look up a native extension by id. Returns undefined for unknown ids.
|