framewatch-mcp-server 0.1.1 → 0.2.0
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/README.md +797 -8
- package/dist/constants.d.ts +264 -0
- package/dist/constants.js +268 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.js +4 -0
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +6 -1
- package/dist/engine/interaction.js +21 -6
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +42 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.js +4 -3
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +83 -23
- package/dist/tools/capture.js +105 -60
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.js +4 -3
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +16 -1
- package/dist/tools/index.js +42 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +29 -16
- package/dist/tools/interact.js +99 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +6 -6
- package/dist/tools/responsive.js +9 -9
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +22 -22
- package/dist/tools/screenshot.js +13 -5
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +42 -1
- package/dist/utils/storage-state.js +115 -4
- package/dist/utils/storage-state.js.map +1 -1
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- package/package.json +6 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_SEO_WAIT_MS, DEFAULT_VIEWPORT, MAX_SEO_HEADINGS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { PERF_INIT_SCRIPT, extractSeo, fetchRobots, probeImage, readPerformance, } from "../engine/seo.js";
|
|
5
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
6
|
+
import { SEO_AREAS, judgeSeo, metaValues, } from "../utils/seo-rules.js";
|
|
7
|
+
import { resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
8
|
+
export const SEO_TOOL_NAME = "framewatch_seo";
|
|
9
|
+
export const seoInputShape = {
|
|
10
|
+
url: z.string().url().describe("URL to audit, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
11
|
+
wait_ms: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(0)
|
|
15
|
+
.default(DEFAULT_SEO_WAIT_MS)
|
|
16
|
+
.describe("Wait time (ms) after page load before reading the page, so a client-rendered app can finish"),
|
|
17
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before reading the page"),
|
|
18
|
+
wait_for_timeout_ms: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1)
|
|
22
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
23
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
24
|
+
check_robots: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.default(true)
|
|
27
|
+
.describe("Fetch /robots.txt and work out whether this page's own path is crawlable"),
|
|
28
|
+
robots_user_agent: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.default("Googlebot")
|
|
32
|
+
.describe("Which crawler to answer the robots.txt question for. A named group beats the `*` group, as it does for the real thing."),
|
|
33
|
+
check_og_image: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.default(true)
|
|
36
|
+
.describe("Fetch the og:image and measure it — a share image that 404s or is too small is invisible from the page itself"),
|
|
37
|
+
include_performance: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.default(false)
|
|
40
|
+
.describe("Also measure this load: LCP, CLS, TTFB, page weight and DOM size. Lab numbers from one headless load, " +
|
|
41
|
+
"useful for finding the slow element, not a prediction of field data."),
|
|
42
|
+
viewport: z
|
|
43
|
+
.object({
|
|
44
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
45
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
46
|
+
})
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Viewport size (defaults to 1280x720)"),
|
|
49
|
+
storage_state: storageStateField,
|
|
50
|
+
};
|
|
51
|
+
export const seoInputSchema = z.object(seoInputShape);
|
|
52
|
+
/**
|
|
53
|
+
* Audit what a search engine — and a link preview — would make of a page.
|
|
54
|
+
*
|
|
55
|
+
* Everything is read from the *rendered* document rather than the HTML source.
|
|
56
|
+
* A single-page app ships an empty `<div id="app">` and fills in the title,
|
|
57
|
+
* the description and the structured data afterwards, so reading the source
|
|
58
|
+
* would report every client-rendered page as having no SEO at all, which is
|
|
59
|
+
* both wrong and the opposite of useful.
|
|
60
|
+
*
|
|
61
|
+
* Three things are checked that are not on the page and cannot be seen from
|
|
62
|
+
* it: the response headers (an `X-Robots-Tag: noindex` is invisible in the
|
|
63
|
+
* DOM), robots.txt (a page can be perfect and simply not crawlable), and the
|
|
64
|
+
* share image itself, which is fetched and measured — an `og:image` pointing
|
|
65
|
+
* at a 404 looks exactly like a working one until somebody shares the link.
|
|
66
|
+
*/
|
|
67
|
+
export async function auditSeo(rawInput) {
|
|
68
|
+
const parsed = seoInputSchema.safeParse(rawInput);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
71
|
+
return errorResult(`SEO audit failed: invalid input — ${issues}`);
|
|
72
|
+
}
|
|
73
|
+
const input = parsed.data;
|
|
74
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
75
|
+
let contextOptions = {};
|
|
76
|
+
let auth = null;
|
|
77
|
+
try {
|
|
78
|
+
auth = await resolveStorageState(input.storage_state);
|
|
79
|
+
if (auth)
|
|
80
|
+
contextOptions = { storageState: auth.state };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return errorResult(`SEO audit of ${input.url} failed: ${firstLine(error)}`);
|
|
84
|
+
}
|
|
85
|
+
let gathered;
|
|
86
|
+
try {
|
|
87
|
+
gathered = await withPage({ viewport, contextOptions }, (page, context) => gather(input, page, context));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return errorResult(describeSeoFailure(input, error));
|
|
91
|
+
}
|
|
92
|
+
const report = judgeSeo({
|
|
93
|
+
requested_url: input.url,
|
|
94
|
+
page: gathered.page,
|
|
95
|
+
robots_user_agent: input.robots_user_agent,
|
|
96
|
+
...(gathered.response ? { response: gathered.response } : {}),
|
|
97
|
+
...(gathered.robots ? { robots: gathered.robots } : {}),
|
|
98
|
+
...(gathered.og_image ? { og_image: gathered.og_image } : {}),
|
|
99
|
+
...(gathered.performance ? { performance: gathered.performance } : {}),
|
|
100
|
+
});
|
|
101
|
+
return withAuthNote(await render(gathered, report), auth);
|
|
102
|
+
}
|
|
103
|
+
/** Open the page and collect everything the audit needs from it. */
|
|
104
|
+
async function gather(input, page, context) {
|
|
105
|
+
// The observers have to exist before the navigation: LCP, CLS and long
|
|
106
|
+
// tasks are only observable as they happen.
|
|
107
|
+
if (input.include_performance)
|
|
108
|
+
await page.addInitScript(PERF_INIT_SCRIPT);
|
|
109
|
+
const response = await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
110
|
+
if (input.wait_for) {
|
|
111
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
112
|
+
}
|
|
113
|
+
if (input.wait_ms > 0) {
|
|
114
|
+
await page.waitForTimeout(input.wait_ms);
|
|
115
|
+
}
|
|
116
|
+
const seo = await extractSeo(page);
|
|
117
|
+
const gathered = { page: seo };
|
|
118
|
+
if (response) {
|
|
119
|
+
gathered.response = { status: response.status(), headers: response.headers() };
|
|
120
|
+
}
|
|
121
|
+
if (input.include_performance) {
|
|
122
|
+
const metrics = await readPerformance(page);
|
|
123
|
+
if (metrics)
|
|
124
|
+
gathered.performance = metrics;
|
|
125
|
+
}
|
|
126
|
+
if (input.check_robots) {
|
|
127
|
+
gathered.robots = await fetchRobots(context, seo.url);
|
|
128
|
+
}
|
|
129
|
+
if (input.check_og_image) {
|
|
130
|
+
const target = resolveUrl(metaValues(seo, "og:image")[0], seo.url);
|
|
131
|
+
if (target)
|
|
132
|
+
gathered.og_image = await probeImage(context, target);
|
|
133
|
+
}
|
|
134
|
+
return gathered;
|
|
135
|
+
}
|
|
136
|
+
/* ── Rendering ────────────────────────────────────────────────────────── */
|
|
137
|
+
const AREA_TITLES = {
|
|
138
|
+
indexing: "Indexing",
|
|
139
|
+
content: "Title & description",
|
|
140
|
+
headings: "Headings",
|
|
141
|
+
social: "Social (Open Graph)",
|
|
142
|
+
images: "Images",
|
|
143
|
+
structured_data: "Structured data",
|
|
144
|
+
performance: "Performance (lab)",
|
|
145
|
+
};
|
|
146
|
+
const MARKS = {
|
|
147
|
+
problem: "✗",
|
|
148
|
+
warning: "!",
|
|
149
|
+
pass: "✓",
|
|
150
|
+
info: "·",
|
|
151
|
+
};
|
|
152
|
+
async function render(gathered, report) {
|
|
153
|
+
const content = [{ type: "text", text: formatSeoReport(gathered.page, report) }];
|
|
154
|
+
// The share image is worth looking at, not only measuring: whether the logo
|
|
155
|
+
// is cropped or the text unreadable at card size is not a thing any header
|
|
156
|
+
// can answer.
|
|
157
|
+
const probe = gathered.og_image;
|
|
158
|
+
if (probe?.data) {
|
|
159
|
+
try {
|
|
160
|
+
const resized = await resizeForOutput(probe.data);
|
|
161
|
+
content.push({ type: "image", data: toBase64(resized), mimeType: "image/png" });
|
|
162
|
+
content.push({
|
|
163
|
+
type: "text",
|
|
164
|
+
text: `og:image — ${probe.url} (${probe.width}x${probe.height}${probe.content_type ? `, ${probe.content_type}` : ""}), as it will appear when the page is shared.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Measured already; failing to re-encode it is not worth failing the audit for.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { content };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The report as text: a verdict line, the problems up front, then every check
|
|
175
|
+
* in the order it was run.
|
|
176
|
+
*
|
|
177
|
+
* Passing checks are printed rather than counted. A report of four lines with
|
|
178
|
+
* nothing else in it is otherwise indistinguishable from an audit that only
|
|
179
|
+
* looked at four things.
|
|
180
|
+
*/
|
|
181
|
+
export function formatSeoReport(page, report) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
lines.push(`SEO audit of ${page.url} — ${count(report.problems, "problem")}, ${count(report.warnings, "warning")}, ` +
|
|
184
|
+
`${report.passes} ${report.passes === 1 ? "check" : "checks"} passed.`);
|
|
185
|
+
const problems = report.findings.filter((finding) => finding.level === "problem");
|
|
186
|
+
if (problems.length > 0) {
|
|
187
|
+
lines.push(`Problems: ${problems.map((finding) => `${finding.label} (${headline(finding)})`).join("; ")}`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lines.push("Nothing here would keep this page out of an index or break its share card.");
|
|
191
|
+
}
|
|
192
|
+
for (const area of SEO_AREAS) {
|
|
193
|
+
const findings = report.findings.filter((finding) => finding.area === area);
|
|
194
|
+
if (findings.length === 0)
|
|
195
|
+
continue;
|
|
196
|
+
lines.push("");
|
|
197
|
+
lines.push(AREA_TITLES[area]);
|
|
198
|
+
for (const finding of findings) {
|
|
199
|
+
lines.push(` ${MARKS[finding.level]} ${finding.label} — ${finding.detail}`);
|
|
200
|
+
if (finding.fix)
|
|
201
|
+
lines.push(` → ${finding.fix}`);
|
|
202
|
+
}
|
|
203
|
+
if (area === "headings" && page.headings.length > 0) {
|
|
204
|
+
lines.push(" Outline:");
|
|
205
|
+
for (const heading of page.headings) {
|
|
206
|
+
const indent = " ".repeat(Math.max(0, heading.level - 1));
|
|
207
|
+
const text = heading.empty ? "(empty)" : heading.text;
|
|
208
|
+
lines.push(` ${indent}h${heading.level} ${text}${heading.hidden ? " (hidden)" : ""}`);
|
|
209
|
+
}
|
|
210
|
+
if (page.heading_total > page.headings.length) {
|
|
211
|
+
lines.push(` … and ${page.heading_total - page.headings.length} more headings (the first ${MAX_SEO_HEADINGS} are listed)`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
/** The short form of a finding, for the problems line at the top. */
|
|
218
|
+
function headline(finding) {
|
|
219
|
+
const [first] = finding.detail.split(" — ");
|
|
220
|
+
return first.length > 90 ? `${first.slice(0, 90)}…` : first;
|
|
221
|
+
}
|
|
222
|
+
function count(n, noun) {
|
|
223
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
224
|
+
}
|
|
225
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
226
|
+
/** Resolve a possibly relative URL against the page. Returns undefined for anything unusable. */
|
|
227
|
+
function resolveUrl(href, base) {
|
|
228
|
+
if (!href)
|
|
229
|
+
return undefined;
|
|
230
|
+
try {
|
|
231
|
+
const resolved = new URL(href, base);
|
|
232
|
+
// A data: URI is already the image; there is nothing to fetch and nothing
|
|
233
|
+
// a network could fail to load.
|
|
234
|
+
return resolved.protocol === "http:" || resolved.protocol === "https:" ? resolved.href : undefined;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function firstLine(error) {
|
|
241
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
242
|
+
}
|
|
243
|
+
function errorResult(text) {
|
|
244
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* One actionable line for an audit that could not run. Mirrors
|
|
248
|
+
* `describeFailure` in screenshot.ts: match on the failing Playwright call,
|
|
249
|
+
* never on substrings of a user-supplied selector.
|
|
250
|
+
*/
|
|
251
|
+
export function describeSeoFailure(input, error) {
|
|
252
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
253
|
+
const line = message.split("\n")[0];
|
|
254
|
+
const prefix = `SEO audit of ${input.url} failed:`;
|
|
255
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
256
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
257
|
+
`Run \`npx playwright install chromium\` and try again. (${line})`);
|
|
258
|
+
}
|
|
259
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
260
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
261
|
+
}
|
|
262
|
+
if (/^page\.goto:/.test(message)) {
|
|
263
|
+
return `${prefix} the page could not be opened — ${line}`;
|
|
264
|
+
}
|
|
265
|
+
return `${prefix} ${line}`;
|
|
266
|
+
}
|
|
267
|
+
export function registerSeoTool(server) {
|
|
268
|
+
server.registerTool(SEO_TOOL_NAME, {
|
|
269
|
+
title: "SEO",
|
|
270
|
+
description: "Audit what a search engine and a link preview would make of a page: title and meta description (with " +
|
|
271
|
+
"lengths), canonical, robots directives and robots.txt, Open Graph and Twitter card tags, the heading " +
|
|
272
|
+
"outline, images with no alt text, and JSON-LD structured data checked against what Google's rich " +
|
|
273
|
+
"results require. Everything is read from the rendered DOM, so a client-rendered app is measured " +
|
|
274
|
+
"properly rather than reported as empty. The og:image is fetched and measured — a share image that " +
|
|
275
|
+
"404s or is too small is invisible from the page itself — and comes back as an image to look at. " +
|
|
276
|
+
"Set `include_performance: true` to add LCP, CLS, TTFB, page weight and DOM size from the same load.",
|
|
277
|
+
inputSchema: seoInputShape,
|
|
278
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
279
|
+
}, async (args) => auditSeo(args));
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=seo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seo.js","sourceRoot":"","sources":["../../src/tools/seo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,UAAU,EACV,eAAe,GAKhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EACL,SAAS,EACT,QAAQ,EACR,UAAU,GAMX,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEjG,MAAM,CAAC,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAE9C,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,mFAAmF,CAAC;IACnH,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,mBAAmB,CAAC;SAC5B,QAAQ,CAAC,6FAA6F,CAAC;IAC1G,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4DAA4D,CAAC;IACtG,mBAAmB,EAAE,CAAC;SACnB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,mBAAmB,CAAC;SAC5B,QAAQ,CAAC,8DAA8D,CAAC;IAC3E,YAAY,EAAE,CAAC;SACZ,OAAO,EAAE;SACT,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,0EAA0E,CAAC;IACvF,iBAAiB,EAAE,CAAC;SACjB,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,WAAW,CAAC;SACpB,QAAQ,CAAC,wHAAwH,CAAC;IACrI,cAAc,EAAE,CAAC;SACd,OAAO,EAAE;SACT,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,+GAA+G,CAAC;IAC5H,mBAAmB,EAAE,CAAC;SACnB,OAAO,EAAE;SACT,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,wGAAwG;QACtG,sEAAsE,CACzE;IACH,QAAQ,EAAE,CAAC;SACR,MAAM,CAAC;QACN,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC;QACtF,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC,OAAO,CAAC,gBAAgB,CAAC,MAAM,CAAC;KAC1F,CAAC;SACD,QAAQ,EAAE;SACV,QAAQ,CAAC,sCAAsC,CAAC;IACnD,aAAa,EAAE,iBAAiB;CACjC,CAAC;AAEF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;AAatD;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,QAAkB;IAC/C,MAAM,MAAM,GAAG,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzG,OAAO,WAAW,CAAC,qCAAqC,MAAM,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC;IAC1B,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG,gBAAgB,EAAE,CAAC;IAE3D,IAAI,cAAc,GAAG,EAAE,CAAC;IACxB,IAAI,IAAI,GAAG,IAAI,CAAC;IAChB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,mBAAmB,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QACtD,IAAI,IAAI;YAAE,cAAc,GAAG,EAAE,YAAY,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,WAAW,CAAC,gBAAgB,KAAK,CAAC,GAAG,YAAY,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC9E,CAAC;IAED,IAAI,QAAkB,CAAC;IACvB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,QAAQ,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3G,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,WAAW,CAAC,kBAAkB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,MAAM,GAAG,QAAQ,CAAC;QACtB,aAAa,EAAE,KAAK,CAAC,GAAG;QACxB,IAAI,EAAE,QAAQ,CAAC,IAAI;QACnB,iBAAiB,EAAE,KAAK,CAAC,iBAAiB;QAC1C,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC,CAAC;IAEH,OAAO,YAAY,CAAC,MAAM,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC;AAC5D,CAAC;AAED,oEAAoE;AACpE,KAAK,UAAU,MAAM,CAAC,KAAqB,EAAE,IAAU,EAAE,OAAuB;IAC9E,uEAAuE;IACvE,4CAA4C;IAC5C,IAAI,KAAK,CAAC,mBAAmB;QAAE,MAAM,IAAI,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC;IAE1E,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC,CAAC;IACnG,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACnB,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,mBAAmB,EAAE,CAAC,CAAC;IACvG,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,QAAQ,GAAa,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IAEzC,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,QAAQ,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC;IACjF,CAAC;IACD,IAAI,KAAK,CAAC,mBAAmB,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,IAAI,CAAC,CAAC;QAC5C,IAAI,OAAO;YAAE,QAAQ,CAAC,WAAW,GAAG,OAAO,CAAC;IAC9C,CAAC;IACD,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,QAAQ,CAAC,MAAM,GAAG,MAAM,WAAW,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACxD,CAAC;IACD,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;QACzB,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QACnE,IAAI,MAAM;YAAE,QAAQ,CAAC,QAAQ,GAAG,MAAM,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACpE,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,6EAA6E;AAE7E,MAAM,WAAW,GAAsC;IACrD,QAAQ,EAAE,UAAU;IACpB,OAAO,EAAE,qBAAqB;IAC9B,QAAQ,EAAE,UAAU;IACpB,MAAM,EAAE,qBAAqB;IAC7B,MAAM,EAAE,QAAQ;IAChB,eAAe,EAAE,iBAAiB;IAClC,WAAW,EAAE,mBAAmB;CACjC,CAAC;AAEF,MAAM,KAAK,GAAuC;IAChD,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,GAAG;IACZ,IAAI,EAAE,GAAG;IACT,IAAI,EAAE,GAAG;CACV,CAAC;AAEF,KAAK,UAAU,MAAM,CAAC,QAAkB,EAAE,MAAiB;IACzD,MAAM,OAAO,GAA8B,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IAE5G,4EAA4E;IAC5E,2EAA2E;IAC3E,cAAc;IACd,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,CAAC;IAChC,IAAI,KAAK,EAAE,IAAI,EAAE,CAAC;QAChB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAClD,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;YAChF,OAAO,CAAC,IAAI,CAAC;gBACX,IAAI,EAAE,MAAM;gBACZ,IAAI,EAAE,cAAc,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,+CAA+C;aACnK,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,gFAAgF;QAClF,CAAC;IACH,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,CAAC;AACrB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,IAAa,EAAE,MAAiB;IAC9D,MAAM,KAAK,GAAa,EAAE,CAAC;IAE3B,KAAK,CAAC,IAAI,CACR,gBAAgB,IAAI,CAAC,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI;QACvG,GAAG,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,UAAU,CACzE,CAAC;IAEF,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC;IAClF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,KAAK,CAAC,IAAI,CAAC,aAAa,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,GAAG,OAAO,CAAC,KAAK,KAAK,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC7G,CAAC;SAAM,CAAC;QACN,KAAK,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAC;IAC3F,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;QAC7B,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;QAC5E,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACpC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC;QAC9B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,KAAK,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,KAAK,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;YAC7E,IAAI,OAAO,CAAC,GAAG;gBAAE,KAAK,CAAC,IAAI,CAAC,WAAW,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACxD,CAAC;QACD,IAAI,IAAI,KAAK,UAAU,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpD,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YACzB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACpC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;gBAC3D,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;gBACtD,KAAK,CAAC,IAAI,CAAC,OAAO,MAAM,IAAI,OAAO,CAAC,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC3F,CAAC;YACD,IAAI,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;gBAC9C,KAAK,CAAC,IAAI,CAAC,aAAa,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,6BAA6B,gBAAgB,cAAc,CAAC,CAAC;YAChI,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,qEAAqE;AACrE,SAAS,QAAQ,CAAC,OAAmB;IACnC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC5C,OAAO,KAAK,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AAC9D,CAAC;AAED,SAAS,KAAK,CAAC,CAAS,EAAE,IAAY;IACpC,OAAO,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC7C,CAAC;AAED,6EAA6E;AAE7E,iGAAiG;AACjG,SAAS,UAAU,CAAC,IAAwB,EAAE,IAAY;IACxD,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACrC,0EAA0E;QAC1E,gCAAgC;QAChC,OAAO,QAAQ,CAAC,QAAQ,KAAK,OAAO,IAAI,QAAQ,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;IACrG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAC9D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAChC,KAAsE,EACtE,KAAc;IAEd,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACpC,MAAM,MAAM,GAAG,gBAAgB,KAAK,CAAC,GAAG,UAAU,CAAC;IAEnD,IAAI,+CAA+C,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAClE,OAAO,CACL,GAAG,MAAM,mDAAmD;YAC5D,2DAA2D,IAAI,GAAG,CACnE,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,QAAQ,IAAI,yBAAyB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAC9D,OAAO,GAAG,MAAM,cAAc,KAAK,CAAC,QAAQ,mCAAmC,KAAK,CAAC,mBAAmB,KAAK,CAAC;IAChH,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACjC,OAAO,GAAG,MAAM,mCAAmC,IAAI,EAAE,CAAC;IAC5D,CAAC;IACD,OAAO,GAAG,MAAM,IAAI,IAAI,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAAiB;IAC/C,MAAM,CAAC,YAAY,CACjB,aAAa,EACb;QACE,KAAK,EAAE,KAAK;QACZ,WAAW,EACT,uGAAuG;YACvG,uGAAuG;YACvG,mGAAmG;YACnG,kGAAkG;YAClG,oGAAoG;YACpG,kGAAkG;YAClG,qGAAqG;QACvG,WAAW,EAAE,aAAa;QAC1B,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACvG,EACD,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAC/B,CAAC;AACJ,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { BrowserContext, Page } from \"playwright\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_SEO_WAIT_MS,\n DEFAULT_VIEWPORT,\n MAX_SEO_HEADINGS,\n MAX_VIEWPORT_HEIGHT,\n MAX_VIEWPORT_WIDTH,\n NAVIGATION_TIMEOUT_MS,\n SELECTOR_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport {\n PERF_INIT_SCRIPT,\n extractSeo,\n fetchRobots,\n probeImage,\n readPerformance,\n type ImageProbe,\n type PageSeo,\n type PerfMetrics,\n type RobotsFetch,\n} from \"../engine/seo.js\";\nimport { resizeForOutput, toBase64 } from \"../utils/image.js\";\nimport {\n SEO_AREAS,\n judgeSeo,\n metaValues,\n type SeoArea,\n type SeoFinding,\n type SeoLevel,\n type SeoReport,\n type SeoResponseInfo,\n} from \"../utils/seo-rules.js\";\nimport { resolveStorageState, storageStateField, withAuthNote } from \"../utils/storage-state.js\";\n\nexport const SEO_TOOL_NAME = \"framewatch_seo\";\n\nexport const seoInputShape = {\n url: z.string().url().describe(\"URL to audit, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_SEO_WAIT_MS)\n .describe(\"Wait time (ms) after page load before reading the page, so a client-rendered app can finish\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before reading the page\"),\n wait_for_timeout_ms: z\n .number()\n .int()\n .min(1)\n .default(SELECTOR_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for `wait_for` to appear (must be > 0)\"),\n check_robots: z\n .boolean()\n .default(true)\n .describe(\"Fetch /robots.txt and work out whether this page's own path is crawlable\"),\n robots_user_agent: z\n .string()\n .min(1)\n .default(\"Googlebot\")\n .describe(\"Which crawler to answer the robots.txt question for. A named group beats the `*` group, as it does for the real thing.\"),\n check_og_image: z\n .boolean()\n .default(true)\n .describe(\"Fetch the og:image and measure it — a share image that 404s or is too small is invisible from the page itself\"),\n include_performance: z\n .boolean()\n .default(false)\n .describe(\n \"Also measure this load: LCP, CLS, TTFB, page weight and DOM size. Lab numbers from one headless load, \" +\n \"useful for finding the slow element, not a prediction of field data.\",\n ),\n viewport: z\n .object({\n width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),\n })\n .optional()\n .describe(\"Viewport size (defaults to 1280x720)\"),\n storage_state: storageStateField,\n};\n\nexport const seoInputSchema = z.object(seoInputShape);\nexport type SeoInput = z.input<typeof seoInputSchema>;\ntype ParsedSeoInput = z.output<typeof seoInputSchema>;\n\n/** Everything one audit gathered, before anything judges it. */\ninterface Gathered {\n page: PageSeo;\n response?: SeoResponseInfo;\n robots?: RobotsFetch;\n og_image?: ImageProbe;\n performance?: PerfMetrics;\n}\n\n/**\n * Audit what a search engine — and a link preview — would make of a page.\n *\n * Everything is read from the *rendered* document rather than the HTML source.\n * A single-page app ships an empty `<div id=\"app\">` and fills in the title,\n * the description and the structured data afterwards, so reading the source\n * would report every client-rendered page as having no SEO at all, which is\n * both wrong and the opposite of useful.\n *\n * Three things are checked that are not on the page and cannot be seen from\n * it: the response headers (an `X-Robots-Tag: noindex` is invisible in the\n * DOM), robots.txt (a page can be perfect and simply not crawlable), and the\n * share image itself, which is fetched and measured — an `og:image` pointing\n * at a 404 looks exactly like a working one until somebody shares the link.\n */\nexport async function auditSeo(rawInput: SeoInput): Promise<CallToolResult> {\n const parsed = seoInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`SEO audit failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };\n\n let contextOptions = {};\n let auth = null;\n try {\n auth = await resolveStorageState(input.storage_state);\n if (auth) contextOptions = { storageState: auth.state };\n } catch (error) {\n return errorResult(`SEO audit of ${input.url} failed: ${firstLine(error)}`);\n }\n\n let gathered: Gathered;\n try {\n gathered = await withPage({ viewport, contextOptions }, (page, context) => gather(input, page, context));\n } catch (error) {\n return errorResult(describeSeoFailure(input, error));\n }\n\n const report = judgeSeo({\n requested_url: input.url,\n page: gathered.page,\n robots_user_agent: input.robots_user_agent,\n ...(gathered.response ? { response: gathered.response } : {}),\n ...(gathered.robots ? { robots: gathered.robots } : {}),\n ...(gathered.og_image ? { og_image: gathered.og_image } : {}),\n ...(gathered.performance ? { performance: gathered.performance } : {}),\n });\n\n return withAuthNote(await render(gathered, report), auth);\n}\n\n/** Open the page and collect everything the audit needs from it. */\nasync function gather(input: ParsedSeoInput, page: Page, context: BrowserContext): Promise<Gathered> {\n // The observers have to exist before the navigation: LCP, CLS and long\n // tasks are only observable as they happen.\n if (input.include_performance) await page.addInitScript(PERF_INIT_SCRIPT);\n\n const response = await page.goto(input.url, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n if (input.wait_for) {\n await page.waitForSelector(input.wait_for, { state: \"visible\", timeout: input.wait_for_timeout_ms });\n }\n if (input.wait_ms > 0) {\n await page.waitForTimeout(input.wait_ms);\n }\n\n const seo = await extractSeo(page);\n const gathered: Gathered = { page: seo };\n\n if (response) {\n gathered.response = { status: response.status(), headers: response.headers() };\n }\n if (input.include_performance) {\n const metrics = await readPerformance(page);\n if (metrics) gathered.performance = metrics;\n }\n if (input.check_robots) {\n gathered.robots = await fetchRobots(context, seo.url);\n }\n if (input.check_og_image) {\n const target = resolveUrl(metaValues(seo, \"og:image\")[0], seo.url);\n if (target) gathered.og_image = await probeImage(context, target);\n }\n\n return gathered;\n}\n\n/* ── Rendering ────────────────────────────────────────────────────────── */\n\nconst AREA_TITLES: Readonly<Record<SeoArea, string>> = {\n indexing: \"Indexing\",\n content: \"Title & description\",\n headings: \"Headings\",\n social: \"Social (Open Graph)\",\n images: \"Images\",\n structured_data: \"Structured data\",\n performance: \"Performance (lab)\",\n};\n\nconst MARKS: Readonly<Record<SeoLevel, string>> = {\n problem: \"✗\",\n warning: \"!\",\n pass: \"✓\",\n info: \"·\",\n};\n\nasync function render(gathered: Gathered, report: SeoReport): Promise<CallToolResult> {\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: formatSeoReport(gathered.page, report) }];\n\n // The share image is worth looking at, not only measuring: whether the logo\n // is cropped or the text unreadable at card size is not a thing any header\n // can answer.\n const probe = gathered.og_image;\n if (probe?.data) {\n try {\n const resized = await resizeForOutput(probe.data);\n content.push({ type: \"image\", data: toBase64(resized), mimeType: \"image/png\" });\n content.push({\n type: \"text\",\n text: `og:image — ${probe.url} (${probe.width}x${probe.height}${probe.content_type ? `, ${probe.content_type}` : \"\"}), as it will appear when the page is shared.`,\n });\n } catch {\n // Measured already; failing to re-encode it is not worth failing the audit for.\n }\n }\n\n return { content };\n}\n\n/**\n * The report as text: a verdict line, the problems up front, then every check\n * in the order it was run.\n *\n * Passing checks are printed rather than counted. A report of four lines with\n * nothing else in it is otherwise indistinguishable from an audit that only\n * looked at four things.\n */\nexport function formatSeoReport(page: PageSeo, report: SeoReport): string {\n const lines: string[] = [];\n\n lines.push(\n `SEO audit of ${page.url} — ${count(report.problems, \"problem\")}, ${count(report.warnings, \"warning\")}, ` +\n `${report.passes} ${report.passes === 1 ? \"check\" : \"checks\"} passed.`,\n );\n\n const problems = report.findings.filter((finding) => finding.level === \"problem\");\n if (problems.length > 0) {\n lines.push(`Problems: ${problems.map((finding) => `${finding.label} (${headline(finding)})`).join(\"; \")}`);\n } else {\n lines.push(\"Nothing here would keep this page out of an index or break its share card.\");\n }\n\n for (const area of SEO_AREAS) {\n const findings = report.findings.filter((finding) => finding.area === area);\n if (findings.length === 0) continue;\n lines.push(\"\");\n lines.push(AREA_TITLES[area]);\n for (const finding of findings) {\n lines.push(` ${MARKS[finding.level]} ${finding.label} — ${finding.detail}`);\n if (finding.fix) lines.push(` → ${finding.fix}`);\n }\n if (area === \"headings\" && page.headings.length > 0) {\n lines.push(\" Outline:\");\n for (const heading of page.headings) {\n const indent = \" \".repeat(Math.max(0, heading.level - 1));\n const text = heading.empty ? \"(empty)\" : heading.text;\n lines.push(` ${indent}h${heading.level} ${text}${heading.hidden ? \" (hidden)\" : \"\"}`);\n }\n if (page.heading_total > page.headings.length) {\n lines.push(` … and ${page.heading_total - page.headings.length} more headings (the first ${MAX_SEO_HEADINGS} are listed)`);\n }\n }\n }\n\n return lines.join(\"\\n\");\n}\n\n/** The short form of a finding, for the problems line at the top. */\nfunction headline(finding: SeoFinding): string {\n const [first] = finding.detail.split(\" — \");\n return first.length > 90 ? `${first.slice(0, 90)}…` : first;\n}\n\nfunction count(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n\n/* ── Helpers ──────────────────────────────────────────────────────────── */\n\n/** Resolve a possibly relative URL against the page. Returns undefined for anything unusable. */\nfunction resolveUrl(href: string | undefined, base: string): string | undefined {\n if (!href) return undefined;\n try {\n const resolved = new URL(href, base);\n // A data: URI is already the image; there is nothing to fetch and nothing\n // a network could fail to load.\n return resolved.protocol === \"http:\" || resolved.protocol === \"https:\" ? resolved.href : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction firstLine(error: unknown): string {\n return (error instanceof Error ? error.message : String(error)).split(\"\\n\")[0];\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * One actionable line for an audit that could not run. Mirrors\n * `describeFailure` in screenshot.ts: match on the failing Playwright call,\n * never on substrings of a user-supplied selector.\n */\nexport function describeSeoFailure(\n input: { url: string; wait_for?: string; wait_for_timeout_ms: number },\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const line = message.split(\"\\n\")[0];\n const prefix = `SEO audit of ${input.url} failed:`;\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n `${prefix} Playwright's Chromium browser is not installed. ` +\n `Run \\`npx playwright install chromium\\` and try again. (${line})`\n );\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `${prefix} selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms.`;\n }\n if (/^page\\.goto:/.test(message)) {\n return `${prefix} the page could not be opened — ${line}`;\n }\n return `${prefix} ${line}`;\n}\n\nexport function registerSeoTool(server: McpServer): void {\n server.registerTool(\n SEO_TOOL_NAME,\n {\n title: \"SEO\",\n description:\n \"Audit what a search engine and a link preview would make of a page: title and meta description (with \" +\n \"lengths), canonical, robots directives and robots.txt, Open Graph and Twitter card tags, the heading \" +\n \"outline, images with no alt text, and JSON-LD structured data checked against what Google's rich \" +\n \"results require. Everything is read from the rendered DOM, so a client-rendered app is measured \" +\n \"properly rather than reported as empty. The og:image is fetched and measured — a share image that \" +\n \"404s or is too small is invisible from the page itself — and comes back as an image to look at. \" +\n \"Set `include_performance: true` to add LCP, CLS, TTFB, page weight and DOM size from the same load.\",\n inputSchema: seoInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => auditSeo(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { getSessionPage } from "../engine/browser.js";
|
|
5
|
+
import { type PageSnapshot } from "../engine/snapshot.js";
|
|
6
|
+
import { type VueInfo } from "../utils/vue-rules.js";
|
|
7
|
+
import { type ResolvedAuth } from "../utils/storage-state.js";
|
|
8
|
+
import type { Viewport } from "../types.js";
|
|
9
|
+
export declare const SNAPSHOT_TOOL_NAME = "framewatch_snapshot";
|
|
10
|
+
export declare const snapshotInputShape: {
|
|
11
|
+
url: z.ZodOptional<z.ZodString>;
|
|
12
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
13
|
+
mode: z.ZodDefault<z.ZodEnum<["full", "interactive"]>>;
|
|
14
|
+
max_chars: z.ZodDefault<z.ZodNumber>;
|
|
15
|
+
include_screenshot: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
+
include_components: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
19
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
20
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
21
|
+
width: z.ZodNumber;
|
|
22
|
+
height: z.ZodNumber;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}, {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}>>;
|
|
30
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
31
|
+
};
|
|
32
|
+
export declare const snapshotInputSchema: z.ZodObject<{
|
|
33
|
+
url: z.ZodOptional<z.ZodString>;
|
|
34
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
35
|
+
mode: z.ZodDefault<z.ZodEnum<["full", "interactive"]>>;
|
|
36
|
+
max_chars: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
include_screenshot: z.ZodDefault<z.ZodBoolean>;
|
|
38
|
+
include_components: z.ZodDefault<z.ZodBoolean>;
|
|
39
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
41
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
width: z.ZodNumber;
|
|
44
|
+
height: z.ZodNumber;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}, {
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
}>>;
|
|
52
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
53
|
+
}, "strip", z.ZodTypeAny, {
|
|
54
|
+
wait_ms: number;
|
|
55
|
+
wait_for_timeout_ms: number;
|
|
56
|
+
mode: "full" | "interactive";
|
|
57
|
+
max_chars: number;
|
|
58
|
+
include_screenshot: boolean;
|
|
59
|
+
include_components: boolean;
|
|
60
|
+
url?: string | undefined;
|
|
61
|
+
viewport?: {
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
} | undefined;
|
|
65
|
+
wait_for?: string | undefined;
|
|
66
|
+
storage_state?: string | undefined;
|
|
67
|
+
selector?: string | undefined;
|
|
68
|
+
}, {
|
|
69
|
+
url?: string | undefined;
|
|
70
|
+
viewport?: {
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
} | undefined;
|
|
74
|
+
wait_ms?: number | undefined;
|
|
75
|
+
wait_for?: string | undefined;
|
|
76
|
+
wait_for_timeout_ms?: number | undefined;
|
|
77
|
+
storage_state?: string | undefined;
|
|
78
|
+
selector?: string | undefined;
|
|
79
|
+
mode?: "full" | "interactive" | undefined;
|
|
80
|
+
max_chars?: number | undefined;
|
|
81
|
+
include_screenshot?: boolean | undefined;
|
|
82
|
+
include_components?: boolean | undefined;
|
|
83
|
+
}>;
|
|
84
|
+
export type SnapshotInput = z.input<typeof snapshotInputSchema>;
|
|
85
|
+
/**
|
|
86
|
+
* Read the page as a tree of named elements, on the interact session's page so
|
|
87
|
+
* the refs it hands out are the ones the next `framewatch_interact` or
|
|
88
|
+
* `framewatch_inspect` can use.
|
|
89
|
+
*/
|
|
90
|
+
export declare function snapshotPage(rawInput: SnapshotInput): Promise<CallToolResult>;
|
|
91
|
+
export declare function describeCounts(snapshot: Pick<PageSnapshot, "elements" | "interactive">): string;
|
|
92
|
+
/** Input the session-page tools share: where to open, how long to wait, what size. */
|
|
93
|
+
export interface SessionPageInput {
|
|
94
|
+
url?: string;
|
|
95
|
+
wait_ms?: number;
|
|
96
|
+
wait_for?: string;
|
|
97
|
+
wait_for_timeout_ms?: number;
|
|
98
|
+
viewport?: Viewport;
|
|
99
|
+
storage_state?: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The session page, opened at `url` when one is given. Shared by snapshot and
|
|
103
|
+
* inspect, which both read the page interact drives; `toolName` is only for
|
|
104
|
+
* the error wording.
|
|
105
|
+
*/
|
|
106
|
+
export interface OpenedSessionPage {
|
|
107
|
+
page: Awaited<ReturnType<typeof getSessionPage>>["page"];
|
|
108
|
+
vue: VueInfo | null;
|
|
109
|
+
/** The saved auth in force for this call, if any. */
|
|
110
|
+
auth: ResolvedAuth | null;
|
|
111
|
+
/** Whether the page showed a login form after opening — only checked when `url` was given. */
|
|
112
|
+
login_visible: boolean;
|
|
113
|
+
}
|
|
114
|
+
export declare function openSessionPage(input: SessionPageInput, toolName: string): Promise<OpenedSessionPage | {
|
|
115
|
+
error: string;
|
|
116
|
+
}>;
|
|
117
|
+
export declare function describeSessionFailure(toolName: string, input: {
|
|
118
|
+
url?: string;
|
|
119
|
+
wait_for?: string;
|
|
120
|
+
wait_for_timeout_ms?: number;
|
|
121
|
+
}, error: unknown): string;
|
|
122
|
+
export declare function registerSnapshotTool(server: McpServer): void;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_SNAPSHOT_MAX_CHARS, DEFAULT_SNAPSHOT_WAIT_MS, MAX_SNAPSHOT_MAX_CHARS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, VUE_READY_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { getSessionPage, withSessionLock } from "../engine/browser.js";
|
|
4
|
+
import { hmrFor } from "../engine/hmr.js";
|
|
5
|
+
import { takeSnapshot } from "../engine/snapshot.js";
|
|
6
|
+
import { componentTree, detectVue, waitForVueReady } from "../engine/vue.js";
|
|
7
|
+
import { describeVue, formatComponentTree } from "../utils/vue-rules.js";
|
|
8
|
+
import { describeScale } from "../utils/format.js";
|
|
9
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
10
|
+
import { loginFormVisible, resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
11
|
+
export const SNAPSHOT_TOOL_NAME = "framewatch_snapshot";
|
|
12
|
+
export const snapshotInputShape = {
|
|
13
|
+
url: z
|
|
14
|
+
.string()
|
|
15
|
+
.url()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Open this URL first. Omit to read the page left open by framewatch_interact / framewatch_inspect."),
|
|
18
|
+
selector: z.string().optional().describe("Only this container, e.g. `main` or `#checkout`"),
|
|
19
|
+
mode: z
|
|
20
|
+
.enum(["full", "interactive"])
|
|
21
|
+
.default("full")
|
|
22
|
+
.describe("`full` is the whole tree with headings and text; `interactive` is a flat list of what can be clicked, typed into or chosen"),
|
|
23
|
+
max_chars: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.min(200)
|
|
27
|
+
.max(MAX_SNAPSHOT_MAX_CHARS)
|
|
28
|
+
.default(DEFAULT_SNAPSHOT_MAX_CHARS)
|
|
29
|
+
.describe("Cut the tree past this many characters (a note says how many lines went)"),
|
|
30
|
+
include_screenshot: z.boolean().default(false).describe("Also return a screenshot of the page as it was read"),
|
|
31
|
+
include_components: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.default(false)
|
|
34
|
+
.describe("On a Vue app: also return the component tree (names and nesting) from the root"),
|
|
35
|
+
wait_ms: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.min(0)
|
|
39
|
+
.default(DEFAULT_SNAPSHOT_WAIT_MS)
|
|
40
|
+
.describe("Settle time (ms) after opening `url`. On a Vue app this is a ceiling: the page is read as soon as the app " +
|
|
41
|
+
"is mounted and its router ready"),
|
|
42
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before reading"),
|
|
43
|
+
wait_for_timeout_ms: z.number().int().min(1).default(SELECTOR_TIMEOUT_MS).describe("Max time (ms) to wait for `wait_for`"),
|
|
44
|
+
viewport: z
|
|
45
|
+
.object({
|
|
46
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH),
|
|
47
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT),
|
|
48
|
+
})
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Resize the page to this first (defaults to leaving it as it is)"),
|
|
51
|
+
storage_state: storageStateField,
|
|
52
|
+
};
|
|
53
|
+
export const snapshotInputSchema = z.object(snapshotInputShape);
|
|
54
|
+
/**
|
|
55
|
+
* Read the page as a tree of named elements, on the interact session's page so
|
|
56
|
+
* the refs it hands out are the ones the next `framewatch_interact` or
|
|
57
|
+
* `framewatch_inspect` can use.
|
|
58
|
+
*/
|
|
59
|
+
export function snapshotPage(rawInput) {
|
|
60
|
+
return withSessionLock(() => runSnapshot(rawInput));
|
|
61
|
+
}
|
|
62
|
+
async function runSnapshot(rawInput) {
|
|
63
|
+
const parsed = snapshotInputSchema.safeParse(rawInput);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
66
|
+
return errorResult(`Snapshot failed: invalid input — ${issues}`);
|
|
67
|
+
}
|
|
68
|
+
const input = parsed.data;
|
|
69
|
+
try {
|
|
70
|
+
const opened = await openSessionPage(input, "Snapshot");
|
|
71
|
+
if ("error" in opened)
|
|
72
|
+
return errorResult(opened.error);
|
|
73
|
+
const { page, vue, auth, login_visible } = opened;
|
|
74
|
+
const snapshot = await takeSnapshot(page, {
|
|
75
|
+
...(input.selector ? { selector: input.selector } : {}),
|
|
76
|
+
mode: input.mode,
|
|
77
|
+
max_chars: input.max_chars,
|
|
78
|
+
});
|
|
79
|
+
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
80
|
+
const title = await page.title().catch(() => "");
|
|
81
|
+
const lines = [
|
|
82
|
+
`Snapshot of ${page.url()}${title ? ` — "${title}"` : ""} — ${describeScale(viewport).replace(/, images.*$/, "")} — ${describeCounts(snapshot)}${vue ? ` — ${describeVue(vue)}` : ""}`,
|
|
83
|
+
...(input.include_screenshot ? [describeScale(viewport)] : []),
|
|
84
|
+
...(input.selector ? [`Scope: ${input.selector}`] : []),
|
|
85
|
+
REF_HINT,
|
|
86
|
+
"",
|
|
87
|
+
snapshot.text,
|
|
88
|
+
...(snapshot.cut_lines > 0
|
|
89
|
+
? [`… ${snapshot.cut_lines} more lines cut — raise \`max_chars\`, pass \`selector\`, or use mode "interactive".`]
|
|
90
|
+
: []),
|
|
91
|
+
];
|
|
92
|
+
if (input.include_components) {
|
|
93
|
+
lines.push("", ...(await describeComponents(page, vue)));
|
|
94
|
+
}
|
|
95
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
96
|
+
if (input.include_screenshot) {
|
|
97
|
+
const png = await page.screenshot({ type: "png" });
|
|
98
|
+
content.unshift({ type: "image", data: toBase64(await resizeForOutput(png)), mimeType: "image/png" });
|
|
99
|
+
}
|
|
100
|
+
hmrFor(page).markSeen();
|
|
101
|
+
return withAuthNote({ content }, auth, login_visible);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return errorResult(describeSessionFailure("Snapshot", input, error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const REF_HINT = "Refs: pass one as `ref` to framewatch_interact (to act on it) or framewatch_inspect (to measure it). " +
|
|
108
|
+
"They stay valid until the page changes — snapshot again after an action that re-rendered. " +
|
|
109
|
+
'In a framewatch_capture script, target the same element with a selector such as role=button[name="Sign in"].';
|
|
110
|
+
export function describeCounts(snapshot) {
|
|
111
|
+
return `${snapshot.elements} elements, ${snapshot.interactive} interactive`;
|
|
112
|
+
}
|
|
113
|
+
/** The component tree, or why there is none. */
|
|
114
|
+
async function describeComponents(page, vue) {
|
|
115
|
+
if (!vue)
|
|
116
|
+
return ["Components: no Vue app on this page"];
|
|
117
|
+
if (vue.major < 3)
|
|
118
|
+
return ["Components: Vue 2 — component details need Vue 3"];
|
|
119
|
+
if (vue.production)
|
|
120
|
+
return ["Components: production build — no component data on elements"];
|
|
121
|
+
const tree = await componentTree(page);
|
|
122
|
+
if (!tree)
|
|
123
|
+
return ["Components: could not be read"];
|
|
124
|
+
return formatComponentTree(tree.root, tree.total);
|
|
125
|
+
}
|
|
126
|
+
export async function openSessionPage(input, toolName) {
|
|
127
|
+
const options = {};
|
|
128
|
+
if (input.viewport)
|
|
129
|
+
options.viewport = input.viewport;
|
|
130
|
+
const auth = await resolveStorageState(input.storage_state);
|
|
131
|
+
if (auth)
|
|
132
|
+
options.storageState = { path: auth.path, state: auth.state };
|
|
133
|
+
const { page, previousUrl } = await getSessionPage(options);
|
|
134
|
+
const target = input.url ?? previousUrl;
|
|
135
|
+
if (target !== undefined) {
|
|
136
|
+
await page.goto(target, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
137
|
+
if (input.wait_for) {
|
|
138
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms ?? SELECTOR_TIMEOUT_MS });
|
|
139
|
+
}
|
|
140
|
+
// `wait_ms` is a ceiling, not a sleep: a Vue app is read the moment it is
|
|
141
|
+
// mounted and its router has resolved; a page without one gets the full wait.
|
|
142
|
+
const { vue } = await waitForVueReady(page, { detect_ms: input.wait_ms ?? 0, ready_ms: VUE_READY_TIMEOUT_MS });
|
|
143
|
+
if (page.url() === "about:blank")
|
|
144
|
+
return { error: noPage(toolName) };
|
|
145
|
+
return { page, vue, auth, login_visible: auth && input.url !== undefined ? await loginFormVisible(page) : false };
|
|
146
|
+
}
|
|
147
|
+
if (page.url() === "about:blank")
|
|
148
|
+
return { error: noPage(toolName) };
|
|
149
|
+
return { page, vue: await detectVue(page), auth, login_visible: false };
|
|
150
|
+
}
|
|
151
|
+
function noPage(toolName) {
|
|
152
|
+
return `${toolName} failed: no page is open yet — pass \`url\` to open one (e.g. http://localhost:3000).`;
|
|
153
|
+
}
|
|
154
|
+
export function describeSessionFailure(toolName, input, error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
const firstLine = message.split("\n")[0];
|
|
157
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
158
|
+
return `${toolName} failed: Playwright's Chromium browser is not installed. Run \`npx playwright install chromium\` and try again. (${firstLine})`;
|
|
159
|
+
}
|
|
160
|
+
if (/^page\.goto:/.test(message)) {
|
|
161
|
+
return `${toolName} failed: could not open ${input.url ?? "the page"} — ${firstLine}`;
|
|
162
|
+
}
|
|
163
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
164
|
+
return `${toolName} failed: selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms ?? SELECTOR_TIMEOUT_MS}ms.`;
|
|
165
|
+
}
|
|
166
|
+
return `${toolName} failed: ${firstLine}`;
|
|
167
|
+
}
|
|
168
|
+
function errorResult(text) {
|
|
169
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
170
|
+
}
|
|
171
|
+
export function registerSnapshotTool(server) {
|
|
172
|
+
server.registerTool(SNAPSHOT_TOOL_NAME, {
|
|
173
|
+
title: "Snapshot",
|
|
174
|
+
description: "Read the page as a tree of named elements — every heading, field, button and link with its accessible name " +
|
|
175
|
+
"and a short ref like `e8` — so you know what is on the page and what to call it instead of guessing selectors " +
|
|
176
|
+
"from a screenshot. Pass a ref as `ref` to framewatch_interact to act on that element, or to framewatch_inspect " +
|
|
177
|
+
"to measure it. Works on the page framewatch_interact keeps open; pass `url` to open one. " +
|
|
178
|
+
"Use mode `interactive` for a short list of only what can be acted on.",
|
|
179
|
+
inputSchema: snapshotInputShape,
|
|
180
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
181
|
+
}, async (args) => snapshotPage(args));
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=snapshot.js.map
|