framewatch-mcp-server 0.1.0 → 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 +895 -15
- package/dist/constants.d.ts +274 -0
- package/dist/constants.js +279 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.d.ts +20 -4
- package/dist/engine/browser.js +26 -9
- 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 +12 -7
- package/dist/engine/interaction.js +110 -18
- 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 +45 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.d.ts +4 -0
- package/dist/tools/accessibility.js +9 -2
- 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 +90 -26
- package/dist/tools/capture.js +109 -58
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.d.ts +4 -0
- package/dist/tools/compare.js +16 -5
- 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 +17 -1
- package/dist/tools/index.js +45 -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 +37 -18
- package/dist/tools/interact.js +113 -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 +10 -6
- package/dist/tools/responsive.js +21 -4
- 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 +263 -0
- package/dist/tools/save-auth.js +253 -0
- package/dist/tools/save-auth.js.map +1 -0
- package/dist/tools/screenshot.d.ts +4 -0
- package/dist/tools/screenshot.js +15 -4
- 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 +76 -0
- package/dist/utils/storage-state.js +195 -0
- package/dist/utils/storage-state.js.map +1 -0
- 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,640 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_LINKS_WAIT_MS, DEFAULT_LINK_CONCURRENCY, DEFAULT_LINK_PAGES, DEFAULT_LINK_TIMEOUT_MS, DEFAULT_MAX_LINKS, DEFAULT_VIEWPORT, HIGHLIGHT_BROKEN_COLOUR, HIGHLIGHT_DEAD_COLOUR, MAX_HIGHLIGHTS, MAX_LINKS_CAP, MAX_LINKS_LISTED, MAX_LINK_CONCURRENCY, MAX_LINK_DEPTH, MAX_LINK_PAGES, MAX_LINK_SELECTOR_LENGTH, MAX_LINK_SOURCES_LISTED, MAX_LINK_TIMEOUT_MS, MAX_LINK_URL_LENGTH, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, MIN_LINK_TIMEOUT_MS, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { checkUrl, collectLinks, findFragmentTargets, watchLoads, } from "../engine/links.js";
|
|
5
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
6
|
+
import { highlightElements } from "../utils/highlight.js";
|
|
7
|
+
import { classifyHref, dedupeKey, isAlwaysValidFragment, isInternal, judgeCheck, } from "../utils/link-rules.js";
|
|
8
|
+
import { resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
9
|
+
export const LINKS_TOOL_NAME = "framewatch_links";
|
|
10
|
+
export const linksInputShape = {
|
|
11
|
+
url: z
|
|
12
|
+
.string()
|
|
13
|
+
.url()
|
|
14
|
+
.describe("URL to check, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
15
|
+
depth: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(0)
|
|
19
|
+
.max(MAX_LINK_DEPTH)
|
|
20
|
+
.default(0)
|
|
21
|
+
.describe("How far to follow the site. 0 checks the links on this page only; 1 also opens each internal page this " +
|
|
22
|
+
"one links to and checks its links, and so on. Each level costs a real browser navigation per page."),
|
|
23
|
+
check_external: z
|
|
24
|
+
.boolean()
|
|
25
|
+
.default(true)
|
|
26
|
+
.describe("Also check links to other origins. This sends a request to somebody else's server — turn it off on a " +
|
|
27
|
+
"page full of third-party links, or when working offline."),
|
|
28
|
+
include_resources: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.default(true)
|
|
31
|
+
.describe("Also check what the page loads for itself: images, scripts, stylesheets, iframes, media. Most of these " +
|
|
32
|
+
"cost nothing to check — the browser already fetched them and its answer is reused."),
|
|
33
|
+
check_fragments: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.default(true)
|
|
36
|
+
.describe("Check that a link into this same page (`#pricing`) actually points at an element. Nothing over HTTP can " +
|
|
37
|
+
"catch this: the page loads fine and the visitor simply does not arrive where the link said."),
|
|
38
|
+
timeout_ms: z
|
|
39
|
+
.number()
|
|
40
|
+
.int()
|
|
41
|
+
.min(MIN_LINK_TIMEOUT_MS)
|
|
42
|
+
.max(MAX_LINK_TIMEOUT_MS)
|
|
43
|
+
.default(DEFAULT_LINK_TIMEOUT_MS)
|
|
44
|
+
.describe("How long one link has to answer before it is reported as timed out"),
|
|
45
|
+
concurrency: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.max(MAX_LINK_CONCURRENCY)
|
|
50
|
+
.default(DEFAULT_LINK_CONCURRENCY)
|
|
51
|
+
.describe("How many links to check at once. Lower it if a host starts answering 429."),
|
|
52
|
+
max_links: z
|
|
53
|
+
.number()
|
|
54
|
+
.int()
|
|
55
|
+
.min(1)
|
|
56
|
+
.max(MAX_LINKS_CAP)
|
|
57
|
+
.default(DEFAULT_MAX_LINKS)
|
|
58
|
+
.describe("Distinct addresses to check. Links come before resources, so the cap is spent on links first."),
|
|
59
|
+
max_pages: z
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(MAX_LINK_PAGES)
|
|
64
|
+
.default(DEFAULT_LINK_PAGES)
|
|
65
|
+
.describe("Pages to open when `depth` is above 0, including the one you named"),
|
|
66
|
+
selector: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("Only collect links inside this container, e.g. 'main' or 'footer'"),
|
|
70
|
+
wait_ms: z
|
|
71
|
+
.number()
|
|
72
|
+
.int()
|
|
73
|
+
.min(0)
|
|
74
|
+
.default(DEFAULT_LINKS_WAIT_MS)
|
|
75
|
+
.describe("Wait time (ms) after each page load before its links are read, so a client-rendered app can finish"),
|
|
76
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) after each load"),
|
|
77
|
+
wait_for_timeout_ms: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.min(1)
|
|
81
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
82
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
83
|
+
full_page: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.default(false)
|
|
86
|
+
.describe("Photograph the whole document instead of the viewport, so broken links below the fold are visible too"),
|
|
87
|
+
viewport: z
|
|
88
|
+
.object({
|
|
89
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
90
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
91
|
+
})
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Viewport size (defaults to 1280x720)"),
|
|
94
|
+
storage_state: storageStateField,
|
|
95
|
+
};
|
|
96
|
+
export const linksInputSchema = z.object(linksInputShape);
|
|
97
|
+
/**
|
|
98
|
+
* Check every link on a page — and, on request, every link on the pages it
|
|
99
|
+
* leads to — and report the ones that do not work.
|
|
100
|
+
*
|
|
101
|
+
* The page is read after it has rendered rather than from its HTML source, so
|
|
102
|
+
* a client-rendered app is checked properly instead of being reported as
|
|
103
|
+
* having no links. The checks go out through the browser context, carrying the
|
|
104
|
+
* same cookies as the page, so a link behind a login is checked as the
|
|
105
|
+
* signed-in visitor sees it.
|
|
106
|
+
*
|
|
107
|
+
* Three things separate this from a HEAD loop. A HEAD that fails is retried as
|
|
108
|
+
* a GET before anything is called broken, because a great many servers refuse
|
|
109
|
+
* the method and serve the URL perfectly well. A status that means "I will not
|
|
110
|
+
* answer that" (401, 403, 429) is reported apart from one that means "there is
|
|
111
|
+
* nothing here", because a report that cries wolf gets ignored. And a
|
|
112
|
+
* same-page `#fragment` is checked against the document, which is the one
|
|
113
|
+
* broken link no HTTP check can ever find.
|
|
114
|
+
*/
|
|
115
|
+
export async function checkLinks(rawInput) {
|
|
116
|
+
const parsed = linksInputSchema.safeParse(rawInput);
|
|
117
|
+
if (!parsed.success) {
|
|
118
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
119
|
+
return errorResult(`Link check failed: invalid input — ${issues}`);
|
|
120
|
+
}
|
|
121
|
+
const input = parsed.data;
|
|
122
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
123
|
+
let contextOptions = {};
|
|
124
|
+
let auth = null;
|
|
125
|
+
try {
|
|
126
|
+
auth = await resolveStorageState(input.storage_state);
|
|
127
|
+
if (auth)
|
|
128
|
+
contextOptions = { storageState: auth.state };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return errorResult(`Link check of ${input.url} failed: ${firstLine(error)}`);
|
|
132
|
+
}
|
|
133
|
+
let crawl;
|
|
134
|
+
try {
|
|
135
|
+
crawl = await withPage({ viewport, contextOptions }, (page, context) => runCrawl(input, page, context));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return errorResult(describeLinksFailure(input, error));
|
|
139
|
+
}
|
|
140
|
+
return withAuthNote(await render(input, crawl), auth);
|
|
141
|
+
}
|
|
142
|
+
/* ── The crawl ────────────────────────────────────────────────────────── */
|
|
143
|
+
async function runCrawl(input, page, context) {
|
|
144
|
+
const watch = watchLoads(page);
|
|
145
|
+
const targets = new Map();
|
|
146
|
+
const skipped = new Map();
|
|
147
|
+
const deadFragments = new Map();
|
|
148
|
+
const visited = new Set();
|
|
149
|
+
const queue = [{ url: input.url, depth: 0 }];
|
|
150
|
+
const pages = [];
|
|
151
|
+
const unreachablePages = [];
|
|
152
|
+
let found = 0;
|
|
153
|
+
let fragmentsOk = 0;
|
|
154
|
+
let overCap = 0;
|
|
155
|
+
const note = (map, key, display, reason, suspect, where) => {
|
|
156
|
+
const existing = map.get(key);
|
|
157
|
+
if (existing)
|
|
158
|
+
existing.occurrences.push(where);
|
|
159
|
+
else
|
|
160
|
+
map.set(key, { key, display, reason, occurrences: [where], suspect });
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
while (queue.length > 0 && pages.length < input.max_pages) {
|
|
164
|
+
const { url, depth } = queue.shift();
|
|
165
|
+
const pageKey = dedupeKey(url);
|
|
166
|
+
if (visited.has(pageKey))
|
|
167
|
+
continue;
|
|
168
|
+
visited.add(pageKey);
|
|
169
|
+
try {
|
|
170
|
+
await load(input, page, url);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
// The page the caller named is the whole run; anything found later is
|
|
174
|
+
// one page of many and is reported rather than thrown.
|
|
175
|
+
if (pages.length === 0)
|
|
176
|
+
throw error;
|
|
177
|
+
unreachablePages.push({ url, error: firstLine(error) });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const pageUrl = safely(() => page.url()) ?? url;
|
|
181
|
+
pages.push(pageUrl);
|
|
182
|
+
const raw = await collectLinks(page, {
|
|
183
|
+
...(input.selector !== undefined ? { selector: input.selector } : {}),
|
|
184
|
+
include_resources: input.include_resources,
|
|
185
|
+
});
|
|
186
|
+
found += raw.length;
|
|
187
|
+
const wantedFragments = new Map();
|
|
188
|
+
const fresh = [];
|
|
189
|
+
for (const link of raw) {
|
|
190
|
+
const where = occurrenceOf(link, pageUrl);
|
|
191
|
+
const classified = classifyHref(link.href, pageUrl);
|
|
192
|
+
switch (classified.kind) {
|
|
193
|
+
case "http": {
|
|
194
|
+
const external = !isInternal(classified.resolved, pageUrl);
|
|
195
|
+
if (external && !input.check_external) {
|
|
196
|
+
note(skipped, `external:${dedupeKey(classified.resolved)}`, classified.resolved, "another origin, and `check_external` is off", false, where);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const key = dedupeKey(classified.resolved);
|
|
200
|
+
const existing = targets.get(key);
|
|
201
|
+
if (existing) {
|
|
202
|
+
existing.occurrences.push(where);
|
|
203
|
+
// A link matters more than a resource: if the same address is
|
|
204
|
+
// both, it is reported as the link.
|
|
205
|
+
if (where.role === "link")
|
|
206
|
+
existing.role = "link";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (targets.size >= input.max_links) {
|
|
210
|
+
overCap++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const target = { key, url: key, occurrences: [where], external, role: where.role };
|
|
214
|
+
targets.set(key, target);
|
|
215
|
+
fresh.push(target);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
case "same_page": {
|
|
219
|
+
const fragment = classified.fragment ?? "";
|
|
220
|
+
if (!input.check_fragments) {
|
|
221
|
+
note(skipped, `fragment:${pageUrl}#${fragment}`, `#${fragment}`, "a link into this page, and `check_fragments` is off", false, where);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const list = wantedFragments.get(fragment) ?? [];
|
|
225
|
+
list.push(where);
|
|
226
|
+
wantedFragments.set(fragment, list);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
case "empty":
|
|
230
|
+
case "malformed":
|
|
231
|
+
case "javascript":
|
|
232
|
+
note(skipped, `${classified.kind}:${link.href}`, link.href || "(an empty href)", classified.reason ?? classified.kind, classified.kind !== "javascript", where);
|
|
233
|
+
continue;
|
|
234
|
+
default: {
|
|
235
|
+
const display = link.href;
|
|
236
|
+
note(skipped, `${classified.kind}:${display}`, display, classified.reason ?? `a ${classified.kind}: link, which no checker can follow`, classified.reason !== undefined, where);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (wantedFragments.size > 0) {
|
|
242
|
+
// `#` and `#top` are the top of the document in every browser and need
|
|
243
|
+
// no element; everything else has to be found in the DOM.
|
|
244
|
+
const wanted = [...wantedFragments.keys()].filter((fragment) => !isAlwaysValidFragment(fragment));
|
|
245
|
+
const present = new Set([
|
|
246
|
+
...(await findFragmentTargets(page, wanted)),
|
|
247
|
+
...[...wantedFragments.keys()].filter(isAlwaysValidFragment),
|
|
248
|
+
]);
|
|
249
|
+
for (const [fragment, occurrences] of wantedFragments) {
|
|
250
|
+
if (present.has(fragment)) {
|
|
251
|
+
// Counted per distinct fragment, not per link: three anchors to
|
|
252
|
+
// #pricing are one thing that either resolves or does not.
|
|
253
|
+
fragmentsOk++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const key = `${pageUrl}#${fragment}`;
|
|
257
|
+
const existing = deadFragments.get(key);
|
|
258
|
+
if (existing)
|
|
259
|
+
existing.occurrences.push(...occurrences);
|
|
260
|
+
else
|
|
261
|
+
deadFragments.set(key, { fragment, page_url: pageUrl, occurrences });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Checked here rather than at the end, because what comes back decides
|
|
265
|
+
// what is worth opening next: a 404 is not a page to crawl, and neither
|
|
266
|
+
// is a PDF.
|
|
267
|
+
await runPool(fresh, input.concurrency, async (target) => {
|
|
268
|
+
const observed = target.role === "resource" ? watch.get(target.url) : undefined;
|
|
269
|
+
const outcome = observed
|
|
270
|
+
? {
|
|
271
|
+
chain: observed.chain.length > 0 ? observed.chain : [target.url],
|
|
272
|
+
...(observed.status !== undefined ? { status: observed.status } : {}),
|
|
273
|
+
...(observed.failure !== undefined ? { error: observed.failure } : {}),
|
|
274
|
+
method: "GET",
|
|
275
|
+
observed: true,
|
|
276
|
+
}
|
|
277
|
+
: await checkUrl(context, target.url, { timeout_ms: input.timeout_ms });
|
|
278
|
+
target.outcome = outcome;
|
|
279
|
+
target.verdict = judgeCheck(outcome);
|
|
280
|
+
});
|
|
281
|
+
if (depth < input.depth) {
|
|
282
|
+
for (const target of fresh) {
|
|
283
|
+
if (target.external || target.role !== "link")
|
|
284
|
+
continue;
|
|
285
|
+
if (target.verdict === undefined || (target.verdict.category !== "ok" && target.verdict.category !== "redirect"))
|
|
286
|
+
continue;
|
|
287
|
+
// Only a document is worth opening. An internal link to a PDF or a
|
|
288
|
+
// zip answers perfectly well and has no links on it.
|
|
289
|
+
if (!/html/i.test(target.outcome?.content_type ?? ""))
|
|
290
|
+
continue;
|
|
291
|
+
const next = target.verdict.final_url ?? target.url;
|
|
292
|
+
if (visited.has(dedupeKey(next)))
|
|
293
|
+
continue;
|
|
294
|
+
queue.push({ url: next, depth: depth + 1 });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const crawl = {
|
|
299
|
+
targets: [...targets.values()],
|
|
300
|
+
skipped: [...skipped.values()],
|
|
301
|
+
dead_fragments: [...deadFragments.values()],
|
|
302
|
+
fragments_ok: fragmentsOk,
|
|
303
|
+
found,
|
|
304
|
+
pages,
|
|
305
|
+
unreachable_pages: unreachablePages,
|
|
306
|
+
over_cap: overCap,
|
|
307
|
+
unhighlighted: 0,
|
|
308
|
+
};
|
|
309
|
+
const marked = await drawFindings(input, page, crawl, pages[0]);
|
|
310
|
+
if (marked.png)
|
|
311
|
+
crawl.png = marked.png;
|
|
312
|
+
crawl.unhighlighted = marked.unhighlighted;
|
|
313
|
+
return crawl;
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
watch.detach();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Open a page the way every other tool does, so `wait_for` means the same thing everywhere. */
|
|
320
|
+
async function load(input, page, url) {
|
|
321
|
+
await page.goto(url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
322
|
+
if (input.wait_for) {
|
|
323
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
324
|
+
}
|
|
325
|
+
if (input.wait_ms > 0) {
|
|
326
|
+
await page.waitForTimeout(input.wait_ms);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Put the entry page back on screen and box everything on it that failed.
|
|
331
|
+
*
|
|
332
|
+
* A selector says nothing about where the problem is; a picture of the page
|
|
333
|
+
* with the broken links outlined says it immediately. Only the entry page is
|
|
334
|
+
* marked up — a crawl of ten pages would otherwise return ten screenshots —
|
|
335
|
+
* and nothing is photographed at all when there is nothing to mark.
|
|
336
|
+
*/
|
|
337
|
+
async function drawFindings(input, page, crawl, entryUrl) {
|
|
338
|
+
const failing = crawl.targets.filter((target) => FAILED.has(target.verdict?.category ?? "ok"));
|
|
339
|
+
const boxes = [];
|
|
340
|
+
for (const target of failing) {
|
|
341
|
+
for (const where of target.occurrences) {
|
|
342
|
+
if (!boxable(where, entryUrl))
|
|
343
|
+
continue;
|
|
344
|
+
boxes.push({ selector: where.selector, match_index: where.match_index, broken: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const fragment of crawl.dead_fragments) {
|
|
348
|
+
if (fragment.page_url !== entryUrl)
|
|
349
|
+
continue;
|
|
350
|
+
for (const where of fragment.occurrences) {
|
|
351
|
+
if (!boxable(where, entryUrl))
|
|
352
|
+
continue;
|
|
353
|
+
boxes.push({ selector: where.selector, match_index: where.match_index, broken: false });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (boxes.length === 0)
|
|
357
|
+
return { unhighlighted: 0 };
|
|
358
|
+
try {
|
|
359
|
+
if (safely(() => page.url()) !== entryUrl)
|
|
360
|
+
await load(input, page, entryUrl);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// The entry page will not open a second time; the report stands without a
|
|
364
|
+
// picture of it.
|
|
365
|
+
return { unhighlighted: boxes.length };
|
|
366
|
+
}
|
|
367
|
+
const highlights = boxes.slice(0, MAX_HIGHLIGHTS).map((box, index) => ({
|
|
368
|
+
selector: box.selector,
|
|
369
|
+
match_index: box.match_index,
|
|
370
|
+
label: String(index + 1),
|
|
371
|
+
colour: box.broken ? HIGHLIGHT_DEAD_COLOUR : HIGHLIGHT_BROKEN_COLOUR,
|
|
372
|
+
wash: box.broken ? "rgba(229, 25, 75, 0.16)" : "rgba(240, 140, 0, 0.16)",
|
|
373
|
+
}));
|
|
374
|
+
const drawn = await highlightElements(page, highlights);
|
|
375
|
+
const png = await safeScreenshot(page, input.full_page);
|
|
376
|
+
return { ...(png ? { png } : {}), unhighlighted: boxes.length - drawn.drawn.length };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Elements that never have a box to draw over.
|
|
380
|
+
*
|
|
381
|
+
* A broken stylesheet is a real finding and lives in `<head>`, where it has no
|
|
382
|
+
* geometry at all. Asking for a box over one is not a near miss to be counted
|
|
383
|
+
* and explained — it is a question with no answer, so it is never asked.
|
|
384
|
+
*/
|
|
385
|
+
const UNRENDERED = new Set(["script", "link", "meta", "source", "track", "base"]);
|
|
386
|
+
/** Can this occurrence be pointed at in a screenshot of the entry page? */
|
|
387
|
+
function boxable(where, entryUrl) {
|
|
388
|
+
return where.page_url === entryUrl && where.selector !== "" && !UNRENDERED.has(where.tag);
|
|
389
|
+
}
|
|
390
|
+
/** Categories that mean the link does not work for a visitor. */
|
|
391
|
+
const FAILED = new Set(["broken", "timeout", "error"]);
|
|
392
|
+
function occurrenceOf(link, pageUrl) {
|
|
393
|
+
return {
|
|
394
|
+
page_url: pageUrl,
|
|
395
|
+
description: link.description,
|
|
396
|
+
selector: link.selector,
|
|
397
|
+
match_index: link.match_index,
|
|
398
|
+
role: link.role,
|
|
399
|
+
tag: link.tag,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** Run `worker` over `items`, `limit` at a time. Every one of these is a real request. */
|
|
403
|
+
async function runPool(items, limit, worker) {
|
|
404
|
+
let next = 0;
|
|
405
|
+
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
406
|
+
for (;;) {
|
|
407
|
+
const index = next++;
|
|
408
|
+
if (index >= items.length)
|
|
409
|
+
return;
|
|
410
|
+
await worker(items[index]);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
await Promise.all(runners);
|
|
414
|
+
}
|
|
415
|
+
/* ── Rendering ────────────────────────────────────────────────────────── */
|
|
416
|
+
async function render(input, crawl) {
|
|
417
|
+
const content = [{ type: "text", text: formatCrawl(input, crawl) }];
|
|
418
|
+
if (crawl.png) {
|
|
419
|
+
try {
|
|
420
|
+
content.push({ type: "image", data: toBase64(await resizeForOutput(crawl.png)), mimeType: "image/png" });
|
|
421
|
+
content.push({
|
|
422
|
+
type: "text",
|
|
423
|
+
text: `${crawl.pages[0]}, with every link that failed boxed in red and every dead fragment in orange, ` +
|
|
424
|
+
"numbered in the order they appear above." +
|
|
425
|
+
(crawl.unhighlighted > 0
|
|
426
|
+
? ` ${crawl.unhighlighted} could not be boxed — those elements are hidden, or the page had re-rendered by the time the overlay was drawn.`
|
|
427
|
+
: ""),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Measured already; failing to re-encode the frame is not worth failing
|
|
432
|
+
// the check for.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { content };
|
|
436
|
+
}
|
|
437
|
+
function formatCrawl(input, crawl) {
|
|
438
|
+
const of = (category) => crawl.targets.filter((t) => t.verdict?.category === category);
|
|
439
|
+
const broken = of("broken");
|
|
440
|
+
const timedOut = of("timeout");
|
|
441
|
+
const unreachable = of("error");
|
|
442
|
+
const redirected = of("redirect");
|
|
443
|
+
const blocked = of("blocked");
|
|
444
|
+
const working = of("ok");
|
|
445
|
+
const notChecked = crawl.skipped.length + crawl.over_cap;
|
|
446
|
+
const lines = [];
|
|
447
|
+
const counts = [
|
|
448
|
+
`${broken.length} broken`,
|
|
449
|
+
...(timedOut.length > 0 ? [`${timedOut.length} timed out`] : []),
|
|
450
|
+
...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
|
|
451
|
+
...(crawl.dead_fragments.length > 0 ? [`${count(crawl.dead_fragments.length, "dead fragment")}`] : []),
|
|
452
|
+
...(redirected.length > 0 ? [`${redirected.length} redirected`] : []),
|
|
453
|
+
...(blocked.length > 0 ? [`${blocked.length} blocked`] : []),
|
|
454
|
+
`${working.length} working`,
|
|
455
|
+
...(notChecked > 0 ? [`${notChecked} not checked`] : []),
|
|
456
|
+
];
|
|
457
|
+
lines.push(`Link check of ${input.url} — ${count(crawl.found, "link")} found on ` +
|
|
458
|
+
`${count(crawl.pages.length, "page")} (${unique(crawl)} unique), ${counts.join(", ")}.`);
|
|
459
|
+
if (crawl.found === 0) {
|
|
460
|
+
lines.push("Nothing on this page points anywhere — no link, image, script or stylesheet.");
|
|
461
|
+
return lines.join("\n");
|
|
462
|
+
}
|
|
463
|
+
const failures = broken.length + timedOut.length + unreachable.length + crawl.dead_fragments.length;
|
|
464
|
+
if (failures === 0) {
|
|
465
|
+
lines.push("Every link that could be checked answered.");
|
|
466
|
+
}
|
|
467
|
+
if (crawl.fragments_ok > 0) {
|
|
468
|
+
lines.push(`${count(crawl.fragments_ok, "same-page fragment")} resolved against the document rather than over HTTP.`);
|
|
469
|
+
}
|
|
470
|
+
for (const page of crawl.unreachable_pages) {
|
|
471
|
+
lines.push(`The crawl could not open ${elide(page.url, MAX_LINK_URL_LENGTH)} — ${page.error}`);
|
|
472
|
+
}
|
|
473
|
+
section(lines, `Broken (${broken.length})`, broken, crawl);
|
|
474
|
+
section(lines, `Timed out (${timedOut.length})`, timedOut, crawl);
|
|
475
|
+
section(lines, `Could not be reached (${unreachable.length})`, unreachable, crawl);
|
|
476
|
+
if (crawl.dead_fragments.length > 0) {
|
|
477
|
+
lines.push("");
|
|
478
|
+
lines.push(`Fragments that point at nothing (${crawl.dead_fragments.length}):`);
|
|
479
|
+
for (const fragment of crawl.dead_fragments.slice(0, MAX_LINKS_LISTED)) {
|
|
480
|
+
lines.push(` #${fragment.fragment} — no element on the page has that id, and no <a name> either`);
|
|
481
|
+
for (const line of sourceLines(fragment.occurrences, crawl))
|
|
482
|
+
lines.push(line);
|
|
483
|
+
}
|
|
484
|
+
if (crawl.dead_fragments.length > MAX_LINKS_LISTED) {
|
|
485
|
+
lines.push(` … and ${crawl.dead_fragments.length - MAX_LINKS_LISTED} more`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
section(lines, `Redirected (${redirected.length})`, redirected, crawl);
|
|
489
|
+
section(lines, `Blocked — the server refused the check, not necessarily the link (${blocked.length})`, blocked, crawl);
|
|
490
|
+
if (crawl.skipped.length > 0 || crawl.over_cap > 0) {
|
|
491
|
+
lines.push("");
|
|
492
|
+
lines.push(`Not checked (${notChecked}):`);
|
|
493
|
+
const ordered = [...crawl.skipped].sort((a, b) => Number(b.suspect) - Number(a.suspect));
|
|
494
|
+
for (const entry of ordered.slice(0, MAX_LINKS_LISTED)) {
|
|
495
|
+
lines.push(` ${elide(entry.display, MAX_LINK_URL_LENGTH)} — ${entry.reason}`);
|
|
496
|
+
for (const line of sourceLines(entry.occurrences, crawl))
|
|
497
|
+
lines.push(line);
|
|
498
|
+
}
|
|
499
|
+
if (ordered.length > MAX_LINKS_LISTED) {
|
|
500
|
+
lines.push(` … and ${ordered.length - MAX_LINKS_LISTED} more`);
|
|
501
|
+
}
|
|
502
|
+
if (crawl.over_cap > 0) {
|
|
503
|
+
lines.push(` ${count(crawl.over_cap, "address")} past \`max_links\` (${input.max_links}) — raise it, or narrow ` +
|
|
504
|
+
"the run with `selector`.");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (working.length > 0) {
|
|
508
|
+
lines.push("");
|
|
509
|
+
lines.push(`Working (${working.length}):`);
|
|
510
|
+
for (const target of working.slice(0, MAX_LINKS_LISTED)) {
|
|
511
|
+
lines.push(` ${elide(target.url, MAX_LINK_URL_LENGTH)} — ${detailOf(target)}`);
|
|
512
|
+
}
|
|
513
|
+
if (working.length > MAX_LINKS_LISTED) {
|
|
514
|
+
lines.push(` … and ${working.length - MAX_LINKS_LISTED} more that answered`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return lines.join("\n");
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Distinct addresses this run reached a verdict on.
|
|
521
|
+
*
|
|
522
|
+
* Everything that was judged, however it was judged: requested, refused,
|
|
523
|
+
* skipped for a reason, or looked up in the document. Leaving the fragments
|
|
524
|
+
* out would make the number quietly disagree with the sections beneath it.
|
|
525
|
+
*/
|
|
526
|
+
function unique(crawl) {
|
|
527
|
+
return crawl.targets.length + crawl.skipped.length + crawl.dead_fragments.length + crawl.fragments_ok;
|
|
528
|
+
}
|
|
529
|
+
/** One section of the report: the address, where it was written, and what to do. */
|
|
530
|
+
function section(lines, heading, targets, crawl) {
|
|
531
|
+
if (targets.length === 0)
|
|
532
|
+
return;
|
|
533
|
+
lines.push("");
|
|
534
|
+
lines.push(`${heading}:`);
|
|
535
|
+
for (const target of targets.slice(0, MAX_LINKS_LISTED)) {
|
|
536
|
+
lines.push(` ${elide(target.url, MAX_LINK_URL_LENGTH)} — ${detailOf(target)}`);
|
|
537
|
+
for (const line of sourceLines(target.occurrences, crawl))
|
|
538
|
+
lines.push(line);
|
|
539
|
+
const fix = target.verdict?.fix;
|
|
540
|
+
if (fix)
|
|
541
|
+
lines.push(` → ${fix}`);
|
|
542
|
+
}
|
|
543
|
+
if (targets.length > MAX_LINKS_LISTED) {
|
|
544
|
+
lines.push(` … and ${targets.length - MAX_LINKS_LISTED} more`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* What happened, plus a note when the answer came from the browser rather than
|
|
549
|
+
* from a request of this tool's own — the two are not quite the same claim,
|
|
550
|
+
* and the difference matters when they disagree with a curl.
|
|
551
|
+
*/
|
|
552
|
+
function detailOf(target) {
|
|
553
|
+
const detail = target.verdict?.detail ?? "no verdict";
|
|
554
|
+
return target.outcome?.observed === true ? `${detail} (seen when the page loaded it)` : detail;
|
|
555
|
+
}
|
|
556
|
+
/** `from a "Pricing" (#nav-pricing)`, naming the page too once there is more than one. */
|
|
557
|
+
function sourceLines(occurrences, crawl) {
|
|
558
|
+
const lines = [];
|
|
559
|
+
const multipage = crawl.pages.length > 1;
|
|
560
|
+
for (const where of occurrences.slice(0, MAX_LINK_SOURCES_LISTED)) {
|
|
561
|
+
const selector = where.selector === "" ? "" : ` (${elide(where.selector, MAX_LINK_SELECTOR_LENGTH)})`;
|
|
562
|
+
const page = multipage ? ` on ${elide(where.page_url, MAX_LINK_URL_LENGTH)}` : "";
|
|
563
|
+
lines.push(` from ${where.description}${selector}${page}`);
|
|
564
|
+
}
|
|
565
|
+
if (occurrences.length > MAX_LINK_SOURCES_LISTED) {
|
|
566
|
+
lines.push(` … and ${occurrences.length - MAX_LINK_SOURCES_LISTED} more places`);
|
|
567
|
+
}
|
|
568
|
+
return lines;
|
|
569
|
+
}
|
|
570
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
571
|
+
/** A screenshot is always worth having and never worth failing the check over. */
|
|
572
|
+
async function safeScreenshot(page, fullPage) {
|
|
573
|
+
try {
|
|
574
|
+
if (page.isClosed())
|
|
575
|
+
return undefined;
|
|
576
|
+
return await page.screenshot({ type: "png", fullPage });
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function elide(value, max) {
|
|
583
|
+
const flat = String(value).replace(/\s+/g, " ");
|
|
584
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
585
|
+
}
|
|
586
|
+
function count(n, noun) {
|
|
587
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
588
|
+
}
|
|
589
|
+
function safely(read) {
|
|
590
|
+
try {
|
|
591
|
+
return read();
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function firstLine(error) {
|
|
598
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
599
|
+
}
|
|
600
|
+
function errorResult(text) {
|
|
601
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* One actionable line for a check that could not run. Mirrors `describeFailure`
|
|
605
|
+
* in screenshot.ts: match on the failing Playwright call, never on substrings
|
|
606
|
+
* of a user-supplied selector.
|
|
607
|
+
*/
|
|
608
|
+
export function describeLinksFailure(input, error) {
|
|
609
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
610
|
+
const line = firstLine(message);
|
|
611
|
+
const prefix = `Link check of ${input.url} failed:`;
|
|
612
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
613
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
614
|
+
`Run \`npx playwright install chromium\` and try again. (${line})`);
|
|
615
|
+
}
|
|
616
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
617
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
618
|
+
}
|
|
619
|
+
if (/^page\.goto:/.test(message)) {
|
|
620
|
+
return `${prefix} the page could not be opened — ${line}`;
|
|
621
|
+
}
|
|
622
|
+
return `${prefix} ${line}`;
|
|
623
|
+
}
|
|
624
|
+
export function registerLinksTool(server) {
|
|
625
|
+
server.registerTool(LINKS_TOOL_NAME, {
|
|
626
|
+
title: "Links",
|
|
627
|
+
description: "Check every link on a page and report the ones that do not work: 404s, server errors, redirect " +
|
|
628
|
+
"chains, redirects that end on an error page, hosts that never answer, and `#fragment` links that " +
|
|
629
|
+
"point at no element on the page. Links are read from the rendered DOM, so a client-rendered app is " +
|
|
630
|
+
"checked properly. What the page loads for itself — images, scripts, stylesheets, iframes — is checked " +
|
|
631
|
+
"too, reusing the browser's own result rather than requesting it again. A HEAD that fails is retried " +
|
|
632
|
+
"as a GET before anything is called broken, and a 401/403/429 is reported as a refused check rather " +
|
|
633
|
+
"than a broken link. Set `depth` above 0 to follow the site's own internal links a level at a time. " +
|
|
634
|
+
"Returns the findings grouped by what went wrong, plus a screenshot of the page with the failing " +
|
|
635
|
+
"links boxed. NOTE: with `check_external` on this sends requests to third-party servers.",
|
|
636
|
+
inputSchema: linksInputShape,
|
|
637
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
638
|
+
}, async (args) => checkLinks(args));
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=links.js.map
|