pagesight 0.16.0 → 0.18.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 +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +26 -97
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +59 -20
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -852
- package/src/tools/speed.ts +0 -956
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
interface SchemaRule {
|
|
2
|
+
source: string;
|
|
3
|
+
required: string[];
|
|
4
|
+
requiredAny?: string[];
|
|
5
|
+
recommended: string[];
|
|
6
|
+
imageFields: string[];
|
|
7
|
+
nestedRequired?: Record<string, string[]>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ValidationIssue {
|
|
11
|
+
type: string;
|
|
12
|
+
level: "required" | "recommended";
|
|
13
|
+
field: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatJsonLdType(value: unknown, separator = ", "): string {
|
|
17
|
+
if (typeof value === "string") return value;
|
|
18
|
+
if (Array.isArray(value) && value.every((item): item is string => typeof item === "string"))
|
|
19
|
+
return value.join(separator);
|
|
20
|
+
return JSON.stringify(value) ?? "undefined";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatJsonLd(data: unknown, indent = 0): string[] {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
const pad = " ".repeat(indent);
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(data)) {
|
|
28
|
+
for (const item of data) {
|
|
29
|
+
lines.push(...formatJsonLd(item, indent));
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (data && typeof data === "object") {
|
|
35
|
+
const obj = data as Record<string, unknown>;
|
|
36
|
+
const type = obj["@type"];
|
|
37
|
+
if (type) {
|
|
38
|
+
const label = formatJsonLdType(type);
|
|
39
|
+
lines.push(`${pad}@type: ${label}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
43
|
+
if (key.startsWith("@") && key !== "@type") continue;
|
|
44
|
+
if (key === "@type") continue;
|
|
45
|
+
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
|
|
46
|
+
const str = String(val);
|
|
47
|
+
lines.push(`${pad}${key}: ${str.length > 100 ? `${str.slice(0, 100)}...` : str}`);
|
|
48
|
+
} else if (Array.isArray(val)) {
|
|
49
|
+
lines.push(`${pad}${key}: [${val.length} items]`);
|
|
50
|
+
} else if (val && typeof val === "object") {
|
|
51
|
+
const nested = val as Record<string, unknown>;
|
|
52
|
+
if (nested["@type"]) {
|
|
53
|
+
lines.push(`${pad}${key}:`);
|
|
54
|
+
lines.push(...formatJsonLd(nested, indent + 1));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Presence checks for selected Google-documented fields, reviewed 2026-09-08.
|
|
64
|
+
// These do not cover every conditional requirement, value rule, or eligibility policy.
|
|
65
|
+
const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
66
|
+
WebSite: {
|
|
67
|
+
source: "https://developers.google.com/search/docs/appearance/site-names",
|
|
68
|
+
required: ["name", "url"],
|
|
69
|
+
recommended: ["alternateName"],
|
|
70
|
+
imageFields: [],
|
|
71
|
+
},
|
|
72
|
+
Organization: {
|
|
73
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/organization",
|
|
74
|
+
required: [],
|
|
75
|
+
recommended: ["name", "url", "logo", "sameAs", "contactPoint"],
|
|
76
|
+
imageFields: ["logo"],
|
|
77
|
+
},
|
|
78
|
+
Article: {
|
|
79
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
80
|
+
required: [],
|
|
81
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
82
|
+
imageFields: ["image"],
|
|
83
|
+
},
|
|
84
|
+
NewsArticle: {
|
|
85
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
86
|
+
required: [],
|
|
87
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
88
|
+
imageFields: ["image"],
|
|
89
|
+
},
|
|
90
|
+
Product: {
|
|
91
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/product-snippet",
|
|
92
|
+
required: ["name"],
|
|
93
|
+
requiredAny: ["offers", "review", "aggregateRating"],
|
|
94
|
+
recommended: ["offers", "review", "aggregateRating"],
|
|
95
|
+
imageFields: ["image"],
|
|
96
|
+
},
|
|
97
|
+
LocalBusiness: {
|
|
98
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/local-business",
|
|
99
|
+
required: ["name", "address"],
|
|
100
|
+
recommended: ["telephone", "openingHoursSpecification", "url"],
|
|
101
|
+
imageFields: ["image"],
|
|
102
|
+
},
|
|
103
|
+
BreadcrumbList: {
|
|
104
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/breadcrumb",
|
|
105
|
+
required: ["itemListElement"],
|
|
106
|
+
recommended: [],
|
|
107
|
+
imageFields: [],
|
|
108
|
+
},
|
|
109
|
+
Event: {
|
|
110
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/event",
|
|
111
|
+
required: ["name", "startDate", "location"],
|
|
112
|
+
recommended: ["endDate", "image", "description", "offers", "organizer"],
|
|
113
|
+
imageFields: ["image"],
|
|
114
|
+
},
|
|
115
|
+
Recipe: {
|
|
116
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/recipe",
|
|
117
|
+
required: ["name", "image"],
|
|
118
|
+
recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"],
|
|
119
|
+
imageFields: ["image"],
|
|
120
|
+
},
|
|
121
|
+
VideoObject: {
|
|
122
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/video",
|
|
123
|
+
required: ["name", "thumbnailUrl", "uploadDate"],
|
|
124
|
+
recommended: ["description", "duration", "contentUrl", "embedUrl"],
|
|
125
|
+
imageFields: ["thumbnailUrl"],
|
|
126
|
+
},
|
|
127
|
+
SoftwareApplication: {
|
|
128
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/software-app",
|
|
129
|
+
required: ["name", "offers"],
|
|
130
|
+
requiredAny: ["review", "aggregateRating"],
|
|
131
|
+
recommended: ["applicationCategory", "operatingSystem", "review", "aggregateRating"],
|
|
132
|
+
imageFields: ["image"],
|
|
133
|
+
nestedRequired: { offers: ["price"] },
|
|
134
|
+
},
|
|
135
|
+
Dataset: {
|
|
136
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/dataset",
|
|
137
|
+
required: ["name", "description"],
|
|
138
|
+
recommended: ["distribution", "creator", "license"],
|
|
139
|
+
imageFields: [],
|
|
140
|
+
nestedRequired: { distribution: ["contentUrl"] },
|
|
141
|
+
},
|
|
142
|
+
BlogPosting: {
|
|
143
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
144
|
+
required: [],
|
|
145
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
146
|
+
imageFields: ["image"],
|
|
147
|
+
},
|
|
148
|
+
Course: {
|
|
149
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/course",
|
|
150
|
+
required: ["name", "description"],
|
|
151
|
+
recommended: ["provider"],
|
|
152
|
+
imageFields: [],
|
|
153
|
+
},
|
|
154
|
+
JobPosting: {
|
|
155
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/job-posting",
|
|
156
|
+
required: ["title", "description", "datePosted", "hiringOrganization"],
|
|
157
|
+
recommended: ["employmentType", "baseSalary"],
|
|
158
|
+
imageFields: [],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
163
|
+
const val = obj[field];
|
|
164
|
+
if (val !== undefined && val !== null && val !== "" && !(Array.isArray(val) && val.length === 0)) return val;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateJsonLd(blocks: unknown[]): {
|
|
169
|
+
issues: ValidationIssue[];
|
|
170
|
+
imageUrls: string[];
|
|
171
|
+
validatedTypes: Set<string>;
|
|
172
|
+
} {
|
|
173
|
+
const issues: ValidationIssue[] = [];
|
|
174
|
+
const imageUrls: string[] = [];
|
|
175
|
+
const validatedTypes = new Set<string>();
|
|
176
|
+
|
|
177
|
+
function validateBlock(data: unknown) {
|
|
178
|
+
if (Array.isArray(data)) {
|
|
179
|
+
for (const item of data) validateBlock(item);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!data || typeof data !== "object") return;
|
|
183
|
+
|
|
184
|
+
const obj = data as Record<string, unknown>;
|
|
185
|
+
const rawType = obj["@type"];
|
|
186
|
+
const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
|
|
187
|
+
|
|
188
|
+
for (const type of types) {
|
|
189
|
+
const rule = SCHEMA_RULES[String(type)];
|
|
190
|
+
if (!rule) continue;
|
|
191
|
+
validatedTypes.add(String(type));
|
|
192
|
+
|
|
193
|
+
for (const field of rule.required) {
|
|
194
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
195
|
+
issues.push({ type: String(type), level: "required", field });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rule.requiredAny && !rule.requiredAny.some((field) => getNestedValue(obj, field) !== undefined)) {
|
|
200
|
+
issues.push({ type: String(type), level: "required", field: rule.requiredAny.join(" or ") });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const field of rule.recommended) {
|
|
204
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
205
|
+
issues.push({ type: String(type), level: "recommended", field });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check each nested object; one complete entry cannot hide an incomplete sibling.
|
|
210
|
+
if (rule.nestedRequired) {
|
|
211
|
+
for (const [parent, fields] of Object.entries(rule.nestedRequired)) {
|
|
212
|
+
const parentVal = obj[parent];
|
|
213
|
+
if (parentVal && typeof parentVal === "object") {
|
|
214
|
+
const targets = Array.isArray(parentVal) ? parentVal : [parentVal];
|
|
215
|
+
for (const target of targets) {
|
|
216
|
+
if (target && typeof target === "object") {
|
|
217
|
+
const nested = target as Record<string, unknown>;
|
|
218
|
+
for (const field of fields) {
|
|
219
|
+
if (nested[field] === undefined || nested[field] === null || nested[field] === "") {
|
|
220
|
+
issues.push({ type: String(type), level: "required", field: `${parent}.${field}` });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Collect image URLs for validation
|
|
230
|
+
for (const field of rule.imageFields) {
|
|
231
|
+
const val = obj[field];
|
|
232
|
+
if (typeof val === "string" && val.startsWith("http")) {
|
|
233
|
+
imageUrls.push(val);
|
|
234
|
+
} else if (val && typeof val === "object") {
|
|
235
|
+
const nested = val as Record<string, unknown>;
|
|
236
|
+
const url = nested.url ?? nested.contentUrl;
|
|
237
|
+
if (typeof url === "string" && url.startsWith("http")) {
|
|
238
|
+
imageUrls.push(url);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Recurse into nested objects and arrays (e.g., @graph)
|
|
245
|
+
for (const val of Object.values(obj)) {
|
|
246
|
+
if (Array.isArray(val)) {
|
|
247
|
+
for (const item of val) validateBlock(item);
|
|
248
|
+
} else if (val && typeof val === "object") {
|
|
249
|
+
validateBlock(val);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const block of blocks) validateBlock(block);
|
|
255
|
+
return { issues, imageUrls, validatedTypes };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>): string[] {
|
|
259
|
+
const lines: string[] = [
|
|
260
|
+
"Selected field-presence checks only; values, conditional rules, and Google eligibility are not fully checked.",
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
// Report only the subset checked here, not complete rich-result eligibility.
|
|
264
|
+
const typesWithRequiredIssues = new Set(issues.filter((i) => i.level === "required").map((i) => i.type));
|
|
265
|
+
for (const type of validatedTypes) {
|
|
266
|
+
if (!typesWithRequiredIssues.has(type)) {
|
|
267
|
+
lines.push(`CHECKED ${type} — no missing required fields in the checked subset`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const type of validatedTypes) lines.push(`Source (${type}): ${SCHEMA_RULES[type].source}`);
|
|
272
|
+
|
|
273
|
+
const required = issues.filter((i) => i.level === "required");
|
|
274
|
+
const recommended = issues.filter((i) => i.level === "recommended");
|
|
275
|
+
|
|
276
|
+
if (required.length > 0) {
|
|
277
|
+
for (const i of required) {
|
|
278
|
+
lines.push(`MISSING ${i.type}.${i.field} (required in checked subset)`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (recommended.length > 0) {
|
|
282
|
+
for (const i of recommended) {
|
|
283
|
+
lines.push(`OPTIONAL ${i.type}.${i.field}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { analyzePage } from "./analyze.js";
|
|
4
|
+
import { analyzeBatch } from "./batch.js";
|
|
5
|
+
import { analyzeContrast } from "./contrast.js";
|
|
6
|
+
|
|
7
|
+
export function registerPageTool(server: McpServer): void {
|
|
8
|
+
server.tool(
|
|
9
|
+
"page",
|
|
10
|
+
"Analyze what's on a page — meta tags, Open Graph, Twitter Card, structured data (JSON-LD), internal links, and redirect chains. Also includes a WCAG contrast checker for accessibility fixes.",
|
|
11
|
+
{
|
|
12
|
+
url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
|
|
13
|
+
urls: z
|
|
14
|
+
.array(z.string().url())
|
|
15
|
+
.min(2)
|
|
16
|
+
.max(10)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(
|
|
19
|
+
"Multiple URLs (2-10) for batch analysis. Returns a summary table of meta tags, structured data, and link health per page.",
|
|
20
|
+
),
|
|
21
|
+
check_links: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Check internal links for broken links and redirect chains. Default: true. Set false to skip."),
|
|
25
|
+
user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
|
|
26
|
+
foreground: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Foreground (text) hex color for WCAG contrast check (e.g., '#D4594C'). Use with background."),
|
|
30
|
+
background: z.string().optional().describe("Background hex color for contrast check (e.g., '#FFFFFF')."),
|
|
31
|
+
large_text: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
|
|
35
|
+
},
|
|
36
|
+
async (options) => {
|
|
37
|
+
const { url, urls, foreground, background, large_text } = options;
|
|
38
|
+
if (urls) return analyzeBatch(urls, options);
|
|
39
|
+
if (foreground && background && !url) return analyzeContrast(foreground, background, large_text);
|
|
40
|
+
if (url) return analyzePage(url, options);
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{ type: "text", text: "Provide 'url' for page analysis or 'foreground' + 'background' for contrast check." },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type SearchOptions } from "./schema.js";
|
|
2
|
+
import { runInspect } from "./inspection.js";
|
|
3
|
+
import { runCoverage } from "./coverage.js";
|
|
4
|
+
import { runListSites, runGetSite, runGetSitemap, runSitemaps } from "./sites.js";
|
|
5
|
+
import { runAnalytics } from "./analytics.js";
|
|
6
|
+
import { runGaps } from "./gaps.js";
|
|
7
|
+
import { runSample } from "./sample.js";
|
|
8
|
+
import { textResult } from "./result.js";
|
|
9
|
+
export async function runSearch(params: SearchOptions) {
|
|
10
|
+
const { site_url, url, sitemap_url, sample_size, filter, start_date, end_date, dimensions, filters, compare } =
|
|
11
|
+
params;
|
|
12
|
+
// ── Route to action ──
|
|
13
|
+
const hasAnalyticsParams = !!(start_date || end_date || dimensions || filters || compare);
|
|
14
|
+
|
|
15
|
+
const resolvedAction =
|
|
16
|
+
params.action ??
|
|
17
|
+
(url && site_url && !hasAnalyticsParams
|
|
18
|
+
? "inspect"
|
|
19
|
+
: sample_size || filter
|
|
20
|
+
? "sample"
|
|
21
|
+
: hasAnalyticsParams
|
|
22
|
+
? "analytics"
|
|
23
|
+
: sitemap_url && site_url
|
|
24
|
+
? "get_sitemap"
|
|
25
|
+
: site_url
|
|
26
|
+
? "sitemaps"
|
|
27
|
+
: "list_sites");
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
switch (resolvedAction) {
|
|
31
|
+
case "inspect":
|
|
32
|
+
return await runInspect(params);
|
|
33
|
+
case "sample":
|
|
34
|
+
return await runSample(params);
|
|
35
|
+
case "coverage":
|
|
36
|
+
return await runCoverage(params);
|
|
37
|
+
case "list_sites":
|
|
38
|
+
return await runListSites();
|
|
39
|
+
case "get_site":
|
|
40
|
+
return await runGetSite(params);
|
|
41
|
+
case "get_sitemap":
|
|
42
|
+
return await runGetSitemap(params);
|
|
43
|
+
case "sitemaps":
|
|
44
|
+
return await runSitemaps(params);
|
|
45
|
+
case "analytics":
|
|
46
|
+
return await runAnalytics(params);
|
|
47
|
+
case "gaps":
|
|
48
|
+
return await runGaps(params);
|
|
49
|
+
}
|
|
50
|
+
const unhandled: never = resolvedAction;
|
|
51
|
+
return textResult(`Error: Unknown action "${String(unhandled)}".`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
return textResult(`Error: ${msg}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../../providers/gsc.js";
|
|
2
|
+
import { pacificDaysAgo } from "../../shared/dates.js";
|
|
3
|
+
import { type SearchOptions } from "./schema.js";
|
|
4
|
+
import { textResult } from "./result.js";
|
|
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 ? `+${current} (new)` : "0%";
|
|
23
|
+
const change = ((current - previous) / previous) * 100;
|
|
24
|
+
return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatPerformance(
|
|
28
|
+
siteUrl: string,
|
|
29
|
+
result: SearchAnalyticsResponse,
|
|
30
|
+
dimensions: string[],
|
|
31
|
+
startDate: string,
|
|
32
|
+
endDate: string,
|
|
33
|
+
): string {
|
|
34
|
+
const rows = result.rows ?? [];
|
|
35
|
+
const lines: string[] = [
|
|
36
|
+
`=== Search Performance: ${siteUrl} ===`,
|
|
37
|
+
`Period: ${startDate} to ${endDate}`,
|
|
38
|
+
`Dimensions: ${dimensions.join(", ")}`,
|
|
39
|
+
`Aggregation: ${result.responseAggregationType ?? "auto"}`,
|
|
40
|
+
`Results: ${rows.length}`,
|
|
41
|
+
"",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (result.metadata) {
|
|
45
|
+
if (result.metadata.first_incomplete_date)
|
|
46
|
+
lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
|
|
47
|
+
if (result.metadata.first_incomplete_hour)
|
|
48
|
+
lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (rows.length === 0) {
|
|
53
|
+
lines.push("No data found for this period and filters.");
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const totals = computeTotals(rows);
|
|
58
|
+
|
|
59
|
+
if (totals.impressions < 10) {
|
|
60
|
+
lines.push("⚠ Low data volume — trends may not be meaningful.", "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push(
|
|
64
|
+
`--- Returned-row summary (${rows.length} rows; not property totals) ---`,
|
|
65
|
+
"API top-row limits and query privacy exclusions may omit data.",
|
|
66
|
+
"",
|
|
67
|
+
`Clicks: ${totals.clicks.toLocaleString()}`,
|
|
68
|
+
`Impressions: ${totals.impressions.toLocaleString()}`,
|
|
69
|
+
`Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
|
|
70
|
+
`Avg Position: ${totals.position.toFixed(1)}`,
|
|
71
|
+
"",
|
|
72
|
+
"--- Top Results ---",
|
|
73
|
+
"",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const top = rows.slice(0, 25);
|
|
77
|
+
for (const row of top) {
|
|
78
|
+
const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
79
|
+
lines.push(`${keys}`);
|
|
80
|
+
lines.push(
|
|
81
|
+
` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (rows.length > 25) {
|
|
86
|
+
lines.push("", `... and ${rows.length - 25} more rows`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatComparison(
|
|
93
|
+
siteUrl: string,
|
|
94
|
+
current: SearchAnalyticsResponse,
|
|
95
|
+
previous: SearchAnalyticsResponse,
|
|
96
|
+
dimensions: string[],
|
|
97
|
+
currentStart: string,
|
|
98
|
+
currentEnd: string,
|
|
99
|
+
previousStart: string,
|
|
100
|
+
previousEnd: string,
|
|
101
|
+
): string {
|
|
102
|
+
const curRows = current.rows ?? [];
|
|
103
|
+
const prevRows = previous.rows ?? [];
|
|
104
|
+
const cur = computeTotals(curRows);
|
|
105
|
+
const prev = computeTotals(prevRows);
|
|
106
|
+
|
|
107
|
+
const lines: string[] = [
|
|
108
|
+
`=== Search Performance: ${siteUrl} ===`,
|
|
109
|
+
`Current: ${currentStart} to ${currentEnd}`,
|
|
110
|
+
`Previous: ${previousStart} to ${previousEnd}`,
|
|
111
|
+
`Dimensions: ${dimensions.join(", ")}`,
|
|
112
|
+
"",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
if (cur.impressions < 10 && prev.impressions < 10) {
|
|
116
|
+
lines.push("⚠ Low data volume — trends may not be meaningful.", "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push("--- Returned-row summary (not property totals) ---", "");
|
|
120
|
+
lines.push("Query privacy exclusions, top-row limits and aggregation may omit data.");
|
|
121
|
+
lines.push("Position changes can reflect a different mix of impressions, not a ranking change.", "");
|
|
122
|
+
lines.push(" Current Previous Change");
|
|
123
|
+
lines.push(
|
|
124
|
+
`Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
|
|
125
|
+
);
|
|
126
|
+
lines.push(
|
|
127
|
+
`Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
|
|
128
|
+
);
|
|
129
|
+
const curCtr = `${(cur.ctr * 100).toFixed(1)}%`;
|
|
130
|
+
const prevCtr = `${(prev.ctr * 100).toFixed(1)}%`;
|
|
131
|
+
lines.push(`Avg CTR: ${curCtr.padEnd(12)} ${prevCtr.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`);
|
|
132
|
+
const posStatus =
|
|
133
|
+
prev.position === 0 && cur.position > 0
|
|
134
|
+
? "new"
|
|
135
|
+
: cur.position < prev.position
|
|
136
|
+
? "improved"
|
|
137
|
+
: cur.position > prev.position
|
|
138
|
+
? "regressed"
|
|
139
|
+
: "stable";
|
|
140
|
+
lines.push(
|
|
141
|
+
`Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${posStatus} (${(cur.position - prev.position).toFixed(1)})`,
|
|
142
|
+
);
|
|
143
|
+
lines.push("");
|
|
144
|
+
|
|
145
|
+
// Build lookup for previous period rows
|
|
146
|
+
const prevMap = new Map<string, (typeof prevRows)[0]>();
|
|
147
|
+
for (const row of prevRows) {
|
|
148
|
+
prevMap.set(JSON.stringify(row.keys), row);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find biggest movers (by click change)
|
|
152
|
+
const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
|
|
153
|
+
for (const row of curRows) {
|
|
154
|
+
const key = JSON.stringify(row.keys);
|
|
155
|
+
const prevRow = prevMap.get(key);
|
|
156
|
+
if (!prevRow) continue;
|
|
157
|
+
movers.push({
|
|
158
|
+
keys: row.keys,
|
|
159
|
+
curClicks: row.clicks,
|
|
160
|
+
prevClicks: prevRow.clicks,
|
|
161
|
+
curPos: row.position,
|
|
162
|
+
prevPos: prevRow.position,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
lines.push("Movers include only rows observed in both periods. Missing rows are unknown, not zero.", "");
|
|
167
|
+
|
|
168
|
+
movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
|
|
169
|
+
|
|
170
|
+
const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
|
|
171
|
+
const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
|
|
172
|
+
|
|
173
|
+
if (improved.length > 0) {
|
|
174
|
+
lines.push("--- Improved ---", "");
|
|
175
|
+
for (const m of improved) {
|
|
176
|
+
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
177
|
+
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
178
|
+
lines.push(`${keys}`);
|
|
179
|
+
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push("");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
lines.push("--- Regressed ---", "");
|
|
185
|
+
if (regressed.length > 0) {
|
|
186
|
+
for (const m of regressed) {
|
|
187
|
+
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
188
|
+
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
189
|
+
lines.push(`${keys}`);
|
|
190
|
+
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
lines.push("(none)");
|
|
194
|
+
}
|
|
195
|
+
lines.push("");
|
|
196
|
+
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function runAnalytics(options: SearchOptions) {
|
|
201
|
+
const {
|
|
202
|
+
site_url,
|
|
203
|
+
start_date,
|
|
204
|
+
end_date,
|
|
205
|
+
dimensions,
|
|
206
|
+
search_type,
|
|
207
|
+
data_state,
|
|
208
|
+
aggregation_type,
|
|
209
|
+
filters,
|
|
210
|
+
row_limit,
|
|
211
|
+
start_row,
|
|
212
|
+
compare,
|
|
213
|
+
} = options;
|
|
214
|
+
if (!site_url) return textResult("Error: site_url is required for analytics.");
|
|
215
|
+
|
|
216
|
+
const startDate = start_date ?? pacificDaysAgo(28);
|
|
217
|
+
const endDate = end_date ?? pacificDaysAgo(3);
|
|
218
|
+
|
|
219
|
+
if (start_date && Number.isNaN(new Date(start_date).getTime())) {
|
|
220
|
+
return textResult(`Error: invalid start_date "${start_date}". Use YYYY-MM-DD format.`);
|
|
221
|
+
}
|
|
222
|
+
if (end_date && Number.isNaN(new Date(end_date).getTime())) {
|
|
223
|
+
return textResult(`Error: invalid end_date "${end_date}". Use YYYY-MM-DD format.`);
|
|
224
|
+
}
|
|
225
|
+
const dims = dimensions ?? ["query", "page"];
|
|
226
|
+
|
|
227
|
+
const filterGroups =
|
|
228
|
+
filters && filters.length > 0
|
|
229
|
+
? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
|
|
230
|
+
: undefined;
|
|
231
|
+
|
|
232
|
+
const queryOpts = {
|
|
233
|
+
startDate,
|
|
234
|
+
endDate,
|
|
235
|
+
dimensions: dims,
|
|
236
|
+
type: search_type,
|
|
237
|
+
dataState: data_state,
|
|
238
|
+
aggregationType: aggregation_type,
|
|
239
|
+
rowLimit: row_limit ?? 1000,
|
|
240
|
+
startRow: start_row,
|
|
241
|
+
dimensionFilterGroups: filterGroups,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (compare) {
|
|
245
|
+
// Calculate previous period of equal length
|
|
246
|
+
const start = new Date(startDate);
|
|
247
|
+
const end = new Date(endDate);
|
|
248
|
+
const durationMs = end.getTime() - start.getTime();
|
|
249
|
+
const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
|
|
250
|
+
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
|
251
|
+
const prevStartDate = prevStart.toISOString().split("T")[0];
|
|
252
|
+
const prevEndDate = prevEnd.toISOString().split("T")[0];
|
|
253
|
+
|
|
254
|
+
const [current, previous] = await Promise.all([
|
|
255
|
+
querySearchAnalytics(site_url, queryOpts),
|
|
256
|
+
querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
return textResult(
|
|
260
|
+
formatComparison(site_url, current, previous, dims, startDate, endDate, prevStartDate, prevEndDate),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await querySearchAnalytics(site_url, queryOpts);
|
|
265
|
+
return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
|
|
266
|
+
}
|