pagesight 0.17.0 → 0.19.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 +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -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 +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -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/opportunities-text.ts +61 -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 +18 -98
- 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 +1 -1
- 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 -1118
- package/src/tools/speed.ts +0 -956
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
export const gaRealtimeRequestSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
dimensions: z
|
|
69
|
+
.array(z.object({ name: z.string().min(1) }).strict())
|
|
70
|
+
.max(9)
|
|
71
|
+
.default([]),
|
|
72
|
+
metrics: z
|
|
73
|
+
.array(z.object({ name: z.string().min(1) }).strict())
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(10),
|
|
76
|
+
dimensionFilter: z.record(z.unknown()).optional(),
|
|
77
|
+
metricFilter: z.record(z.unknown()).optional(),
|
|
78
|
+
orderBys: z.array(z.record(z.unknown())).optional(),
|
|
79
|
+
limit: z.coerce.number().int().min(1).max(250000).default(10000),
|
|
80
|
+
returnPropertyQuota: z.boolean().default(true),
|
|
81
|
+
minuteRanges: z
|
|
82
|
+
.array(
|
|
83
|
+
z
|
|
84
|
+
.object({
|
|
85
|
+
name: z
|
|
86
|
+
.string()
|
|
87
|
+
.min(1)
|
|
88
|
+
.refine((n) => !n.startsWith("date_range_") && !n.startsWith("RESERVED_"))
|
|
89
|
+
.optional(),
|
|
90
|
+
startMinutesAgo: z.number().int().min(0).max(59).default(29),
|
|
91
|
+
endMinutesAgo: z.number().int().min(0).max(59).default(0),
|
|
92
|
+
})
|
|
93
|
+
.strict()
|
|
94
|
+
.refine((r) => r.startMinutesAgo >= r.endMinutesAgo, "Start must be at least as many minutes ago as end"),
|
|
95
|
+
)
|
|
96
|
+
.min(1)
|
|
97
|
+
.max(2)
|
|
98
|
+
.default([{ startMinutesAgo: 29, endMinutesAgo: 0 }]),
|
|
99
|
+
})
|
|
100
|
+
.strict();
|
|
101
|
+
export type GaRealtimeRequest = z.infer<typeof gaRealtimeRequestSchema>;
|
|
102
|
+
|
|
103
|
+
export const httpUrl = z
|
|
104
|
+
.string()
|
|
105
|
+
.url()
|
|
106
|
+
.refine(
|
|
107
|
+
(v) =>
|
|
108
|
+
URL.canParse(v) &&
|
|
109
|
+
["http:", "https:"].includes(new URL(v).protocol) &&
|
|
110
|
+
!new URL(v).username &&
|
|
111
|
+
!new URL(v).password,
|
|
112
|
+
"Use an HTTP(S) URL without credentials",
|
|
113
|
+
);
|
|
114
|
+
export const configSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
site: httpUrl,
|
|
117
|
+
gscSite: z.string().min(1).optional(),
|
|
118
|
+
bingSite: httpUrl.optional(),
|
|
119
|
+
gaProperty: z
|
|
120
|
+
.string()
|
|
121
|
+
.regex(/^(properties\/)?\d+$/)
|
|
122
|
+
.optional(),
|
|
123
|
+
productionHostname: z.string().min(1).optional(),
|
|
124
|
+
sitemap: httpUrl.optional(),
|
|
125
|
+
pages: z.array(httpUrl).max(10).optional(),
|
|
126
|
+
context: z
|
|
127
|
+
.object({
|
|
128
|
+
objective: z.string().default("Define a product objective before evaluating outcomes."),
|
|
129
|
+
successEvents: z.array(z.string()).default([]),
|
|
130
|
+
excludedKeyEvents: z.array(z.string()).default([]),
|
|
131
|
+
locale: z.string().default("unspecified"),
|
|
132
|
+
country: z.string().default("unspecified"),
|
|
133
|
+
routes: z
|
|
134
|
+
.array(
|
|
135
|
+
z
|
|
136
|
+
.object({ pattern: z.string(), purpose: z.string(), indexing: z.enum(["index", "noindex", "verify"]) })
|
|
137
|
+
.strict(),
|
|
138
|
+
)
|
|
139
|
+
.default([]),
|
|
140
|
+
measurementCaveats: z.array(z.string()).default([]),
|
|
141
|
+
})
|
|
142
|
+
.strict()
|
|
143
|
+
.default({}),
|
|
144
|
+
})
|
|
145
|
+
.strict()
|
|
146
|
+
.transform((c) => ({
|
|
147
|
+
...c,
|
|
148
|
+
productionHostname: c.productionHostname ?? new URL(c.site).hostname,
|
|
149
|
+
pages: [...new Set(c.pages ?? [c.site])],
|
|
150
|
+
}))
|
|
151
|
+
.refine((c) => new URL(c.site).hostname === c.productionHostname, "productionHostname must match site")
|
|
152
|
+
.refine(
|
|
153
|
+
(c) => Boolean(c.pages.length || c.sitemap || c.gscSite || c.gaProperty || c.bingSite),
|
|
154
|
+
"Select at least one page or provider",
|
|
155
|
+
);
|
|
156
|
+
export type SiteConfig = z.infer<typeof configSchema>;
|
|
157
|
+
const assessedSnapshotSchema = snapshotEvidenceSchema.superRefine((snapshot, ctx) => {
|
|
158
|
+
const config = configSchema.safeParse(snapshot.pages[0].response.context.config);
|
|
159
|
+
if (!config.success)
|
|
160
|
+
for (const issue of config.error.issues)
|
|
161
|
+
ctx.addIssue({ ...issue, path: ["pages", 0, "response", "context", "config", ...issue.path] });
|
|
162
|
+
});
|
|
163
|
+
const operationVariants = z.discriminatedUnion("operation", [
|
|
164
|
+
z
|
|
165
|
+
.object({
|
|
166
|
+
operation: z.literal("opportunities"),
|
|
167
|
+
snapshot: assessedSnapshotSchema,
|
|
168
|
+
minImpressions: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(20),
|
|
169
|
+
maxClicks: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(2),
|
|
170
|
+
maxRows: z.number().int().min(1).max(100).default(10),
|
|
171
|
+
})
|
|
172
|
+
.strict(),
|
|
173
|
+
z
|
|
174
|
+
.object({
|
|
175
|
+
operation: z.literal("assess"),
|
|
176
|
+
snapshot: assessedSnapshotSchema,
|
|
177
|
+
maxRows: z.number().int().min(1).max(100).default(10),
|
|
178
|
+
})
|
|
179
|
+
.strict(),
|
|
180
|
+
z.object({ operation: z.literal("evidence.import"), document: uiFindingsSchema }).strict(),
|
|
181
|
+
z
|
|
182
|
+
.object({
|
|
183
|
+
operation: z.literal("compare"),
|
|
184
|
+
baseline: snapshotEvidenceSchema,
|
|
185
|
+
current: snapshotEvidenceSchema,
|
|
186
|
+
maxRows: z.number().int().min(1).max(1000).default(100),
|
|
187
|
+
})
|
|
188
|
+
.strict(),
|
|
189
|
+
z
|
|
190
|
+
.object({
|
|
191
|
+
operation: z.literal("discover"),
|
|
192
|
+
url: httpUrl,
|
|
193
|
+
providers: z
|
|
194
|
+
.array(z.enum(["gsc", "ga", "bing"]))
|
|
195
|
+
.min(1)
|
|
196
|
+
.max(3)
|
|
197
|
+
.default(["gsc", "ga"]),
|
|
198
|
+
})
|
|
199
|
+
.strict(),
|
|
200
|
+
z.object({ operation: z.literal("bing.crawl-stats"), site: httpUrl }).strict(),
|
|
201
|
+
z.object({ operation: z.literal("bing.crawl-issues"), site: httpUrl }).strict(),
|
|
202
|
+
z.object({ operation: z.literal("bing.url-info"), site: httpUrl, url: httpUrl }).strict(),
|
|
203
|
+
z.object({ operation: z.literal("bing.link-counts"), site: httpUrl, maxPages: maxPagesSchema }).strict(),
|
|
204
|
+
z.object({ operation: z.literal("bing.url-links"), site: httpUrl, url: httpUrl, maxPages: maxPagesSchema }).strict(),
|
|
205
|
+
z.object({ operation: z.literal("bing.sites") }).strict(),
|
|
206
|
+
z.object({ operation: z.literal("bing.queries"), site: httpUrl }).strict(),
|
|
207
|
+
z.object({ operation: z.literal("bing.pages"), site: httpUrl }).strict(),
|
|
208
|
+
z.object({ operation: z.literal("bing.traffic"), site: httpUrl }).strict(),
|
|
209
|
+
z.object({ operation: z.literal("gsc.sites") }).strict(),
|
|
210
|
+
z.object({ operation: z.literal("gsc.sitemaps"), site: z.string().min(1) }).strict(),
|
|
211
|
+
z.object({ operation: z.literal("gsc.inspect"), site: z.string().min(1), url: httpUrl }).strict(),
|
|
212
|
+
z
|
|
213
|
+
.object({
|
|
214
|
+
operation: z.literal("gsc.report"),
|
|
215
|
+
site: z.string().min(1),
|
|
216
|
+
request: gscRequestSchema,
|
|
217
|
+
maxPages: maxPagesSchema,
|
|
218
|
+
})
|
|
219
|
+
.strict(),
|
|
220
|
+
z.object({ operation: z.literal("ga.realtime"), property: z.string(), request: gaRealtimeRequestSchema }).strict(),
|
|
221
|
+
z.object({ operation: z.literal("ga.accounts") }).strict(),
|
|
222
|
+
z.object({ operation: z.literal("ga.property"), property: z.string() }).strict(),
|
|
223
|
+
z.object({ operation: z.literal("ga.key-events"), property: z.string() }).strict(),
|
|
224
|
+
z
|
|
225
|
+
.object({
|
|
226
|
+
operation: z.literal("ga.report"),
|
|
227
|
+
property: z.string(),
|
|
228
|
+
request: gaRequestSchema,
|
|
229
|
+
maxPages: maxPagesSchema,
|
|
230
|
+
})
|
|
231
|
+
.strict(),
|
|
232
|
+
z.object({ operation: z.literal("page"), url: httpUrl }).strict(),
|
|
233
|
+
z
|
|
234
|
+
.object({
|
|
235
|
+
operation: z.literal("speed.psi"),
|
|
236
|
+
url: httpUrl,
|
|
237
|
+
strategy: z.enum(["mobile", "desktop"]).default("mobile"),
|
|
238
|
+
})
|
|
239
|
+
.strict(),
|
|
240
|
+
z
|
|
241
|
+
.object({
|
|
242
|
+
operation: z.literal("speed.crux"),
|
|
243
|
+
url: httpUrl,
|
|
244
|
+
formFactor: z.enum(["PHONE", "DESKTOP", "TABLET"]).default("PHONE"),
|
|
245
|
+
origin: z.boolean().default(false),
|
|
246
|
+
})
|
|
247
|
+
.strict(),
|
|
248
|
+
z
|
|
249
|
+
.object({
|
|
250
|
+
operation: z.literal("speed.history"),
|
|
251
|
+
url: httpUrl,
|
|
252
|
+
formFactor: z.enum(["PHONE", "DESKTOP", "TABLET"]).default("PHONE"),
|
|
253
|
+
origin: z.boolean().default(false),
|
|
254
|
+
})
|
|
255
|
+
.strict(),
|
|
256
|
+
z.object({ operation: z.literal("doctor"), config: configSchema }).strict(),
|
|
257
|
+
z
|
|
258
|
+
.object({
|
|
259
|
+
operation: z.literal("snapshot"),
|
|
260
|
+
config: configSchema,
|
|
261
|
+
startDate: dateSchema,
|
|
262
|
+
endDate: dateSchema,
|
|
263
|
+
maxPages: maxPagesSchema.default(4),
|
|
264
|
+
})
|
|
265
|
+
.strict(),
|
|
266
|
+
]);
|
|
267
|
+
export const operationSchema = operationVariants.refine(
|
|
268
|
+
(op) => op.operation !== "snapshot" || (op.startDate <= op.endDate && op.endDate <= pacificDate()),
|
|
269
|
+
"Invalid or future snapshot interval",
|
|
270
|
+
);
|
|
271
|
+
export type Operation = z.input<typeof operationSchema>;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { aggregate, capture, type Evidence } from "./evidence.js";
|
|
2
|
+
import { configSchema, gaRequestSchema, gscRequestSchema, type Operation, type SiteConfig } from "./schema.js";
|
|
3
|
+
import { observeSitemap } from "../web/sitemap-inventory.js";
|
|
4
|
+
|
|
5
|
+
import type { Executor } from "./execute.js";
|
|
6
|
+
|
|
7
|
+
export function snapshotOperations(
|
|
8
|
+
config: SiteConfig,
|
|
9
|
+
startDate: string,
|
|
10
|
+
endDate: string,
|
|
11
|
+
maxPages: number,
|
|
12
|
+
): Operation[] {
|
|
13
|
+
const operations: Operation[] = [];
|
|
14
|
+
const range = { startDate, endDate };
|
|
15
|
+
if (config.gscSite) {
|
|
16
|
+
const site = config.gscSite;
|
|
17
|
+
operations.push(
|
|
18
|
+
...[[], ["page"], ["query"], ["date"]].map((dimensions) => ({
|
|
19
|
+
operation: "gsc.report" as const,
|
|
20
|
+
site,
|
|
21
|
+
maxPages,
|
|
22
|
+
request: gscRequestSchema.parse({ ...range, dimensions }),
|
|
23
|
+
})),
|
|
24
|
+
{ operation: "gsc.sitemaps", site },
|
|
25
|
+
...config.pages.map((url) => ({ operation: "gsc.inspect" as const, url, site })),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (config.bingSite) {
|
|
29
|
+
const site = config.bingSite;
|
|
30
|
+
operations.push(
|
|
31
|
+
{ operation: "bing.queries", site },
|
|
32
|
+
{ operation: "bing.pages", site },
|
|
33
|
+
{ operation: "bing.traffic", site },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (config.gaProperty) {
|
|
37
|
+
const property = config.gaProperty;
|
|
38
|
+
const hostFilter = {
|
|
39
|
+
filter: { fieldName: "hostName", stringFilter: { matchType: "EXACT", value: config.productionHostname } },
|
|
40
|
+
};
|
|
41
|
+
const ga = (
|
|
42
|
+
dimensions: string[],
|
|
43
|
+
metrics: string[],
|
|
44
|
+
production = true,
|
|
45
|
+
extra: Record<string, unknown> = {},
|
|
46
|
+
): Operation => ({
|
|
47
|
+
operation: "ga.report",
|
|
48
|
+
property,
|
|
49
|
+
maxPages,
|
|
50
|
+
request: gaRequestSchema.parse({
|
|
51
|
+
dateRanges: [range],
|
|
52
|
+
dimensions: dimensions.map((name) => ({ name })),
|
|
53
|
+
metrics: metrics.map((name) => ({ name })),
|
|
54
|
+
...(production ? { dimensionFilter: hostFilter } : {}),
|
|
55
|
+
...extra,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
operations.push(
|
|
59
|
+
{ operation: "ga.property", property },
|
|
60
|
+
{ operation: "ga.key-events", property },
|
|
61
|
+
ga(["hostName"], ["sessions", "screenPageViews"], false),
|
|
62
|
+
ga(["sessionDefaultChannelGroup", "sessionSourceMedium"], ["sessions", "engagedSessions"]),
|
|
63
|
+
ga(["eventName"], ["eventCount", "keyEvents"]),
|
|
64
|
+
ga(["landingPagePlusQueryString", "sessionSource"], ["sessions", "engagedSessions"], true, {
|
|
65
|
+
dimensionFilter: {
|
|
66
|
+
andGroup: {
|
|
67
|
+
expressions: [
|
|
68
|
+
hostFilter,
|
|
69
|
+
{
|
|
70
|
+
filter: {
|
|
71
|
+
fieldName: "sessionDefaultChannelGroup",
|
|
72
|
+
stringFilter: { matchType: "EXACT", value: "Organic Search" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
ga(["eventName"], ["eventCount", "keyEvents"], true, {
|
|
80
|
+
dimensionFilter: {
|
|
81
|
+
andGroup: {
|
|
82
|
+
expressions: [
|
|
83
|
+
hostFilter,
|
|
84
|
+
{
|
|
85
|
+
filter: {
|
|
86
|
+
fieldName: "sessionDefaultChannelGroup",
|
|
87
|
+
stringFilter: { matchType: "EXACT", value: "Organic Search" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
ga(["landingPagePlusQueryString", "sessionSource", "eventName"], ["eventCount"], true, {
|
|
95
|
+
dimensionFilter: {
|
|
96
|
+
andGroup: {
|
|
97
|
+
expressions: [
|
|
98
|
+
hostFilter,
|
|
99
|
+
{
|
|
100
|
+
filter: {
|
|
101
|
+
fieldName: "sessionDefaultChannelGroup",
|
|
102
|
+
stringFilter: { matchType: "EXACT", value: "Organic Search" },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
operations.push(...config.pages.map((url) => ({ operation: "page" as const, url })));
|
|
112
|
+
return operations;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function snapshot(
|
|
116
|
+
input: { config: SiteConfig; startDate: string; endDate: string; maxPages: number },
|
|
117
|
+
run: Executor,
|
|
118
|
+
): Promise<Evidence> {
|
|
119
|
+
const config = configSchema.parse(input.config);
|
|
120
|
+
const requests = snapshotOperations(config, input.startDate, input.endDate, input.maxPages);
|
|
121
|
+
const observations: Evidence[] = [];
|
|
122
|
+
// Small batches bound simultaneous token exchanges and provider load.
|
|
123
|
+
for (let i = 0; i < requests.length; i += 3)
|
|
124
|
+
observations.push(
|
|
125
|
+
...(await Promise.all(
|
|
126
|
+
requests.slice(i, i + 3).map(async (request) => ({
|
|
127
|
+
...(await run(request)),
|
|
128
|
+
name: observationName(request),
|
|
129
|
+
})),
|
|
130
|
+
)),
|
|
131
|
+
);
|
|
132
|
+
if (config.sitemap) {
|
|
133
|
+
const sitemap = config.sitemap;
|
|
134
|
+
const inventory = await capture(
|
|
135
|
+
"web",
|
|
136
|
+
"sitemap-inventory",
|
|
137
|
+
sitemap,
|
|
138
|
+
{ url: sitemap, maxDocuments: 5, maxBytes: 8_000_000 },
|
|
139
|
+
() => observeSitemap(sitemap),
|
|
140
|
+
);
|
|
141
|
+
const inventoryResponse = inventory.pages[0]?.response as Awaited<ReturnType<typeof observeSitemap>> | undefined;
|
|
142
|
+
if (inventoryResponse && !inventoryResponse.complete) inventory.status = "partial";
|
|
143
|
+
inventory.name = "web.sitemap";
|
|
144
|
+
observations.push(inventory);
|
|
145
|
+
}
|
|
146
|
+
const dateReport = observations.find(
|
|
147
|
+
(o) =>
|
|
148
|
+
o.provider === "gsc" &&
|
|
149
|
+
o.operation === "report" &&
|
|
150
|
+
(o.pages[0]?.request as { dimensions?: string[] })?.dimensions?.[0] === "date",
|
|
151
|
+
);
|
|
152
|
+
const coveredDates =
|
|
153
|
+
dateReport?.pages
|
|
154
|
+
.flatMap((p) => ((p.response as { rows?: Array<{ keys: string[] }> }).rows ?? []).map((r) => r.keys[0]))
|
|
155
|
+
.sort() ?? [];
|
|
156
|
+
const result = aggregate("snapshot", config.site, input, observations);
|
|
157
|
+
(result.pages[0].response as Record<string, unknown>).snapshotVersion = 1;
|
|
158
|
+
(result.pages[0].response as Record<string, unknown>).context = {
|
|
159
|
+
config,
|
|
160
|
+
providers: providerSelection(config),
|
|
161
|
+
deploymentVersion: null,
|
|
162
|
+
referenceIdentity: null,
|
|
163
|
+
requestedDates: { startDate: input.startDate, endDate: input.endDate },
|
|
164
|
+
observedGscDates: coveredDates,
|
|
165
|
+
};
|
|
166
|
+
result.warnings.push(
|
|
167
|
+
"Deployment version and content reference identity are unknown; content/sitemap hashes identify observed artifacts only.",
|
|
168
|
+
"Dates with no GSC rows may have no activity or unavailable data; observed dates do not prove full date coverage.",
|
|
169
|
+
"GSC uses Pacific dates; GA uses the property timezone in response metadata. Counts cannot be directly reconciled.",
|
|
170
|
+
"Keep raw landing URLs. Resolve observed canonicals and project route policy before page-level comparison.",
|
|
171
|
+
"Search changes are descriptive; traffic volume and content changes limit causal conclusions.",
|
|
172
|
+
...config.context.measurementCaveats,
|
|
173
|
+
);
|
|
174
|
+
if (!config.context.successEvents.length)
|
|
175
|
+
result.warnings.push("No validated success events configured. Do not optimize total keyEvents as conversions.");
|
|
176
|
+
if (config.context.successEvents.length)
|
|
177
|
+
result.warnings.push(
|
|
178
|
+
"Configured success events are caller-designated; Pagesight has not independently validated their tracking or business meaning.",
|
|
179
|
+
);
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function observationName(op: Operation): string {
|
|
184
|
+
if (op.operation === "gsc.report") return `gsc.report.${op.request.dimensions?.join("+") || "property"}`;
|
|
185
|
+
if (op.operation === "ga.report") {
|
|
186
|
+
const dimensions = op.request.dimensions?.map((d) => d.name).join("+") || "property";
|
|
187
|
+
const organic = JSON.stringify(op.request.dimensionFilter ?? {}).includes("Organic Search");
|
|
188
|
+
return `ga.report.${dimensions}${organic ? ".organic" : ""}`;
|
|
189
|
+
}
|
|
190
|
+
if (op.operation === "page" || op.operation === "gsc.inspect") return `${op.operation}:${op.url}`;
|
|
191
|
+
return op.operation;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function providerSelection(config: SiteConfig) {
|
|
195
|
+
return {
|
|
196
|
+
gsc: config.gscSite ? "selected" : "not_selected",
|
|
197
|
+
bing: config.bingSite ? "selected" : "not_selected",
|
|
198
|
+
ga: config.gaProperty ? "selected" : "not_selected",
|
|
199
|
+
sitemap: config.sitemap ? "selected" : "not_selected",
|
|
200
|
+
web: config.pages.length ? "selected" : "not_selected",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { capture } from "./evidence.js";
|
|
3
|
+
|
|
4
|
+
const url = z
|
|
5
|
+
.string()
|
|
6
|
+
.url()
|
|
7
|
+
.max(4096)
|
|
8
|
+
.refine((value) => {
|
|
9
|
+
const parsed = new URL(value);
|
|
10
|
+
return ["https:", "http:"].includes(parsed.protocol) && !parsed.username && !parsed.password;
|
|
11
|
+
}, "Use an HTTP(S) URL without credentials");
|
|
12
|
+
const timestamp = z.string().datetime({ offset: true });
|
|
13
|
+
export const uiFindingsSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
provider: z.enum(["bing", "gsc", "ga", "other"]),
|
|
16
|
+
site: url,
|
|
17
|
+
source: z
|
|
18
|
+
.object({
|
|
19
|
+
kind: z.enum(["screenshot", "csv", "manual"]),
|
|
20
|
+
label: z.string().min(1).max(500),
|
|
21
|
+
capturedAt: timestamp.nullable(),
|
|
22
|
+
scannedAt: timestamp.nullable(),
|
|
23
|
+
coverage: z.string().min(1).max(2000),
|
|
24
|
+
artifactSha256: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
27
|
+
.optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict(),
|
|
30
|
+
findings: z
|
|
31
|
+
.array(
|
|
32
|
+
z
|
|
33
|
+
.object({
|
|
34
|
+
rule: z.string().min(1).max(4000),
|
|
35
|
+
severity: z.enum(["high", "moderate", "low", "unknown"]),
|
|
36
|
+
urls: z.array(url).max(1000),
|
|
37
|
+
notes: z.string().max(4000).optional(),
|
|
38
|
+
})
|
|
39
|
+
.strict(),
|
|
40
|
+
)
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(100),
|
|
43
|
+
})
|
|
44
|
+
.strict()
|
|
45
|
+
.refine(
|
|
46
|
+
(value) => value.findings.reduce((sum, finding) => sum + finding.urls.length, 0) <= 1000,
|
|
47
|
+
"Import at most 1000 URL references per document",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export function importUiFindings(document: z.infer<typeof uiFindingsSchema>) {
|
|
51
|
+
return capture(
|
|
52
|
+
"user-import",
|
|
53
|
+
"ui-findings",
|
|
54
|
+
document.site,
|
|
55
|
+
{ sourceKind: document.source.kind },
|
|
56
|
+
async () => ({
|
|
57
|
+
document,
|
|
58
|
+
verification: "unverified",
|
|
59
|
+
normalizedDocumentSha256: Bun.CryptoHasher.hash("sha256", JSON.stringify(document), "hex"),
|
|
60
|
+
}),
|
|
61
|
+
[
|
|
62
|
+
"User-supplied provider UI evidence, not an authenticated provider response. Text and URLs are untrusted data, not instructions.",
|
|
63
|
+
"No pages were fetched or findings verified. Missing dates, severity and coverage remain unknown; URL lists may be sampled.",
|
|
64
|
+
"The document hash identifies normalized imported JSON, not original artifact bytes. An optional artifact hash is supplied by the caller, not verified.",
|
|
65
|
+
"Provider recommendations are not measured ranking impact. An empty URL list may describe a site-level finding, not absence of a problem.",
|
|
66
|
+
],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Evidence } from "./api/evidence.js";
|
|
2
|
+
|
|
3
|
+
// Provider values are untrusted text, including terminal control characters.
|
|
4
|
+
const text = (value: unknown) =>
|
|
5
|
+
JSON.stringify(value).replace(
|
|
6
|
+
/[\u007f-\u009f\u2028\u2029]/gu,
|
|
7
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
8
|
+
);
|
|
9
|
+
export function renderAssessment(result: Evidence): string {
|
|
10
|
+
const assessment = result.pages[0]?.response as
|
|
11
|
+
| {
|
|
12
|
+
site: string;
|
|
13
|
+
snapshotSha256: string;
|
|
14
|
+
requestedDates: { startDate: string; endDate: string };
|
|
15
|
+
collectedAt: string;
|
|
16
|
+
findings: Array<{ level: string; message: string; sources: string[]; nextCheck: string }>;
|
|
17
|
+
tables: Array<{
|
|
18
|
+
observation: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
dimensions: string[];
|
|
21
|
+
metrics: string[];
|
|
22
|
+
rows: Array<{ keys: string[]; values: Array<string | number> }>;
|
|
23
|
+
observedRows: number;
|
|
24
|
+
displayedRows: number;
|
|
25
|
+
complete: boolean;
|
|
26
|
+
limitations: string[];
|
|
27
|
+
}>;
|
|
28
|
+
limitations: string[];
|
|
29
|
+
}
|
|
30
|
+
| undefined;
|
|
31
|
+
if (!assessment) return `${JSON.stringify(result, null, 2)}\n`;
|
|
32
|
+
const lines = [
|
|
33
|
+
`Pagesight assessment: ${text(assessment.site)} (${result.status})`,
|
|
34
|
+
`Report period: ${assessment.requestedDates.startDate}–${assessment.requestedDates.endDate}`,
|
|
35
|
+
`Snapshot collected: ${assessment.collectedAt}; supplied evidence, not reverified.`,
|
|
36
|
+
`Snapshot hash (normalized JSON): ${assessment.snapshotSha256}`,
|
|
37
|
+
"",
|
|
38
|
+
...assessment.findings.flatMap((f) => [
|
|
39
|
+
`- [${text(f.level)}] ${text(f.message)}`,
|
|
40
|
+
` Source: ${f.sources.map(text).join(", ")}`,
|
|
41
|
+
` Next check: ${text(f.nextCheck)}`,
|
|
42
|
+
]),
|
|
43
|
+
];
|
|
44
|
+
for (const table of assessment.tables) {
|
|
45
|
+
lines.push(
|
|
46
|
+
"",
|
|
47
|
+
`${text(table.observation)} (${table.scope}; showing ${table.displayedRows}/${table.observedRows} observed rows; ${table.complete ? "pagination exhausted" : "incomplete report"})`,
|
|
48
|
+
);
|
|
49
|
+
lines.push(` ${[...table.dimensions, ...table.metrics].map(text).join(" | ")}`);
|
|
50
|
+
for (const row of table.rows) lines.push(` ${[...row.keys, ...row.values].map(text).join(" | ")}`);
|
|
51
|
+
for (const limitation of table.limitations) lines.push(` Limit: ${text(limitation)}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push("", ...assessment.limitations.map((l) => `Limit: ${text(l)}`));
|
|
54
|
+
return `${lines.join("\n")}\n`;
|
|
55
|
+
}
|