@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,265 @@
1
+ /**
2
+ * HTML parser using cheerio.
3
+ *
4
+ * Ported from the Python `parse_html.py` in claude-seo. Extracts all
5
+ * on-page SEO elements: title, meta, canonical, headings, links, images,
6
+ * JSON-LD schema, Open Graph, Twitter Card, hreflang, and word count.
7
+ */
8
+ import * as cheerio from "cheerio";
9
+ const SUSPICIOUS_HEADING_TEXTS = new Set([
10
+ "",
11
+ "click here",
12
+ "read more",
13
+ "learn more",
14
+ "more",
15
+ "here",
16
+ ]);
17
+ function detectLazyMethod($, el) {
18
+ const loading = $(el).attr("loading");
19
+ if (loading === "lazy")
20
+ return "native";
21
+ const dataSrc = $(el).attr("data-src");
22
+ if (dataSrc)
23
+ return "dataSrc";
24
+ const dataLazy = $(el).attr("data-lazy") ?? $(el).attr("data-lazy-src");
25
+ if (dataLazy)
26
+ return "dataLazy";
27
+ return "none";
28
+ }
29
+ function isInternal(href, baseUrl) {
30
+ try {
31
+ const url = new URL(href, baseUrl);
32
+ const base = new URL(baseUrl);
33
+ return url.hostname === base.hostname;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function resolveUrl(href, baseUrl) {
40
+ try {
41
+ return new URL(href, baseUrl).href;
42
+ }
43
+ catch {
44
+ return href;
45
+ }
46
+ }
47
+ /** Parse JSON-LD blocks from the HTML. */
48
+ function parseJsonLd($, baseUrl) {
49
+ const blocks = [];
50
+ $('script[type="application/ld+json"]').each((_, el) => {
51
+ const raw = $(el).text().trim();
52
+ if (!raw)
53
+ return;
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ const items = Array.isArray(parsed) ? parsed : [parsed];
57
+ for (const item of items) {
58
+ const type = item?.["@type"] ?? "Unknown";
59
+ const issues = [];
60
+ if (!item?.["@context"])
61
+ issues.push("Missing @context");
62
+ if (typeof type === "string" && type.includes("/")) {
63
+ // Could be a full URL type — that's fine.
64
+ }
65
+ // Check for relative URLs.
66
+ const jsonStr = JSON.stringify(item);
67
+ if (jsonStr.includes('"url": "/') || jsonStr.includes('"url":"/')) {
68
+ issues.push("Contains relative URL (should be absolute)");
69
+ }
70
+ blocks.push({
71
+ type: typeof type === "string" ? type : "Composite",
72
+ format: "json-ld",
73
+ raw,
74
+ parsed: item,
75
+ issues,
76
+ });
77
+ }
78
+ }
79
+ catch {
80
+ blocks.push({
81
+ type: "InvalidJSON",
82
+ format: "json-ld",
83
+ raw,
84
+ parsed: null,
85
+ issues: ["Invalid JSON syntax"],
86
+ });
87
+ }
88
+ });
89
+ return blocks;
90
+ }
91
+ /** Detect microdata markup. */
92
+ function detectMicrodata($) {
93
+ const blocks = [];
94
+ $("[itemscope]").each((_, el) => {
95
+ const type = $(el).attr("itemtype") ?? "Unknown";
96
+ blocks.push({
97
+ type: type.replace("https://schema.org/", ""),
98
+ format: "microdata",
99
+ raw: $.html(el),
100
+ issues: [],
101
+ });
102
+ });
103
+ return blocks;
104
+ }
105
+ /** Parse a full PageData object from HTML. */
106
+ export function parsePage(html, url, baseUrl) {
107
+ const $ = cheerio.load(html);
108
+ const effectiveBaseUrl = baseUrl ?? url ?? "https://example.com";
109
+ // Title
110
+ const title = $("title").first().text().trim() || null;
111
+ // Meta description
112
+ const metaDesc = $('meta[name="description"]').attr("content")?.trim() || null;
113
+ // Meta robots
114
+ const metaRobots = $('meta[name="robots"]').attr("content")?.trim() || null;
115
+ // Canonical
116
+ const canonical = $('link[rel="canonical"]').attr("href") || null;
117
+ // Open Graph
118
+ const ogTags = [];
119
+ $('meta[property^="og:"]').each((_, el) => {
120
+ ogTags.push({
121
+ property: $(el).attr("property") ?? "",
122
+ content: $(el).attr("content") ?? "",
123
+ });
124
+ });
125
+ // Twitter Card
126
+ const twitterTags = [];
127
+ $('meta[name^="twitter:"]').each((_, el) => {
128
+ twitterTags.push({
129
+ name: $(el).attr("name") ?? "",
130
+ content: $(el).attr("content") ?? "",
131
+ });
132
+ });
133
+ // Headings
134
+ const headings = [];
135
+ let h1Count = 0, h2Count = 0, h3Count = 0;
136
+ $("h1, h2, h3, h4, h5, h6").each((_, el) => {
137
+ const tagName = el.tagName.toLowerCase();
138
+ const level = Number.parseInt(tagName.replace("h", ""), 10);
139
+ const text = $(el).text().trim();
140
+ if (level === 1)
141
+ h1Count++;
142
+ if (level === 2)
143
+ h2Count++;
144
+ if (level === 3)
145
+ h3Count++;
146
+ headings.push({
147
+ level,
148
+ text,
149
+ suspicious: SUSPICIOUS_HEADING_TEXTS.has(text.toLowerCase()),
150
+ });
151
+ });
152
+ // Images
153
+ const images = [];
154
+ $("img").each((_, el) => {
155
+ const src = $(el).attr("src") ?? $(el).attr("data-src") ?? "";
156
+ if (!src || src.startsWith("data:"))
157
+ return;
158
+ images.push({
159
+ src: resolveUrl(src, effectiveBaseUrl),
160
+ alt: $(el).attr("alt") ?? "",
161
+ width: $(el).attr("width"),
162
+ height: $(el).attr("height"),
163
+ loading: $(el).attr("loading"),
164
+ lazyMethod: detectLazyMethod($, el),
165
+ fetchpriority: $(el).attr("fetchpriority"),
166
+ });
167
+ });
168
+ // Links
169
+ const links = [];
170
+ let internalLinkCount = 0;
171
+ let externalLinkCount = 0;
172
+ $("a[href]").each((_, el) => {
173
+ const href = $(el).attr("href") ?? "";
174
+ if (!href || href.startsWith("#") || href.startsWith("javascript:"))
175
+ return;
176
+ const resolved = resolveUrl(href, effectiveBaseUrl);
177
+ const rel = $(el).attr("rel") ?? "";
178
+ const nofollow = rel.toLowerCase().includes("nofollow");
179
+ const internal = isInternal(resolved, effectiveBaseUrl);
180
+ if (internal)
181
+ internalLinkCount++;
182
+ else
183
+ externalLinkCount++;
184
+ links.push({
185
+ href: resolved,
186
+ text: $(el).text().trim(),
187
+ rel,
188
+ type: internal ? "internal" : "external",
189
+ nofollow,
190
+ });
191
+ });
192
+ // Schema
193
+ const schemaBlocks = [...parseJsonLd($, effectiveBaseUrl), ...detectMicrodata($)];
194
+ // Hreflang
195
+ const hreflang = [];
196
+ $('link[rel="alternate"][hreflang]').each((_, el) => {
197
+ hreflang.push({
198
+ href: resolveUrl($(el).attr("href") ?? "", effectiveBaseUrl),
199
+ hreflang: $(el).attr("hreflang") ?? "",
200
+ });
201
+ });
202
+ // Word count (from body text)
203
+ const bodyText = $("body").text().trim();
204
+ const wordCount = bodyText ? bodyText.split(/\s+/).filter(Boolean).length : 0;
205
+ // Language
206
+ const language = $("html").attr("lang") ?? null;
207
+ // Viewport
208
+ const viewport = $('meta[name="viewport"]').attr("content") ?? null;
209
+ // Charset
210
+ const charset = $("meta[charset]").attr("charset") ?? null;
211
+ return {
212
+ url: url ?? effectiveBaseUrl,
213
+ baseUrl: effectiveBaseUrl,
214
+ title,
215
+ titleLength: title?.length ?? 0,
216
+ metaDescription: metaDesc,
217
+ metaDescriptionLength: metaDesc?.length ?? 0,
218
+ metaRobots,
219
+ canonical,
220
+ ogTags,
221
+ twitterTags,
222
+ headings,
223
+ h1Count,
224
+ h2Count,
225
+ h3Count,
226
+ images,
227
+ links,
228
+ internalLinkCount,
229
+ externalLinkCount,
230
+ schemaBlocks,
231
+ hreflang,
232
+ wordCount,
233
+ language,
234
+ viewport,
235
+ charset,
236
+ };
237
+ }
238
+ /** Extract the visible text content from HTML body. */
239
+ export function extractText(html) {
240
+ const $ = cheerio.load(html);
241
+ // Remove script and style elements.
242
+ $("script, style, noscript").remove();
243
+ return $("body").text().replace(/\s+/g, " ").trim();
244
+ }
245
+ /** Extract raw JSON-LD blocks as parsed objects. */
246
+ export function extractJsonLd(html) {
247
+ const $ = cheerio.load(html);
248
+ const results = [];
249
+ $('script[type="application/ld+json"]').each((_, el) => {
250
+ const raw = $(el).text().trim();
251
+ if (!raw)
252
+ return;
253
+ try {
254
+ const parsed = JSON.parse(raw);
255
+ if (Array.isArray(parsed))
256
+ results.push(...parsed);
257
+ else
258
+ results.push(parsed);
259
+ }
260
+ catch {
261
+ // skip invalid JSON
262
+ }
263
+ });
264
+ return results;
265
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Lightweight Playwright renderer for SPA-aware page fetching.
3
+ *
4
+ * Ported from the Python `render_page.py` in claude-seo. Uses a standalone
5
+ * Playwright browser context (not shared with the browser extension) to keep
6
+ * the SEO extension self-contained.
7
+ */
8
+ import type { SeoConfig } from "../config.js";
9
+ import type { FetchResult } from "../types.js";
10
+ export interface RenderOptions {
11
+ viewport?: "desktop" | "mobile";
12
+ timeout?: number;
13
+ blockResources?: string[];
14
+ /** Whether to capture the accessibility tree (for agent UX audits). */
15
+ captureA11y?: boolean;
16
+ /** Whether to capture console errors. */
17
+ captureConsole?: boolean;
18
+ /** User agent override. */
19
+ userAgent?: string;
20
+ }
21
+ export interface RenderResult extends FetchResult {
22
+ isSpa: boolean;
23
+ rawContent: string;
24
+ consoleErrors: string[];
25
+ renderMs: number;
26
+ accessibilityTree: unknown | null;
27
+ }
28
+ /** Close the shared browser instance (called on session shutdown). */
29
+ export declare function closeRenderer(): Promise<void>;
30
+ /** Check whether Playwright Chromium is available. */
31
+ export declare function isRendererAvailable(): Promise<boolean>;
32
+ /**
33
+ * Render a page with Playwright, returning the full DOM after JS execution.
34
+ *
35
+ * This is slower than a plain HTTP fetch but necessary for single-page
36
+ * applications where critical content is injected via JavaScript.
37
+ */
38
+ export declare function renderPage(url: string, config: SeoConfig, opts?: RenderOptions): Promise<RenderResult>;
39
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/utils/renderer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,aAAa,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,yCAAyC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAa,SAAQ,WAAW;IAC/C,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,OAAO,GAAG,IAAI,CAAC;CACnC;AAeD,sEAAsE;AACtE,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAUnD;AAED,sDAAsD;AACtD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO5D;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,EACjB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CA8JvB"}
@@ -0,0 +1,190 @@
1
+ const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
2
+ const MOBILE_VIEWPORT = { width: 390, height: 844 };
3
+ let browserPromise = null;
4
+ async function getBrowser() {
5
+ if (!browserPromise) {
6
+ const { chromium } = await import("playwright");
7
+ browserPromise = chromium.launch({ headless: true });
8
+ }
9
+ return browserPromise;
10
+ }
11
+ /** Close the shared browser instance (called on session shutdown). */
12
+ export async function closeRenderer() {
13
+ if (browserPromise) {
14
+ try {
15
+ const browser = await browserPromise;
16
+ await browser.close();
17
+ }
18
+ catch {
19
+ // ignore
20
+ }
21
+ browserPromise = null;
22
+ }
23
+ }
24
+ /** Check whether Playwright Chromium is available. */
25
+ export async function isRendererAvailable() {
26
+ try {
27
+ await getBrowser();
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Render a page with Playwright, returning the full DOM after JS execution.
36
+ *
37
+ * This is slower than a plain HTTP fetch but necessary for single-page
38
+ * applications where critical content is injected via JavaScript.
39
+ */
40
+ export async function renderPage(url, config, opts = {}) {
41
+ const viewport = opts.viewport ?? config.defaultViewport;
42
+ const timeout = opts.timeout ?? config.timeout;
43
+ const blockResources = opts.blockResources ?? config.blockRenderResources;
44
+ const captureA11y = opts.captureA11y ?? false;
45
+ const captureConsole = opts.captureConsole ?? true;
46
+ const startTime = Date.now();
47
+ const browser = await getBrowser();
48
+ const context = await browser.newContext({
49
+ viewport: viewport === "mobile" ? MOBILE_VIEWPORT : DESKTOP_VIEWPORT,
50
+ userAgent: opts.userAgent ?? config.userAgent,
51
+ });
52
+ // Block resource types to speed up rendering.
53
+ if (blockResources.length > 0) {
54
+ await context.route("**/*", (route) => {
55
+ const resourceType = route.request().resourceType();
56
+ if (blockResources.includes(resourceType)) {
57
+ route.abort().catch(() => { });
58
+ }
59
+ else {
60
+ route.continue().catch(() => { });
61
+ }
62
+ });
63
+ }
64
+ const page = await context.newPage();
65
+ const consoleErrors = [];
66
+ const redirectChain = [];
67
+ if (captureConsole) {
68
+ page.on("console", (msg) => {
69
+ if (msg.type() === "error") {
70
+ consoleErrors.push(msg.text());
71
+ }
72
+ });
73
+ page.on("pageerror", (err) => {
74
+ consoleErrors.push(err.message);
75
+ });
76
+ }
77
+ let response = null;
78
+ let finalUrl = url;
79
+ try {
80
+ response = await page.goto(url, {
81
+ waitUntil: "networkidle",
82
+ timeout,
83
+ });
84
+ if (response) {
85
+ // Track redirects by walking the redirectedFrom() chain.
86
+ let current = response.request().redirectedFrom();
87
+ while (current) {
88
+ const redirectResponse = await current.response().catch(() => null);
89
+ redirectChain.unshift({
90
+ url: current.url(),
91
+ status: redirectResponse?.status() ?? 0,
92
+ });
93
+ current = current.redirectedFrom();
94
+ }
95
+ finalUrl = page.url();
96
+ }
97
+ }
98
+ catch (err) {
99
+ await context.close();
100
+ return {
101
+ url,
102
+ finalUrl,
103
+ statusCode: 0,
104
+ statusText: "Error",
105
+ headers: {},
106
+ content: "",
107
+ redirectChain,
108
+ contentLengthBytes: 0,
109
+ fetchedAt: new Date().toISOString(),
110
+ error: err instanceof Error ? err.message : String(err),
111
+ isSpa: false,
112
+ rawContent: "",
113
+ consoleErrors,
114
+ renderMs: Date.now() - startTime,
115
+ accessibilityTree: null,
116
+ };
117
+ }
118
+ const rawContent = (await response?.text()) ?? "";
119
+ const content = await page.content();
120
+ // SPA heuristic: if the rendered DOM is significantly larger than the raw
121
+ // initial HTML, content was likely injected by JavaScript.
122
+ const isSpa = rawContent.length > 0 && content.length > rawContent.length * 1.5;
123
+ let accessibilityTree = null;
124
+ if (captureA11y) {
125
+ try {
126
+ // Extract accessibility-relevant data from the DOM since Playwright 1.60
127
+ // removed the page.accessibility.snapshot() API.
128
+ accessibilityTree = await page.evaluate(() => {
129
+ const INTERACTIVE_ROLES = new Set([
130
+ "button", "link", "textbox", "checkbox", "radio", "combobox",
131
+ "menuitem", "tab", "slider", "spinbutton", "switch", "searchbox",
132
+ "treeitem", "option",
133
+ ]);
134
+ const all = document.querySelectorAll("*");
135
+ let totalNodes = 0;
136
+ let interactiveNodes = 0;
137
+ let unnamedInteractive = 0;
138
+ let genericNodes = 0;
139
+ const result = [];
140
+ for (const el of all) {
141
+ totalNodes++;
142
+ const role = el.getAttribute("role");
143
+ const name = (el.getAttribute("aria-label") ?? el.textContent?.trim().slice(0, 50) ?? "");
144
+ const tag = el.tagName.toLowerCase();
145
+ const effectiveRole = role ?? (tag === "button" ? "button" : tag === "a" ? "link" : tag === "input" || tag === "textarea" || tag === "select" ? "textbox" : null);
146
+ if (effectiveRole && INTERACTIVE_ROLES.has(effectiveRole)) {
147
+ interactiveNodes++;
148
+ if (!name || name.trim() === "")
149
+ unnamedInteractive++;
150
+ }
151
+ if (effectiveRole === "generic" || (!role && !["button", "a", "input", "textarea", "select", "nav", "main", "header", "footer", "article", "section", "aside", "form"].includes(tag))) {
152
+ genericNodes++;
153
+ }
154
+ if (interactiveNodes < 200) {
155
+ result.push({ role: effectiveRole, name, tag });
156
+ }
157
+ }
158
+ return { totalNodes, interactiveNodes, unnamedInteractive, genericNodes, sample: result.slice(0, 100) };
159
+ });
160
+ }
161
+ catch {
162
+ accessibilityTree = null;
163
+ }
164
+ }
165
+ const renderMs = Date.now() - startTime;
166
+ await context.close();
167
+ const headers = {};
168
+ if (response) {
169
+ const respHeaders = response.headers();
170
+ for (const [k, v] of Object.entries(respHeaders)) {
171
+ headers[k] = v;
172
+ }
173
+ }
174
+ return {
175
+ url,
176
+ finalUrl,
177
+ statusCode: response?.status() ?? 0,
178
+ statusText: response?.statusText() ?? "",
179
+ headers,
180
+ content,
181
+ redirectChain,
182
+ contentLengthBytes: Buffer.byteLength(content, "utf-8"),
183
+ fetchedAt: new Date().toISOString(),
184
+ isSpa,
185
+ rawContent,
186
+ consoleErrors,
187
+ renderMs,
188
+ accessibilityTree,
189
+ };
190
+ }
@@ -0,0 +1,31 @@
1
+ export declare class UrlSafetyError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export interface ValidatedUrl {
5
+ href: string;
6
+ protocol: string;
7
+ hostname: string;
8
+ port: string;
9
+ pathname: string;
10
+ search: string;
11
+ /** Resolved IP addresses (only populated when SSRF protection is on). */
12
+ ips: string[];
13
+ }
14
+ /**
15
+ * Validate a URL string with optional SSRF protection.
16
+ *
17
+ * Throws UrlSafetyError if the URL is invalid, uses a non-http(s) protocol,
18
+ * points to a blocked hostname, or (when ssrfProtection=true) resolves to a
19
+ * private IP address.
20
+ */
21
+ export declare function validateUrl(urlStr: string, opts?: {
22
+ ssrfProtection?: boolean;
23
+ resolveDns?: boolean;
24
+ }): Promise<ValidatedUrl>;
25
+ /** Quick synchronous URL validation without DNS resolution. */
26
+ export declare function validateUrlBasic(urlStr: string): ValidatedUrl;
27
+ /** Normalize a URL for baseline matching (strip UTM, sort params, lowercase host). */
28
+ export declare function normalizeUrl(url: string): string;
29
+ /** Hash a normalized URL for baseline deduplication. */
30
+ export declare function urlHash(url: string): string;
31
+ //# sourceMappingURL=url-safety.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-safety.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/utils/url-safety.ts"],"names":[],"mappings":"AAUA,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAiDD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,GAAG,EAAE,MAAM,EAAE,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,GAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5D,OAAO,CAAC,YAAY,CAAC,CA8EvB;AAED,+DAA+D;AAC/D,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CA8B7D;AAED,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA2BhD;AAED,wDAAwD;AACxD,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE3C"}