pagesight 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
package/src/tools/crux.ts CHANGED
@@ -193,14 +193,15 @@ export function registerCruxTool(server: McpServer): void {
193
193
  } catch (err) {
194
194
  const msg = err instanceof Error ? err.message : String(err);
195
195
  if (msg.includes("404")) {
196
- return {
197
- content: [
198
- {
199
- type: "text",
200
- text: `No CrUX data available for ${url ?? origin}. The page may not have enough Chrome user traffic.`,
201
- },
202
- ],
203
- };
196
+ const target = url ?? origin ?? "";
197
+ const lines = [`No CrUX data for ${target}.`, ""];
198
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
199
+ if (url) {
200
+ const originUrl = new URL(url).origin;
201
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
202
+ }
203
+ lines.push("For lab metrics without traffic requirements, use the pagespeed tool.");
204
+ return { content: [{ type: "text", text: lines.join("\n") }] };
204
205
  }
205
206
  if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
206
207
  return {
@@ -268,14 +269,15 @@ export function registerCruxTool(server: McpServer): void {
268
269
  } catch (err) {
269
270
  const msg = err instanceof Error ? err.message : String(err);
270
271
  if (msg.includes("404")) {
271
- return {
272
- content: [
273
- {
274
- type: "text",
275
- text: `No CrUX history data for ${url ?? origin}. The page may not have enough Chrome user traffic.`,
276
- },
277
- ],
278
- };
272
+ const target = url ?? origin ?? "";
273
+ const lines = [`No CrUX history data for ${target}.`, ""];
274
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
275
+ if (url) {
276
+ const originUrl = new URL(url).origin;
277
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
278
+ }
279
+ lines.push("For lab metrics without traffic requirements, use the pagespeed tool.");
280
+ return { content: [{ type: "text", text: lines.join("\n") }] };
279
281
  }
280
282
  if (msg.includes("SERVICE_DISABLED")) {
281
283
  return {
@@ -134,6 +134,77 @@ function formatJsonLd(data: unknown, indent = 0): string[] {
134
134
  return lines;
135
135
  }
136
136
 
137
+ interface RedirectHop {
138
+ url: string;
139
+ status: number;
140
+ }
141
+
142
+ interface ImageCheck {
143
+ url: string;
144
+ tag: string;
145
+ status: number | null;
146
+ contentType: string | null;
147
+ contentLength: number | null;
148
+ error: string | null;
149
+ }
150
+
151
+ async function followRedirects(
152
+ url: string,
153
+ ua: string,
154
+ maxHops = 10,
155
+ ): Promise<{ chain: RedirectHop[]; response: Response }> {
156
+ const chain: RedirectHop[] = [];
157
+ let current = url;
158
+
159
+ for (let i = 0; i < maxHops; i++) {
160
+ const res = await fetch(current, {
161
+ headers: { "User-Agent": ua, Accept: "text/html" },
162
+ redirect: "manual",
163
+ });
164
+
165
+ chain.push({ url: current, status: res.status });
166
+
167
+ if (res.status >= 300 && res.status < 400) {
168
+ const location = res.headers.get("location");
169
+ if (!location) break;
170
+ current = new URL(location, current).href;
171
+ continue;
172
+ }
173
+
174
+ return { chain, response: res };
175
+ }
176
+
177
+ // If we exhausted hops, do a final follow-redirect fetch
178
+ const res = await fetch(current, {
179
+ headers: { "User-Agent": ua, Accept: "text/html" },
180
+ redirect: "follow",
181
+ });
182
+ return { chain, response: res };
183
+ }
184
+
185
+ async function checkImage(imageUrl: string, tag: string): Promise<ImageCheck> {
186
+ try {
187
+ const res = await fetch(imageUrl, { method: "HEAD", redirect: "follow" });
188
+ return {
189
+ url: imageUrl,
190
+ tag,
191
+ status: res.status,
192
+ contentType: res.headers.get("content-type"),
193
+ contentLength: res.headers.has("content-length") ? Number(res.headers.get("content-length")) : null,
194
+ error: null,
195
+ };
196
+ } catch (err) {
197
+ return {
198
+ url: imageUrl,
199
+ tag,
200
+ status: null,
201
+ contentType: null,
202
+ contentLength: null,
203
+ error: err instanceof Error ? err.message : String(err),
204
+ };
205
+ }
206
+ }
207
+
137
208
  function formatMetatags(url: string, parsed: ParsedHead): string {
138
209
  const lines: string[] = [`=== Meta Tags: ${url} ===`, ""];
139
210
 
@@ -231,13 +302,7 @@ export function registerMetatagsTool(server: McpServer): void {
231
302
  async ({ url, user_agent }) => {
232
303
  try {
233
304
  const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
234
- const res = await fetch(url, {
235
- headers: {
236
- "User-Agent": ua,
237
- Accept: "text/html",
238
- },
239
- redirect: "follow",
240
- });
305
+ const { chain, response: res } = await followRedirects(url, ua);
241
306
 
242
307
  if (!res.ok) {
243
308
  return {
@@ -252,15 +317,50 @@ export function registerMetatagsTool(server: McpServer): void {
252
317
 
253
318
  const html = await res.text();
254
319
  const parsed = parseHead(html);
255
- const finalUrl = res.url !== url ? `(redirected to ${res.url})\n\n` : "";
320
+ const output: string[] = [];
321
+
322
+ // Redirect chain (only if there were redirects)
323
+ if (chain.length > 1) {
324
+ output.push("--- Redirect Chain ---", "");
325
+ for (let i = 0; i < chain.length; i++) {
326
+ const hop = chain[i];
327
+ const prefix = i === chain.length - 1 ? "" : `${hop.status} → `;
328
+ output.push(`${i + 1}. ${prefix}${hop.url}`);
329
+ }
330
+ output.push("");
331
+ }
332
+
333
+ // Main meta tag report
334
+ const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
335
+ output.push(formatMetatags(finalUrl, parsed));
336
+
337
+ // OG/Twitter image validation
338
+ const ogImage = getMeta(parsed.meta, "og:image");
339
+ const twitterImage = getMeta(parsed.meta, "twitter:image");
340
+ const imagesToCheck: Array<{ url: string; tag: string }> = [];
341
+ if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
342
+ if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
343
+
344
+ if (imagesToCheck.length > 0) {
345
+ const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
346
+ output.push("", "--- Image Validation ---", "");
347
+ for (const check of checks) {
348
+ if (check.error) {
349
+ output.push(`${check.tag}: FAILED — ${check.error}`);
350
+ output.push(` URL: ${check.url}`);
351
+ } else if (check.status && check.status >= 400) {
352
+ output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
353
+ output.push(` URL: ${check.url}`);
354
+ } else {
355
+ const size = check.contentLength ? ` (${Math.round(check.contentLength / 1024)} KB)` : "";
356
+ const type = check.contentType ? ` ${check.contentType}` : "";
357
+ output.push(`${check.tag}: OK —${type}${size}`);
358
+ }
359
+ }
360
+ }
256
361
 
257
362
  return {
258
- content: [
259
- {
260
- type: "text",
261
- text: `${finalUrl}${formatMetatags(res.url, parsed)}`,
262
- },
263
- ],
363
+ content: [{ type: "text", text: output.join("\n") }],
264
364
  };
265
365
  } catch (err) {
266
366
  const msg = err instanceof Error ? err.message : String(err);