pagesight 0.5.1 → 0.6.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.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -322,11 +322,15 @@ export function registerMetatagsTool(server: McpServer): void {
322
322
  // Redirect chain (only if there were redirects)
323
323
  if (chain.length > 1) {
324
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}`);
325
+ const parts: string[] = [];
326
+ for (const hop of chain) {
327
+ parts.push(hop.url);
328
+ parts.push(String(hop.status));
329
329
  }
330
+ // Format: URL → status → URL → status (drop trailing status)
331
+ parts.pop();
332
+ output.push(parts.join(" → "));
333
+ output.push(`Hops: ${chain.length - 1}`);
330
334
  output.push("");
331
335
  }
332
336
 
@@ -352,9 +356,27 @@ export function registerMetatagsTool(server: McpServer): void {
352
356
  output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
353
357
  output.push(` URL: ${check.url}`);
354
358
  } else {
355
- const size = check.contentLength ? ` (${Math.round(check.contentLength / 1024)} KB)` : "";
359
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
360
+ const size = sizeKB ? ` (${sizeKB} KB)` : "";
356
361
  const type = check.contentType ? ` ${check.contentType}` : "";
357
362
  output.push(`${check.tag}: OK —${type}${size}`);
363
+ if (sizeKB && sizeKB > 1024) {
364
+ output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
365
+ }
366
+ }
367
+
368
+ // Check declared dimensions from meta tags
369
+ if (check.tag === "og:image") {
370
+ const w = getMeta(parsed.meta, "og:image:width");
371
+ const h = getMeta(parsed.meta, "og:image:height");
372
+ if (w && h) {
373
+ output.push(` Declared dimensions: ${w}x${h}`);
374
+ const wn = Number(w);
375
+ const hn = Number(h);
376
+ if (wn && hn && (wn < 1200 || hn < 630)) {
377
+ output.push(` Note: recommended minimum for og:image is 1200x630`);
378
+ }
379
+ }
358
380
  }
359
381
  }
360
382
  }
@@ -2,6 +2,28 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../lib/gsc.js";
4
4
 
5
+ interface Totals {
6
+ clicks: number;
7
+ impressions: number;
8
+ ctr: number;
9
+ position: number;
10
+ }
11
+
12
+ function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
13
+ const r = rows ?? [];
14
+ const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
15
+ const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
16
+ const ctr = impressions > 0 ? clicks / impressions : 0;
17
+ const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
18
+ return { clicks, impressions, ctr, position };
19
+ }
20
+
21
+ function pctChange(current: number, previous: number): string {
22
+ if (previous === 0) return current > 0 ? "+∞" : "0%";
23
+ const change = ((current - previous) / previous) * 100;
24
+ return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
25
+ }
26
+
5
27
  function formatPerformance(
6
28
  siteUrl: string,
7
29
  result: SearchAnalyticsResponse,
@@ -32,19 +54,15 @@ function formatPerformance(
32
54
  return lines.join("\n");
33
55
  }
34
56
 
35
- const totalClicks = rows.reduce((sum, r) => sum + r.clicks, 0);
36
- const totalImpressions = rows.reduce((sum, r) => sum + r.impressions, 0);
37
- const avgCtr = totalImpressions > 0 ? totalClicks / totalImpressions : 0;
38
- const avgPosition =
39
- totalImpressions > 0 ? rows.reduce((sum, r) => sum + r.position * r.impressions, 0) / totalImpressions : 0;
57
+ const totals = computeTotals(rows);
40
58
 
41
59
  lines.push(
42
60
  `--- Summary (${rows.length} rows returned) ---`,
43
61
  "",
44
- `Clicks: ${totalClicks.toLocaleString()}`,
45
- `Impressions: ${totalImpressions.toLocaleString()}`,
46
- `Avg CTR: ${(avgCtr * 100).toFixed(1)}%`,
47
- `Avg Position: ${avgPosition.toFixed(1)}`,
62
+ `Clicks: ${totals.clicks.toLocaleString()}`,
63
+ `Impressions: ${totals.impressions.toLocaleString()}`,
64
+ `Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
65
+ `Avg Position: ${totals.position.toFixed(1)}`,
48
66
  "",
49
67
  "--- Top Results ---",
50
68
  "",
@@ -66,6 +84,103 @@ function formatPerformance(
66
84
  return lines.join("\n");
67
85
  }
68
86
 
87
+ function formatComparison(
88
+ siteUrl: string,
89
+ current: SearchAnalyticsResponse,
90
+ previous: SearchAnalyticsResponse,
91
+ dimensions: string[],
92
+ currentStart: string,
93
+ currentEnd: string,
94
+ previousStart: string,
95
+ previousEnd: string,
96
+ ): string {
97
+ const curRows = current.rows ?? [];
98
+ const prevRows = previous.rows ?? [];
99
+ const cur = computeTotals(curRows);
100
+ const prev = computeTotals(prevRows);
101
+
102
+ const lines: string[] = [
103
+ `=== Search Performance: ${siteUrl} ===`,
104
+ `Current: ${currentStart} to ${currentEnd}`,
105
+ `Previous: ${previousStart} to ${previousEnd}`,
106
+ `Dimensions: ${dimensions.join(", ")}`,
107
+ "",
108
+ "--- Summary ---",
109
+ "",
110
+ ` Current Previous Change`,
111
+ `Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
112
+ `Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
113
+ `Avg CTR: ${`${(cur.ctr * 100).toFixed(1)}%`.padEnd(12)} ${`${(prev.ctr * 100).toFixed(1)}%`.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`,
114
+ `Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
115
+ "",
116
+ ];
117
+
118
+ // Build lookup for previous period rows
119
+ const prevMap = new Map<string, (typeof prevRows)[0]>();
120
+ for (const row of prevRows) {
121
+ prevMap.set(row.keys.join("|"), row);
122
+ }
123
+
124
+ // Find biggest movers (by click change)
125
+ const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
126
+ for (const row of curRows) {
127
+ const key = row.keys.join("|");
128
+ const prevRow = prevMap.get(key);
129
+ movers.push({
130
+ keys: row.keys,
131
+ curClicks: row.clicks,
132
+ prevClicks: prevRow?.clicks ?? 0,
133
+ curPos: row.position,
134
+ prevPos: prevRow?.position ?? 0,
135
+ });
136
+ }
137
+
138
+ // Also include rows that disappeared (were in previous but not in current)
139
+ const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
140
+ for (const row of prevRows) {
141
+ const key = row.keys.join("|");
142
+ if (!curKeys.has(key)) {
143
+ movers.push({
144
+ keys: row.keys,
145
+ curClicks: 0,
146
+ prevClicks: row.clicks,
147
+ curPos: 0,
148
+ prevPos: row.position,
149
+ });
150
+ }
151
+ }
152
+
153
+ // Sort by absolute click change descending
154
+ movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
155
+
156
+ const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
157
+ const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
158
+
159
+ if (improved.length > 0) {
160
+ lines.push("--- Improved ---", "");
161
+ for (const m of improved) {
162
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
163
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
164
+ lines.push(`${keys}`);
165
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
166
+ }
167
+ lines.push("");
168
+ }
169
+
170
+ if (regressed.length > 0) {
171
+ lines.push("--- Regressed ---", "");
172
+ for (const m of regressed) {
173
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
174
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
175
+ lines.push(`${keys}`);
176
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
177
+ }
178
+ lines.push("");
179
+ }
180
+
181
+ return lines.join("\n");
182
+ }
183
+
69
184
  function daysAgo(n: number): string {
70
185
  // GSC dates are in PT (Pacific Time). Use UTC-8 as a stable approximation.
71
186
  const now = new Date(Date.now() - 8 * 60 * 60 * 1000);
@@ -121,6 +236,12 @@ export function registerPerformanceTool(server: McpServer): void {
121
236
  .describe("Dimension filters. Combined with AND logic."),
122
237
  row_limit: z.number().optional().describe("Max rows (1-25000). Default: 1000."),
123
238
  start_row: z.number().optional().describe("Zero-based offset for pagination. Default: 0."),
239
+ compare: z
240
+ .boolean()
241
+ .optional()
242
+ .describe(
243
+ "Compare against the previous period of equal length. Shows click/impression/CTR/position deltas and top movers.",
244
+ ),
124
245
  },
125
246
  async ({
126
247
  site_url,
@@ -133,6 +254,7 @@ export function registerPerformanceTool(server: McpServer): void {
133
254
  filters,
134
255
  row_limit,
135
256
  start_row,
257
+ compare,
136
258
  }) => {
137
259
  const startDate = start_date ?? daysAgo(28);
138
260
  const endDate = end_date ?? daysAgo(3);
@@ -143,19 +265,54 @@ export function registerPerformanceTool(server: McpServer): void {
143
265
  ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
144
266
  : undefined;
145
267
 
268
+ const queryOpts = {
269
+ startDate,
270
+ endDate,
271
+ dimensions: dims,
272
+ type: search_type,
273
+ dataState: data_state,
274
+ aggregationType: aggregation_type,
275
+ rowLimit: row_limit ?? 1000,
276
+ startRow: start_row,
277
+ dimensionFilterGroups: filterGroups,
278
+ };
279
+
146
280
  try {
147
- const result = await querySearchAnalytics(site_url, {
148
- startDate,
149
- endDate,
150
- dimensions: dims,
151
- type: search_type,
152
- dataState: data_state,
153
- aggregationType: aggregation_type,
154
- rowLimit: row_limit ?? 1000,
155
- startRow: start_row,
156
- dimensionFilterGroups: filterGroups,
157
- });
281
+ if (compare) {
282
+ // Calculate previous period of equal length
283
+ const start = new Date(startDate);
284
+ const end = new Date(endDate);
285
+ const durationMs = end.getTime() - start.getTime();
286
+ const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
287
+ const prevStart = new Date(prevEnd.getTime() - durationMs);
288
+ const prevStartDate = prevStart.toISOString().split("T")[0];
289
+ const prevEndDate = prevEnd.toISOString().split("T")[0];
290
+
291
+ const [current, previous] = await Promise.all([
292
+ querySearchAnalytics(site_url, queryOpts),
293
+ querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
294
+ ]);
295
+
296
+ return {
297
+ content: [
298
+ {
299
+ type: "text",
300
+ text: formatComparison(
301
+ site_url,
302
+ current,
303
+ previous,
304
+ dims,
305
+ startDate,
306
+ endDate,
307
+ prevStartDate,
308
+ prevEndDate,
309
+ ),
310
+ },
311
+ ],
312
+ };
313
+ }
158
314
 
315
+ const result = await querySearchAnalytics(site_url, queryOpts);
159
316
  return {
160
317
  content: [{ type: "text", text: formatPerformance(site_url, result, dims, startDate, endDate) }],
161
318
  };