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,99 @@
|
|
|
1
|
+
import { RequestError } from "../shared/http.js";
|
|
2
|
+
|
|
3
|
+
export interface Evidence {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
provider: string;
|
|
6
|
+
operation: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
target: string;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
finishedAt: string;
|
|
11
|
+
status: "ok" | "partial" | "error";
|
|
12
|
+
pages: Array<{ request: unknown; response: unknown }>;
|
|
13
|
+
pagination?: { exhausted: boolean; nextOffset: number | null; rowsReturned: number };
|
|
14
|
+
warnings: string[];
|
|
15
|
+
credential?: { source: string; type: string | null; clientEmail: string | null };
|
|
16
|
+
failedRequest?: unknown;
|
|
17
|
+
error?: { code: string; message: string; httpStatus: number | null; name?: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createEvidence(provider: string, operation: string, target: string): Evidence {
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
provider,
|
|
24
|
+
operation,
|
|
25
|
+
target,
|
|
26
|
+
startedAt: new Date().toISOString(),
|
|
27
|
+
finishedAt: "",
|
|
28
|
+
status: "ok",
|
|
29
|
+
pages: [],
|
|
30
|
+
warnings: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function fail(result: Evidence, error: unknown): void {
|
|
35
|
+
result.status = result.pages.length ? "partial" : "error";
|
|
36
|
+
result.error =
|
|
37
|
+
error instanceof RequestError
|
|
38
|
+
? { code: error.code, message: error.message, httpStatus: error.status }
|
|
39
|
+
: {
|
|
40
|
+
code: "operation_failed",
|
|
41
|
+
message: "Operation failed; check credentials, request and provider availability",
|
|
42
|
+
httpStatus: null,
|
|
43
|
+
name: error instanceof Error ? error.name : "UnknownError",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function capture(
|
|
48
|
+
provider: string,
|
|
49
|
+
operation: string,
|
|
50
|
+
target: string,
|
|
51
|
+
request: unknown,
|
|
52
|
+
run: () => Promise<unknown>,
|
|
53
|
+
warnings: string[] = [],
|
|
54
|
+
): Promise<Evidence> {
|
|
55
|
+
const result = createEvidence(provider, operation, target);
|
|
56
|
+
result.warnings.push(...warnings);
|
|
57
|
+
try {
|
|
58
|
+
result.pages.push({ request, response: await run() });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
result.failedRequest = request;
|
|
61
|
+
fail(result, error);
|
|
62
|
+
}
|
|
63
|
+
result.finishedAt = new Date().toISOString();
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function aggregate(operation: string, target: string, request: unknown, observations: Evidence[]): Evidence {
|
|
68
|
+
if (!observations.length) throw new RequestError("Select at least one observation", null, "invalid_input");
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
provider: "pagesight",
|
|
72
|
+
operation,
|
|
73
|
+
target,
|
|
74
|
+
startedAt: observations.map((o) => o.startedAt).sort()[0] ?? new Date().toISOString(),
|
|
75
|
+
finishedAt: new Date().toISOString(),
|
|
76
|
+
status: observations.every((o) => o.status === "ok")
|
|
77
|
+
? "ok"
|
|
78
|
+
: observations.every((o) => o.status === "error")
|
|
79
|
+
? "error"
|
|
80
|
+
: "partial",
|
|
81
|
+
pages: [
|
|
82
|
+
{
|
|
83
|
+
request,
|
|
84
|
+
response: {
|
|
85
|
+
observations,
|
|
86
|
+
summary: observations.map((o) => ({
|
|
87
|
+
provider: o.provider,
|
|
88
|
+
operation: o.operation,
|
|
89
|
+
...(o.name ? { name: o.name } : {}),
|
|
90
|
+
target: o.target,
|
|
91
|
+
status: o.status,
|
|
92
|
+
errorCode: o.error?.code ?? null,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
warnings: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { importUiFindings } from "./ui-findings.js";
|
|
2
|
+
import { doctor } from "./doctor.js";
|
|
3
|
+
import { ZodError } from "zod";
|
|
4
|
+
import { queryCrux, queryCruxHistory } from "../providers/crux.js";
|
|
5
|
+
import { gaCredentialInfo, gaFetch, normalizeGaProperty } from "../providers/ga.js";
|
|
6
|
+
import { inspectUrlResponse, listSitemapsResponse, listSitesResponse } from "../providers/gsc.js";
|
|
7
|
+
import { RequestError } from "../shared/http.js";
|
|
8
|
+
import { runPagespeed } from "../providers/pagespeed.js";
|
|
9
|
+
import { bingDiagnostics, bingObservation } from "./bing.js";
|
|
10
|
+
import { compareSnapshots } from "./compare-snapshots.js";
|
|
11
|
+
import { capture, type Evidence } from "./evidence.js";
|
|
12
|
+
import { gaReport, gscReport } from "./reports.js";
|
|
13
|
+
import { operationSchema } from "./schema.js";
|
|
14
|
+
import { discover } from "./discover.js";
|
|
15
|
+
import { snapshot } from "./snapshot.js";
|
|
16
|
+
import { observePage } from "../web/page-observation.js";
|
|
17
|
+
|
|
18
|
+
export type Executor = (input: unknown) => Promise<Evidence>;
|
|
19
|
+
type ParsedOperation = ReturnType<typeof operationSchema.parse>;
|
|
20
|
+
|
|
21
|
+
export async function execute(input: unknown): Promise<Evidence> {
|
|
22
|
+
let op: ParsedOperation;
|
|
23
|
+
try {
|
|
24
|
+
op = operationSchema.parse(input);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new RequestError(
|
|
27
|
+
error instanceof ZodError
|
|
28
|
+
? error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
|
29
|
+
: "Invalid operation",
|
|
30
|
+
null,
|
|
31
|
+
"invalid_input",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const result = await dispatch(op);
|
|
35
|
+
if (op.operation.startsWith("ga.")) result.credential = await gaCredentialInfo();
|
|
36
|
+
if (op.operation.startsWith("bing."))
|
|
37
|
+
result.credential = { source: "BING_WEBMASTER_API_KEY", type: "api_key", clientEmail: null };
|
|
38
|
+
const response = result.pages[0]?.response as { nextPageToken?: string } | undefined;
|
|
39
|
+
if (response?.nextPageToken) {
|
|
40
|
+
result.status = "partial";
|
|
41
|
+
result.warnings.push("More metadata pages are available; nextPageToken is preserved in the response.");
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dispatch(op: ParsedOperation): Promise<Evidence> {
|
|
47
|
+
switch (op.operation) {
|
|
48
|
+
case "evidence.import":
|
|
49
|
+
return importUiFindings(op.document);
|
|
50
|
+
case "compare":
|
|
51
|
+
return compareSnapshots(op.baseline, op.current, op.maxRows);
|
|
52
|
+
case "discover":
|
|
53
|
+
return discover(op.url, op.providers, execute);
|
|
54
|
+
case "bing.crawl-stats":
|
|
55
|
+
case "bing.crawl-issues":
|
|
56
|
+
return bingDiagnostics(op.operation === "bing.crawl-stats" ? "crawl-stats" : "crawl-issues", op.site);
|
|
57
|
+
case "bing.url-info":
|
|
58
|
+
return bingDiagnostics("url-info", op.site, op.url);
|
|
59
|
+
case "bing.link-counts":
|
|
60
|
+
return bingDiagnostics("link-counts", op.site, undefined, op.maxPages);
|
|
61
|
+
case "bing.url-links":
|
|
62
|
+
return bingDiagnostics("url-links", op.site, op.url, op.maxPages);
|
|
63
|
+
case "bing.sites":
|
|
64
|
+
return bingObservation("sites");
|
|
65
|
+
case "bing.queries":
|
|
66
|
+
return bingObservation("queries", op.site);
|
|
67
|
+
case "bing.pages":
|
|
68
|
+
return bingObservation("pages", op.site);
|
|
69
|
+
case "bing.traffic":
|
|
70
|
+
return bingObservation("traffic", op.site);
|
|
71
|
+
case "gsc.sites":
|
|
72
|
+
return capture("gsc", "sites", "accessible-properties", {}, listSitesResponse);
|
|
73
|
+
case "gsc.sitemaps":
|
|
74
|
+
return capture("gsc", "sitemaps", op.site, { site: op.site }, () => listSitemapsResponse(op.site), [
|
|
75
|
+
"contents[].indexed is deprecated and must not be interpreted.",
|
|
76
|
+
]);
|
|
77
|
+
case "gsc.inspect":
|
|
78
|
+
return capture(
|
|
79
|
+
"gsc",
|
|
80
|
+
"inspect",
|
|
81
|
+
op.site,
|
|
82
|
+
{ inspectionUrl: op.url, siteUrl: op.site },
|
|
83
|
+
() => inspectUrlResponse(op.url, op.site),
|
|
84
|
+
["Inspection describes Google's indexed state, not a live fetch. This is one URL, not site coverage."],
|
|
85
|
+
);
|
|
86
|
+
case "gsc.report":
|
|
87
|
+
return gscReport(op.site, op.request, op.maxPages);
|
|
88
|
+
case "ga.accounts":
|
|
89
|
+
return capture(
|
|
90
|
+
"ga",
|
|
91
|
+
"accounts",
|
|
92
|
+
"accessible-properties",
|
|
93
|
+
{ pageSize: 200 },
|
|
94
|
+
() => gaFetch("accountSummaries?pageSize=200", undefined, true),
|
|
95
|
+
["If nextPageToken is present, this discovery response is incomplete."],
|
|
96
|
+
);
|
|
97
|
+
case "ga.property":
|
|
98
|
+
return capture("ga", "property", normalizeGaProperty(op.property), {}, () =>
|
|
99
|
+
gaFetch(normalizeGaProperty(op.property), undefined, true),
|
|
100
|
+
);
|
|
101
|
+
case "ga.key-events":
|
|
102
|
+
return capture(
|
|
103
|
+
"ga",
|
|
104
|
+
"key-events",
|
|
105
|
+
normalizeGaProperty(op.property),
|
|
106
|
+
{ pageSize: 200 },
|
|
107
|
+
() => gaFetch(`${normalizeGaProperty(op.property)}/keyEvents?pageSize=200`, undefined, true),
|
|
108
|
+
[
|
|
109
|
+
"Configured key events are not necessarily validated product outcomes; inspect event names. Check nextPageToken.",
|
|
110
|
+
],
|
|
111
|
+
);
|
|
112
|
+
case "ga.report":
|
|
113
|
+
return gaReport(op.property, op.request, op.maxPages);
|
|
114
|
+
case "page":
|
|
115
|
+
return capture("web", "page", op.url, { url: op.url }, () => observePage(op.url));
|
|
116
|
+
case "speed.psi":
|
|
117
|
+
return capture(
|
|
118
|
+
"psi",
|
|
119
|
+
"run",
|
|
120
|
+
op.url,
|
|
121
|
+
{ url: op.url, strategy: op.strategy, categories: ["performance", "seo"] },
|
|
122
|
+
() => runPagespeed(op.url, { strategy: op.strategy, categories: ["performance", "seo"] }),
|
|
123
|
+
["Single Lighthouse lab run; not field performance or ranking evidence."],
|
|
124
|
+
);
|
|
125
|
+
case "speed.crux":
|
|
126
|
+
case "speed.history": {
|
|
127
|
+
const request = {
|
|
128
|
+
...(op.origin ? { origin: new URL(op.url).origin } : { url: op.url }),
|
|
129
|
+
formFactor: op.formFactor,
|
|
130
|
+
};
|
|
131
|
+
return capture(
|
|
132
|
+
"crux",
|
|
133
|
+
op.operation,
|
|
134
|
+
op.url,
|
|
135
|
+
request,
|
|
136
|
+
() => (op.operation === "speed.crux" ? queryCrux(request) : queryCruxHistory(request)),
|
|
137
|
+
[
|
|
138
|
+
"NOT_FOUND means no record for the requested scope, not zero performance. CrUX aggregates 28-day windows; history periods overlap.",
|
|
139
|
+
],
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
case "doctor":
|
|
143
|
+
return doctor(op.config, execute);
|
|
144
|
+
case "snapshot":
|
|
145
|
+
return snapshot(op, execute);
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { execute } from "./execute.js";
|
|
2
|
+
export type { Evidence } from "./evidence.js";
|
|
3
|
+
export { configSchema, type Operation, operationSchema } from "./schema.js";
|
|
4
|
+
export { evidenceSchema, snapshotEvidenceSchema } from "./evidence-schema.js";
|
|
5
|
+
export { RequestError } from "../shared/http.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type GaReport, gaFetch, normalizeGaProperty } from "../providers/ga.js";
|
|
2
|
+
import { querySearchAnalytics, type SearchAnalyticsResponse } from "../providers/gsc.js";
|
|
3
|
+
import { RequestError } from "../shared/http.js";
|
|
4
|
+
import { type Evidence, createEvidence, fail } from "./evidence.js";
|
|
5
|
+
import type { GaRequest, GscRequest } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
type Query<T> = (request: T) => Promise<SearchAnalyticsResponse | GaReport>;
|
|
8
|
+
|
|
9
|
+
async function pages<T extends GscRequest | GaRequest>(
|
|
10
|
+
provider: "gsc" | "ga",
|
|
11
|
+
target: string,
|
|
12
|
+
request: T,
|
|
13
|
+
maxPages: number,
|
|
14
|
+
query: Query<T>,
|
|
15
|
+
): Promise<Evidence> {
|
|
16
|
+
const result = createEvidence(provider, "report", target);
|
|
17
|
+
const isGsc = provider === "gsc";
|
|
18
|
+
const offsetKey = isGsc ? "startRow" : "offset";
|
|
19
|
+
let offset = Number(isGsc ? (request as GscRequest).startRow : (request as GaRequest).offset);
|
|
20
|
+
const limit = Number(isGsc ? (request as GscRequest).rowLimit : (request as GaRequest).limit);
|
|
21
|
+
result.pagination = { exhausted: false, nextOffset: offset, rowsReturned: 0 };
|
|
22
|
+
if (isGsc) {
|
|
23
|
+
result.warnings.push(
|
|
24
|
+
"GSC dates are America/Los_Angeles; API top-row limits apply even after pagination is exhausted.",
|
|
25
|
+
);
|
|
26
|
+
if ((request as GscRequest).dimensions.includes("query"))
|
|
27
|
+
result.warnings.push("Anonymized queries are omitted. Query-row sums are not property totals.");
|
|
28
|
+
if ((request as GscRequest).dimensions.includes("page"))
|
|
29
|
+
result.warnings.push("Page aggregation differs from property aggregation; do not reconcile their row sums.");
|
|
30
|
+
if ((request as GscRequest).dataState !== "final")
|
|
31
|
+
result.warnings.push("Fresh data can be incomplete even when metadata is absent for this grouping.");
|
|
32
|
+
}
|
|
33
|
+
for (let page = 0; page < maxPages; page++) {
|
|
34
|
+
const effective = { ...request, [offsetKey]: offset };
|
|
35
|
+
try {
|
|
36
|
+
const response = await query(effective);
|
|
37
|
+
if (response.rows !== undefined && !Array.isArray(response.rows))
|
|
38
|
+
throw new RequestError("Invalid report rows", null, "invalid_response");
|
|
39
|
+
const count = response.rows?.length ?? 0;
|
|
40
|
+
const rowCount =
|
|
41
|
+
(response as GaReport).rowCount ??
|
|
42
|
+
(count === 0 && Array.isArray((response as GaReport).metricHeaders) ? 0 : undefined);
|
|
43
|
+
if (!isGsc && (!Number.isSafeInteger(rowCount) || Number(rowCount) < 0))
|
|
44
|
+
throw new RequestError("GA response missing a valid rowCount", null, "invalid_response");
|
|
45
|
+
result.pages.push({ request: effective, response });
|
|
46
|
+
result.pagination.rowsReturned += count;
|
|
47
|
+
offset += count;
|
|
48
|
+
const exhausted = isGsc ? count < limit : offset >= Number(rowCount);
|
|
49
|
+
result.pagination.exhausted = exhausted;
|
|
50
|
+
result.pagination.nextOffset = exhausted ? null : offset;
|
|
51
|
+
if (!isGsc) {
|
|
52
|
+
const metadata = (response as GaReport).metadata;
|
|
53
|
+
if (metadata?.subjectToThresholding) result.warnings.push("GA report is subject to thresholding.");
|
|
54
|
+
if (metadata?.dataLossFromOtherRow)
|
|
55
|
+
result.warnings.push("GA high-cardinality rows were combined into (other).");
|
|
56
|
+
if (metadata?.samplingMetadatas?.length) result.warnings.push("GA report is sampled; see response metadata.");
|
|
57
|
+
}
|
|
58
|
+
if (exhausted) break;
|
|
59
|
+
if (!count) throw new RequestError("Provider pagination made no progress", null, "invalid_response");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
result.failedRequest = effective;
|
|
62
|
+
fail(result, error);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!result.pagination.exhausted && !result.error) result.status = "partial";
|
|
67
|
+
if (!result.pagination.exhausted)
|
|
68
|
+
result.warnings.push("Pagination not exhausted. Missing rows are unknown, not zero.");
|
|
69
|
+
result.warnings = [...new Set(result.warnings)];
|
|
70
|
+
result.finishedAt = new Date().toISOString();
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function gscReport(
|
|
75
|
+
site: string,
|
|
76
|
+
request: GscRequest,
|
|
77
|
+
maxPages: number,
|
|
78
|
+
query = (r: GscRequest) => querySearchAnalytics(site, r),
|
|
79
|
+
): Promise<Evidence> {
|
|
80
|
+
return pages("gsc", site, request, maxPages, query);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function gaReport(
|
|
84
|
+
property: string,
|
|
85
|
+
request: GaRequest,
|
|
86
|
+
maxPages: number,
|
|
87
|
+
query = (r: GaRequest) => gaFetch<GaReport>(`${normalizeGaProperty(property)}:runReport`, r),
|
|
88
|
+
): Promise<Evidence> {
|
|
89
|
+
return pages("ga", normalizeGaProperty(property), request, maxPages, query);
|
|
90
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { uiFindingsSchema } from "./ui-findings.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { snapshotEvidenceSchema } from "./evidence-schema.js";
|
|
4
|
+
import { dateSchema, pacificDate } from "../shared/dates.js";
|
|
5
|
+
|
|
6
|
+
export { dateSchema } from "../shared/dates.js";
|
|
7
|
+
const offset = z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
8
|
+
export const maxPagesSchema = z.number().int().min(1).max(20).default(1);
|
|
9
|
+
export const gscRequestSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
startDate: dateSchema,
|
|
12
|
+
endDate: dateSchema,
|
|
13
|
+
dimensions: z
|
|
14
|
+
.array(z.enum(["date", "hour", "query", "page", "country", "device", "searchAppearance"]))
|
|
15
|
+
.max(7)
|
|
16
|
+
.default([]),
|
|
17
|
+
type: z.enum(["web", "image", "video", "news", "discover", "googleNews"]).default("web"),
|
|
18
|
+
dataState: z.enum(["final", "all", "hourly_all"]).default("final"),
|
|
19
|
+
rowLimit: z.number().int().min(1).max(25000).default(25000),
|
|
20
|
+
startRow: offset.default(0),
|
|
21
|
+
aggregationType: z.enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"]).optional(),
|
|
22
|
+
dimensionFilterGroups: z
|
|
23
|
+
.array(
|
|
24
|
+
z
|
|
25
|
+
.object({
|
|
26
|
+
groupType: z.literal("and").optional(),
|
|
27
|
+
filters: z.array(
|
|
28
|
+
z.object({ dimension: z.string(), operator: z.string(), expression: z.string() }).strict(),
|
|
29
|
+
),
|
|
30
|
+
})
|
|
31
|
+
.strict(),
|
|
32
|
+
)
|
|
33
|
+
.optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict()
|
|
36
|
+
.refine((v) => v.startDate <= v.endDate, "startDate must be on or before endDate")
|
|
37
|
+
.refine((v) => v.endDate <= pacificDate(), "GSC endDate cannot be in the future");
|
|
38
|
+
export type GscRequest = z.infer<typeof gscRequestSchema>;
|
|
39
|
+
|
|
40
|
+
export const gaRequestSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
dateRanges: z
|
|
43
|
+
.array(
|
|
44
|
+
z
|
|
45
|
+
.object({ startDate: dateSchema, endDate: dateSchema, name: z.string().optional() })
|
|
46
|
+
.strict()
|
|
47
|
+
.refine((v) => v.startDate <= v.endDate, "Invalid date range"),
|
|
48
|
+
)
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(4),
|
|
51
|
+
dimensions: z.array(z.object({ name: z.string() }).passthrough()).default([]),
|
|
52
|
+
metrics: z.array(z.object({ name: z.string() }).passthrough()).min(1),
|
|
53
|
+
dimensionFilter: z.record(z.unknown()).optional(),
|
|
54
|
+
metricFilter: z.record(z.unknown()).optional(),
|
|
55
|
+
orderBys: z.array(z.record(z.unknown())).optional(),
|
|
56
|
+
metricAggregations: z.array(z.string()).optional(),
|
|
57
|
+
currencyCode: z.string().optional(),
|
|
58
|
+
keepEmptyRows: z.boolean().optional(),
|
|
59
|
+
limit: z.coerce.number().int().min(1).max(250000).default(10000),
|
|
60
|
+
offset: offset.default(0),
|
|
61
|
+
returnPropertyQuota: z.boolean().default(true),
|
|
62
|
+
})
|
|
63
|
+
.strict();
|
|
64
|
+
export type GaRequest = z.infer<typeof gaRequestSchema>;
|
|
65
|
+
|
|
66
|
+
const httpUrl = z
|
|
67
|
+
.string()
|
|
68
|
+
.url()
|
|
69
|
+
.refine(
|
|
70
|
+
(v) => ["http:", "https:"].includes(new URL(v).protocol) && !new URL(v).username && !new URL(v).password,
|
|
71
|
+
"Use an HTTP(S) URL without credentials",
|
|
72
|
+
);
|
|
73
|
+
export const configSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
site: httpUrl,
|
|
76
|
+
gscSite: z.string().min(1).optional(),
|
|
77
|
+
bingSite: httpUrl.optional(),
|
|
78
|
+
gaProperty: z
|
|
79
|
+
.string()
|
|
80
|
+
.regex(/^(properties\/)?\d+$/)
|
|
81
|
+
.optional(),
|
|
82
|
+
productionHostname: z.string().min(1).optional(),
|
|
83
|
+
sitemap: httpUrl.optional(),
|
|
84
|
+
pages: z.array(httpUrl).max(10).optional(),
|
|
85
|
+
context: z
|
|
86
|
+
.object({
|
|
87
|
+
objective: z.string().default("Define a product objective before evaluating outcomes."),
|
|
88
|
+
successEvents: z.array(z.string()).default([]),
|
|
89
|
+
excludedKeyEvents: z.array(z.string()).default([]),
|
|
90
|
+
locale: z.string().default("unspecified"),
|
|
91
|
+
country: z.string().default("unspecified"),
|
|
92
|
+
routes: z
|
|
93
|
+
.array(
|
|
94
|
+
z
|
|
95
|
+
.object({ pattern: z.string(), purpose: z.string(), indexing: z.enum(["index", "noindex", "verify"]) })
|
|
96
|
+
.strict(),
|
|
97
|
+
)
|
|
98
|
+
.default([]),
|
|
99
|
+
measurementCaveats: z.array(z.string()).default([]),
|
|
100
|
+
})
|
|
101
|
+
.strict()
|
|
102
|
+
.default({}),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
.transform((c) => ({
|
|
106
|
+
...c,
|
|
107
|
+
productionHostname: c.productionHostname ?? new URL(c.site).hostname,
|
|
108
|
+
pages: [...new Set(c.pages ?? [c.site])],
|
|
109
|
+
}))
|
|
110
|
+
.refine((c) => new URL(c.site).hostname === c.productionHostname, "productionHostname must match site")
|
|
111
|
+
.refine(
|
|
112
|
+
(c) => Boolean(c.pages.length || c.sitemap || c.gscSite || c.gaProperty || c.bingSite),
|
|
113
|
+
"Select at least one page or provider",
|
|
114
|
+
);
|
|
115
|
+
export type SiteConfig = z.infer<typeof configSchema>;
|
|
116
|
+
const operationVariants = z.discriminatedUnion("operation", [
|
|
117
|
+
z.object({ operation: z.literal("evidence.import"), document: uiFindingsSchema }).strict(),
|
|
118
|
+
z
|
|
119
|
+
.object({
|
|
120
|
+
operation: z.literal("compare"),
|
|
121
|
+
baseline: snapshotEvidenceSchema,
|
|
122
|
+
current: snapshotEvidenceSchema,
|
|
123
|
+
maxRows: z.number().int().min(1).max(1000).default(100),
|
|
124
|
+
})
|
|
125
|
+
.strict(),
|
|
126
|
+
z
|
|
127
|
+
.object({
|
|
128
|
+
operation: z.literal("discover"),
|
|
129
|
+
url: httpUrl,
|
|
130
|
+
providers: z
|
|
131
|
+
.array(z.enum(["gsc", "ga", "bing"]))
|
|
132
|
+
.min(1)
|
|
133
|
+
.max(3)
|
|
134
|
+
.default(["gsc", "ga"]),
|
|
135
|
+
})
|
|
136
|
+
.strict(),
|
|
137
|
+
z.object({ operation: z.literal("bing.crawl-stats"), site: httpUrl }).strict(),
|
|
138
|
+
z.object({ operation: z.literal("bing.crawl-issues"), site: httpUrl }).strict(),
|
|
139
|
+
z.object({ operation: z.literal("bing.url-info"), site: httpUrl, url: httpUrl }).strict(),
|
|
140
|
+
z.object({ operation: z.literal("bing.link-counts"), site: httpUrl, maxPages: maxPagesSchema }).strict(),
|
|
141
|
+
z.object({ operation: z.literal("bing.url-links"), site: httpUrl, url: httpUrl, maxPages: maxPagesSchema }).strict(),
|
|
142
|
+
z.object({ operation: z.literal("bing.sites") }).strict(),
|
|
143
|
+
z.object({ operation: z.literal("bing.queries"), site: httpUrl }).strict(),
|
|
144
|
+
z.object({ operation: z.literal("bing.pages"), site: httpUrl }).strict(),
|
|
145
|
+
z.object({ operation: z.literal("bing.traffic"), site: httpUrl }).strict(),
|
|
146
|
+
z.object({ operation: z.literal("gsc.sites") }).strict(),
|
|
147
|
+
z.object({ operation: z.literal("gsc.sitemaps"), site: z.string().min(1) }).strict(),
|
|
148
|
+
z.object({ operation: z.literal("gsc.inspect"), site: z.string().min(1), url: httpUrl }).strict(),
|
|
149
|
+
z
|
|
150
|
+
.object({
|
|
151
|
+
operation: z.literal("gsc.report"),
|
|
152
|
+
site: z.string().min(1),
|
|
153
|
+
request: gscRequestSchema,
|
|
154
|
+
maxPages: maxPagesSchema,
|
|
155
|
+
})
|
|
156
|
+
.strict(),
|
|
157
|
+
z.object({ operation: z.literal("ga.accounts") }).strict(),
|
|
158
|
+
z.object({ operation: z.literal("ga.property"), property: z.string() }).strict(),
|
|
159
|
+
z.object({ operation: z.literal("ga.key-events"), property: z.string() }).strict(),
|
|
160
|
+
z
|
|
161
|
+
.object({
|
|
162
|
+
operation: z.literal("ga.report"),
|
|
163
|
+
property: z.string(),
|
|
164
|
+
request: gaRequestSchema,
|
|
165
|
+
maxPages: maxPagesSchema,
|
|
166
|
+
})
|
|
167
|
+
.strict(),
|
|
168
|
+
z.object({ operation: z.literal("page"), url: httpUrl }).strict(),
|
|
169
|
+
z
|
|
170
|
+
.object({
|
|
171
|
+
operation: z.literal("speed.psi"),
|
|
172
|
+
url: httpUrl,
|
|
173
|
+
strategy: z.enum(["mobile", "desktop"]).default("mobile"),
|
|
174
|
+
})
|
|
175
|
+
.strict(),
|
|
176
|
+
z
|
|
177
|
+
.object({
|
|
178
|
+
operation: z.literal("speed.crux"),
|
|
179
|
+
url: httpUrl,
|
|
180
|
+
formFactor: z.enum(["PHONE", "DESKTOP", "TABLET"]).default("PHONE"),
|
|
181
|
+
origin: z.boolean().default(false),
|
|
182
|
+
})
|
|
183
|
+
.strict(),
|
|
184
|
+
z
|
|
185
|
+
.object({
|
|
186
|
+
operation: z.literal("speed.history"),
|
|
187
|
+
url: httpUrl,
|
|
188
|
+
formFactor: z.enum(["PHONE", "DESKTOP", "TABLET"]).default("PHONE"),
|
|
189
|
+
origin: z.boolean().default(false),
|
|
190
|
+
})
|
|
191
|
+
.strict(),
|
|
192
|
+
z.object({ operation: z.literal("doctor"), config: configSchema }).strict(),
|
|
193
|
+
z
|
|
194
|
+
.object({
|
|
195
|
+
operation: z.literal("snapshot"),
|
|
196
|
+
config: configSchema,
|
|
197
|
+
startDate: dateSchema,
|
|
198
|
+
endDate: dateSchema,
|
|
199
|
+
maxPages: maxPagesSchema.default(4),
|
|
200
|
+
})
|
|
201
|
+
.strict(),
|
|
202
|
+
]);
|
|
203
|
+
export const operationSchema = operationVariants.refine(
|
|
204
|
+
(op) => op.operation !== "snapshot" || (op.startDate <= op.endDate && op.endDate <= pacificDate()),
|
|
205
|
+
"Invalid or future snapshot interval",
|
|
206
|
+
);
|
|
207
|
+
export type Operation = z.input<typeof operationSchema>;
|