pagesight 0.18.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 +4 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +7 -0
- package/docs/usage.md +5 -0
- package/package.json +1 -1
- package/src/api/assessment.ts +315 -0
- package/src/api/compare-snapshots.ts +4 -214
- package/src/api/execute.ts +13 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +19 -1
- package/src/api/schema.ts +66 -2
- package/src/api/snapshot.ts +20 -1
- package/src/api/ui-findings.ts +1 -1
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +37 -2
- package/src/opportunities-text.ts +61 -0
- package/src/tools/observe.ts +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Evidence } from "./evidence.js";
|
|
3
|
+
import type { ImportedSnapshot } from "./evidence-schema.js";
|
|
4
|
+
import { gaRequestSchema, gscRequestSchema } from "./schema.js";
|
|
5
|
+
import { gaFreshnessWarnings } from "./ga-freshness.js";
|
|
6
|
+
|
|
7
|
+
const gscRow = z
|
|
8
|
+
.object({
|
|
9
|
+
keys: z.array(z.string()).optional(),
|
|
10
|
+
clicks: z.number().finite(),
|
|
11
|
+
impressions: z.number().finite(),
|
|
12
|
+
ctr: z.number().finite(),
|
|
13
|
+
position: z.number().finite(),
|
|
14
|
+
})
|
|
15
|
+
.passthrough();
|
|
16
|
+
const gscResponse = z
|
|
17
|
+
.object({
|
|
18
|
+
rows: z.array(gscRow).default([]),
|
|
19
|
+
responseAggregationType: z.string().min(1),
|
|
20
|
+
metadata: z.record(z.unknown()).optional(),
|
|
21
|
+
})
|
|
22
|
+
.passthrough();
|
|
23
|
+
const gaResponse = z
|
|
24
|
+
.object({
|
|
25
|
+
dimensionHeaders: z.array(z.object({ name: z.string() })).default([]),
|
|
26
|
+
metricHeaders: z.array(z.object({ name: z.string(), type: z.string().optional() })).min(1),
|
|
27
|
+
rows: z
|
|
28
|
+
.array(
|
|
29
|
+
z.object({
|
|
30
|
+
dimensionValues: z.array(z.object({ value: z.string() })).default([]),
|
|
31
|
+
metricValues: z.array(z.object({ value: z.string() })),
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
.default([]),
|
|
35
|
+
metadata: z.object({ timeZone: z.string().min(1), currencyCode: z.string().optional() }).passthrough(),
|
|
36
|
+
})
|
|
37
|
+
.passthrough();
|
|
38
|
+
const gaComparisonDimensions = new Set([
|
|
39
|
+
"hostName",
|
|
40
|
+
"sessionDefaultChannelGroup",
|
|
41
|
+
"sessionSourceMedium",
|
|
42
|
+
"eventName",
|
|
43
|
+
"landingPagePlusQueryString",
|
|
44
|
+
"sessionSource",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function canonical(value: unknown): string {
|
|
48
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
49
|
+
if (value && typeof value === "object")
|
|
50
|
+
return `{${Object.entries(value)
|
|
51
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
52
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
53
|
+
.join(",")}}`;
|
|
54
|
+
return JSON.stringify(value) ?? "undefined";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type RawValue = number | string;
|
|
58
|
+
type Report = {
|
|
59
|
+
request: unknown;
|
|
60
|
+
start: string;
|
|
61
|
+
end: string;
|
|
62
|
+
dimensions: string[];
|
|
63
|
+
metrics: string[];
|
|
64
|
+
semantics: unknown;
|
|
65
|
+
rows: Map<string, { keys: string[]; values: RawValue[] }>;
|
|
66
|
+
warnings: string[];
|
|
67
|
+
};
|
|
68
|
+
export class Incompatible extends Error {}
|
|
69
|
+
export const requireSame = (a: unknown, b: unknown, message: string) => {
|
|
70
|
+
if (canonical(a) !== canonical(b)) throw new Incompatible(message);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type SnapshotContext = ImportedSnapshot["pages"][0]["response"]["context"];
|
|
74
|
+
|
|
75
|
+
export function normalizeReport(observation: Evidence, context: SnapshotContext): Report {
|
|
76
|
+
let report: Report | undefined;
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (const page of observation.pages) {
|
|
79
|
+
let current: Omit<Report, "rows">;
|
|
80
|
+
let rows: Array<{ keys: string[]; values: RawValue[] }>;
|
|
81
|
+
if (observation.provider === "gsc") {
|
|
82
|
+
const q = gscRequestSchema.parse(page.request);
|
|
83
|
+
const response = gscResponse.parse(page.response);
|
|
84
|
+
if (
|
|
85
|
+
q.dataState !== "final" ||
|
|
86
|
+
Object.keys(response.metadata ?? {}).some((key) => key.startsWith("first_incomplete"))
|
|
87
|
+
)
|
|
88
|
+
throw new Incompatible("GSC data must be finalized.");
|
|
89
|
+
if (q.startRow !== offset)
|
|
90
|
+
throw new Incompatible("Report must contain contiguous pages starting at offset zero.");
|
|
91
|
+
if (q.dimensions.some((dimension) => dimension === "date" || dimension === "hour"))
|
|
92
|
+
throw new Incompatible(
|
|
93
|
+
"Time dimensions need a separate alignment policy; this comparison uses non-time row keys.",
|
|
94
|
+
);
|
|
95
|
+
const { startDate, endDate, startRow: _startRow, rowLimit: _rowLimit, ...scope } = q;
|
|
96
|
+
current = {
|
|
97
|
+
request: scope,
|
|
98
|
+
start: startDate,
|
|
99
|
+
end: endDate,
|
|
100
|
+
dimensions: q.dimensions,
|
|
101
|
+
metrics: ["clicks", "impressions", "ctr", "position"],
|
|
102
|
+
semantics: { aggregation: response.responseAggregationType, timezone: "America/Los_Angeles" },
|
|
103
|
+
warnings: [],
|
|
104
|
+
};
|
|
105
|
+
if (!context.observedGscDates.includes(endDate))
|
|
106
|
+
current.warnings.push(
|
|
107
|
+
"GSC has no observed date row for the requested end date; trailing days may be unavailable or have no activity.",
|
|
108
|
+
);
|
|
109
|
+
rows = response.rows.map((r) => ({ keys: r.keys ?? [], values: [r.clicks, r.impressions, r.ctr, r.position] }));
|
|
110
|
+
} else {
|
|
111
|
+
const q = gaRequestSchema.parse(page.request);
|
|
112
|
+
const response = gaResponse.parse(page.response);
|
|
113
|
+
if (q.dateRanges.length !== 1) throw new Incompatible("GA comparison requires exactly one date range.");
|
|
114
|
+
if (
|
|
115
|
+
q.dimensions.some(
|
|
116
|
+
(dimension) => !gaComparisonDimensions.has(dimension.name) || Object.hasOwn(dimension, "dimensionExpression"),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
throw new Incompatible("GA comparison supports only snapshot dimension names without dimension expressions.");
|
|
120
|
+
if (q.offset !== offset) throw new Incompatible("Report must contain contiguous pages starting at offset zero.");
|
|
121
|
+
const { dateRanges, offset: _offset, limit: _limit, returnPropertyQuota: _quota, ...scope } = q;
|
|
122
|
+
const dimensions = q.dimensions.map((d) => d.name);
|
|
123
|
+
const metrics = q.metrics.map((m) => m.name);
|
|
124
|
+
if (new Set(metrics).size !== metrics.length)
|
|
125
|
+
throw new Incompatible("GA metric names must be unique to preserve every compared value.");
|
|
126
|
+
requireSame(
|
|
127
|
+
response.dimensionHeaders.map((h) => h.name),
|
|
128
|
+
dimensions,
|
|
129
|
+
"GA dimension headers must match the request.",
|
|
130
|
+
);
|
|
131
|
+
requireSame(
|
|
132
|
+
response.metricHeaders.map((h) => h.name),
|
|
133
|
+
metrics,
|
|
134
|
+
"GA metric headers must match the request.",
|
|
135
|
+
);
|
|
136
|
+
current = {
|
|
137
|
+
request: scope,
|
|
138
|
+
start: dateRanges[0].startDate,
|
|
139
|
+
end: dateRanges[0].endDate,
|
|
140
|
+
dimensions,
|
|
141
|
+
metrics,
|
|
142
|
+
semantics: {
|
|
143
|
+
timezone: response.metadata.timeZone,
|
|
144
|
+
currency: response.metadata.currencyCode ?? null,
|
|
145
|
+
metrics: response.metricHeaders,
|
|
146
|
+
},
|
|
147
|
+
warnings: [],
|
|
148
|
+
};
|
|
149
|
+
if (response.metadata.subjectToThresholding) current.warnings.push("GA report is subject to thresholding.");
|
|
150
|
+
if (response.metadata.dataLossFromOtherRow)
|
|
151
|
+
current.warnings.push("GA high-cardinality rows were combined into (other).");
|
|
152
|
+
if (Array.isArray(response.metadata.samplingMetadatas) && response.metadata.samplingMetadatas.length)
|
|
153
|
+
current.warnings.push("GA report is sampled.");
|
|
154
|
+
current.warnings.push(...gaFreshnessWarnings([current.end], response.metadata.timeZone, observation.finishedAt));
|
|
155
|
+
if (response.metricHeaders.some((h) => h.type === "TYPE_CURRENCY") && !response.metadata.currencyCode)
|
|
156
|
+
throw new Incompatible("GA currency metrics require a known response currency.");
|
|
157
|
+
rows = response.rows.map((r) => ({
|
|
158
|
+
keys: r.dimensionValues.map((v) => v.value),
|
|
159
|
+
values: r.metricValues.map((v) => v.value),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
requireSame(
|
|
163
|
+
{ startDate: current.start, endDate: current.end },
|
|
164
|
+
{ startDate: context.requestedDates.startDate, endDate: context.requestedDates.endDate },
|
|
165
|
+
"Report period does not match the snapshot context.",
|
|
166
|
+
);
|
|
167
|
+
if (report) {
|
|
168
|
+
requireSame(
|
|
169
|
+
[report.request, report.start, report.end, report.semantics],
|
|
170
|
+
[current.request, current.start, current.end, current.semantics],
|
|
171
|
+
"Report scope or metadata changed between pages.",
|
|
172
|
+
);
|
|
173
|
+
report.warnings.push(...current.warnings);
|
|
174
|
+
} else report = { ...current, rows: new Map() };
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
if (row.keys.length !== current.dimensions.length || row.values.length !== current.metrics.length)
|
|
177
|
+
throw new Incompatible("Row shape does not match the requested dimensions and metrics.");
|
|
178
|
+
const key = canonical(row.keys);
|
|
179
|
+
if (report.rows.has(key)) throw new Incompatible("Duplicate row keys make this report ambiguous.");
|
|
180
|
+
report.rows.set(key, row);
|
|
181
|
+
}
|
|
182
|
+
offset += rows.length;
|
|
183
|
+
}
|
|
184
|
+
if (!report) throw new Incompatible("No report pages available.");
|
|
185
|
+
if (observation.provider === "ga") {
|
|
186
|
+
const totals = observation.pages.map((p) => (p.response as { rowCount?: number }).rowCount ?? 0);
|
|
187
|
+
if (
|
|
188
|
+
totals.some((n) => !Number.isSafeInteger(n) || n < offset || n !== totals[0]) ||
|
|
189
|
+
(observation.pagination?.exhausted && totals[0] !== offset)
|
|
190
|
+
)
|
|
191
|
+
throw new Incompatible("GA rowCount conflicts with retained rows or completeness.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
observation.pagination &&
|
|
196
|
+
(observation.pagination.rowsReturned !== offset ||
|
|
197
|
+
observation.pagination.nextOffset !== (observation.pagination.exhausted ? null : offset))
|
|
198
|
+
)
|
|
199
|
+
throw new Incompatible("Report pagination does not match the retained rows.");
|
|
200
|
+
if (observation.status !== "ok" || !observation.pagination?.exhausted || observation.error)
|
|
201
|
+
report.warnings.push(
|
|
202
|
+
"Report has incomplete pagination or a provider failure; only observed common rows can be compared.",
|
|
203
|
+
);
|
|
204
|
+
return report;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function parseNumericValue(value: RawValue): number | null {
|
|
208
|
+
if (typeof value === "string" && !/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value)) return null;
|
|
209
|
+
const result = Number(value);
|
|
210
|
+
if (typeof value === "string" && !/^-?\d+$/u.test(value)) {
|
|
211
|
+
const mantissa = value.split(/[eE]/u)[0];
|
|
212
|
+
const significant = mantissa.replace(/[-.]/gu, "").replace(/^0+/u, "").replace(/0+$/u, "");
|
|
213
|
+
if (significant.length > 15 || (result === 0 && /[1-9]/u.test(mantissa))) return null;
|
|
214
|
+
}
|
|
215
|
+
return Number.isFinite(result) && Math.abs(result) <= Number.MAX_SAFE_INTEGER ? result : null;
|
|
216
|
+
}
|
package/src/api/reports.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gaFreshnessWarnings } from "./ga-freshness.js";
|
|
1
2
|
import { type GaReport, gaFetch, normalizeGaProperty } from "../providers/ga.js";
|
|
2
3
|
import { querySearchAnalytics, type SearchAnalyticsResponse } from "../providers/gsc.js";
|
|
3
4
|
import { RequestError } from "../shared/http.js";
|
|
@@ -29,6 +30,14 @@ async function pages<T extends GscRequest | GaRequest>(
|
|
|
29
30
|
result.warnings.push("Page aggregation differs from property aggregation; do not reconcile their row sums.");
|
|
30
31
|
if ((request as GscRequest).dataState !== "final")
|
|
31
32
|
result.warnings.push("Fresh data can be incomplete even when metadata is absent for this grouping.");
|
|
33
|
+
} else if (
|
|
34
|
+
(request as GaRequest).dimensions.some((dimension) => dimension.name === "landingPagePlusQueryString") &&
|
|
35
|
+
(request as GaRequest).dimensions.some((dimension) => dimension.name === "eventName")
|
|
36
|
+
) {
|
|
37
|
+
result.warnings.push(
|
|
38
|
+
"Landing page is the first pageview of the session, not necessarily where an event occurred. Event counts are occurrences, not unique sessions or a conversion rate.",
|
|
39
|
+
"Landing URLs retain query strings and (not set)/(other) values; do not join them to Search Console canonical URLs without verified mapping.",
|
|
40
|
+
);
|
|
32
41
|
}
|
|
33
42
|
for (let page = 0; page < maxPages; page++) {
|
|
34
43
|
const effective = { ...request, [offsetKey]: offset };
|
|
@@ -66,8 +75,17 @@ async function pages<T extends GscRequest | GaRequest>(
|
|
|
66
75
|
if (!result.pagination.exhausted && !result.error) result.status = "partial";
|
|
67
76
|
if (!result.pagination.exhausted)
|
|
68
77
|
result.warnings.push("Pagination not exhausted. Missing rows are unknown, not zero.");
|
|
69
|
-
result.warnings = [...new Set(result.warnings)];
|
|
70
78
|
result.finishedAt = new Date().toISOString();
|
|
79
|
+
if (!isGsc)
|
|
80
|
+
for (const page of result.pages)
|
|
81
|
+
result.warnings.push(
|
|
82
|
+
...gaFreshnessWarnings(
|
|
83
|
+
(request as GaRequest).dateRanges.map((range) => range.endDate),
|
|
84
|
+
(page.response as GaReport).metadata?.timeZone,
|
|
85
|
+
result.finishedAt,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
result.warnings = [...new Set(result.warnings)];
|
|
71
89
|
return result;
|
|
72
90
|
}
|
|
73
91
|
|
package/src/api/schema.ts
CHANGED
|
@@ -63,11 +63,52 @@ export const gaRequestSchema = z
|
|
|
63
63
|
.strict();
|
|
64
64
|
export type GaRequest = z.infer<typeof gaRequestSchema>;
|
|
65
65
|
|
|
66
|
-
const
|
|
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
|
|
67
104
|
.string()
|
|
68
105
|
.url()
|
|
69
106
|
.refine(
|
|
70
|
-
(v) =>
|
|
107
|
+
(v) =>
|
|
108
|
+
URL.canParse(v) &&
|
|
109
|
+
["http:", "https:"].includes(new URL(v).protocol) &&
|
|
110
|
+
!new URL(v).username &&
|
|
111
|
+
!new URL(v).password,
|
|
71
112
|
"Use an HTTP(S) URL without credentials",
|
|
72
113
|
);
|
|
73
114
|
export const configSchema = z
|
|
@@ -113,7 +154,29 @@ export const configSchema = z
|
|
|
113
154
|
"Select at least one page or provider",
|
|
114
155
|
);
|
|
115
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
|
+
});
|
|
116
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(),
|
|
117
180
|
z.object({ operation: z.literal("evidence.import"), document: uiFindingsSchema }).strict(),
|
|
118
181
|
z
|
|
119
182
|
.object({
|
|
@@ -154,6 +217,7 @@ const operationVariants = z.discriminatedUnion("operation", [
|
|
|
154
217
|
maxPages: maxPagesSchema,
|
|
155
218
|
})
|
|
156
219
|
.strict(),
|
|
220
|
+
z.object({ operation: z.literal("ga.realtime"), property: z.string(), request: gaRealtimeRequestSchema }).strict(),
|
|
157
221
|
z.object({ operation: z.literal("ga.accounts") }).strict(),
|
|
158
222
|
z.object({ operation: z.literal("ga.property"), property: z.string() }).strict(),
|
|
159
223
|
z.object({ operation: z.literal("ga.key-events"), property: z.string() }).strict(),
|
package/src/api/snapshot.ts
CHANGED
|
@@ -91,6 +91,21 @@ export function snapshotOperations(
|
|
|
91
91
|
},
|
|
92
92
|
},
|
|
93
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
|
+
}),
|
|
94
109
|
);
|
|
95
110
|
}
|
|
96
111
|
operations.push(...config.pages.map((url) => ({ operation: "page" as const, url })));
|
|
@@ -158,10 +173,14 @@ export async function snapshot(
|
|
|
158
173
|
);
|
|
159
174
|
if (!config.context.successEvents.length)
|
|
160
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
|
+
);
|
|
161
180
|
return result;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
function observationName(op: Operation): string {
|
|
183
|
+
export function observationName(op: Operation): string {
|
|
165
184
|
if (op.operation === "gsc.report") return `gsc.report.${op.request.dimensions?.join("+") || "property"}`;
|
|
166
185
|
if (op.operation === "ga.report") {
|
|
167
186
|
const dimensions = op.request.dimensions?.map((d) => d.name).join("+") || "property";
|
package/src/api/ui-findings.ts
CHANGED
|
@@ -12,7 +12,7 @@ const url = z
|
|
|
12
12
|
const timestamp = z.string().datetime({ offset: true });
|
|
13
13
|
export const uiFindingsSchema = z
|
|
14
14
|
.object({
|
|
15
|
-
provider: z.enum(["bing", "gsc", "other"]),
|
|
15
|
+
provider: z.enum(["bing", "gsc", "ga", "other"]),
|
|
16
16
|
site: url,
|
|
17
17
|
source: z
|
|
18
18
|
.object({
|
|
@@ -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
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { renderAssessment } from "./assessment-text.js";
|
|
2
|
+
import { renderOpportunities } from "./opportunities-text.js";
|
|
1
3
|
import { parseArgs } from "node:util";
|
|
2
4
|
import { defaultDates } from "./shared/dates.js";
|
|
3
5
|
import { execute } from "./api/index.js";
|
|
@@ -22,6 +24,7 @@ pagesight bing sites
|
|
|
22
24
|
pagesight bing queries --site https://example.com/
|
|
23
25
|
pagesight bing pages --site https://example.com/
|
|
24
26
|
pagesight bing traffic --site https://example.com/
|
|
27
|
+
pagesight ga realtime --property 123456 --request realtime.json
|
|
25
28
|
pagesight ga accounts
|
|
26
29
|
pagesight ga property --property 123456
|
|
27
30
|
pagesight ga key-events --property 123456
|
|
@@ -31,12 +34,14 @@ pagesight speed psi --url https://example.com/ [--strategy mobile]
|
|
|
31
34
|
pagesight speed crux --url https://example.com/ [--origin] [--form-factor PHONE]
|
|
32
35
|
pagesight speed history --url https://example.com/ [--origin]
|
|
33
36
|
pagesight snapshot --config seo.config.json [--start YYYY-MM-DD --end YYYY-MM-DD]
|
|
37
|
+
pagesight assess --snapshot saved.json [--format text] [--max-rows 10]
|
|
38
|
+
pagesight opportunities --snapshot saved.json [--min-impressions 20] [--max-clicks 2] [--max-rows 10] [--format text]
|
|
34
39
|
pagesight compare --baseline before.json --current after.json [--max-rows 100]
|
|
35
40
|
pagesight evidence import --request findings.json
|
|
36
41
|
pagesight api --request operation.json
|
|
37
42
|
pagesight serve [--port 6095] Local HTTP API; requires PAGESIGHT_API_TOKEN
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
Data commands emit JSON by default; assess --format text prints a readable summary. --json is accepted for clarity.
|
|
40
45
|
--out FILE saves the same evidence locally. Exit: 0 success, 1 provider failure,
|
|
41
46
|
2 invalid input, 3 partial evidence. Snapshot defaults to 28 days ending Pacific
|
|
42
47
|
today minus 3 days; max-pages defaults to 4 (ad hoc reports: 1; maximum: 20).
|
|
@@ -76,9 +81,13 @@ export async function runCli(args: string[]): Promise<number> {
|
|
|
76
81
|
"form-factor": { type: "string" },
|
|
77
82
|
origin: { type: "boolean" },
|
|
78
83
|
providers: { type: "string" },
|
|
84
|
+
snapshot: { type: "string" },
|
|
85
|
+
format: { type: "string" },
|
|
79
86
|
baseline: { type: "string" },
|
|
80
87
|
current: { type: "string" },
|
|
81
88
|
"max-rows": { type: "string" },
|
|
89
|
+
"min-impressions": { type: "string" },
|
|
90
|
+
"max-clicks": { type: "string" },
|
|
82
91
|
},
|
|
83
92
|
});
|
|
84
93
|
if (args.length === 0 || values.help || positionals[0] === "help") {
|
|
@@ -93,6 +102,8 @@ export async function runCli(args: string[]): Promise<number> {
|
|
|
93
102
|
: family;
|
|
94
103
|
const flags: Record<string, string[]> = {
|
|
95
104
|
"evidence.import": ["request"],
|
|
105
|
+
assess: ["snapshot", "max-rows", "format"],
|
|
106
|
+
opportunities: ["snapshot", "max-rows", "format", "min-impressions", "max-clicks"],
|
|
96
107
|
discover: ["url", "providers"],
|
|
97
108
|
compare: ["baseline", "current", "max-rows"],
|
|
98
109
|
"bing.crawl-stats": ["site"],
|
|
@@ -108,6 +119,7 @@ export async function runCli(args: string[]): Promise<number> {
|
|
|
108
119
|
"gsc.sitemaps": ["site"],
|
|
109
120
|
"gsc.inspect": ["site", "url"],
|
|
110
121
|
"gsc.report": ["site", "request", "max-pages"],
|
|
122
|
+
"ga.realtime": ["property", "request"],
|
|
111
123
|
"ga.accounts": [],
|
|
112
124
|
"ga.property": ["property"],
|
|
113
125
|
"ga.key-events": ["property"],
|
|
@@ -134,10 +146,28 @@ export async function runCli(args: string[]): Promise<number> {
|
|
|
134
146
|
process.stderr.write(`Pagesight API listening on ${server.url}v1/query\n`);
|
|
135
147
|
return 0;
|
|
136
148
|
}
|
|
149
|
+
if (values.format && !["json", "text"].includes(values.format))
|
|
150
|
+
throw new RequestError("Use --format json or text", null, "invalid_input");
|
|
151
|
+
if (values.json && values.format === "text")
|
|
152
|
+
throw new RequestError("--json and --format text conflict", null, "invalid_input");
|
|
137
153
|
let input: unknown;
|
|
138
154
|
if (operation === "api") input = await jsonFile(values.request, "request");
|
|
139
155
|
else if (operation === "evidence.import")
|
|
140
156
|
input = { operation, document: await jsonFile(values.request, "request") };
|
|
157
|
+
else if (operation === "opportunities")
|
|
158
|
+
input = {
|
|
159
|
+
operation,
|
|
160
|
+
snapshot: await jsonFile(values.snapshot, "snapshot"),
|
|
161
|
+
minImpressions: Number(values["min-impressions"] ?? 20),
|
|
162
|
+
maxClicks: Number(values["max-clicks"] ?? 2),
|
|
163
|
+
maxRows: Number(values["max-rows"] ?? 10),
|
|
164
|
+
};
|
|
165
|
+
else if (operation === "assess")
|
|
166
|
+
input = {
|
|
167
|
+
operation,
|
|
168
|
+
snapshot: await jsonFile(values.snapshot, "snapshot"),
|
|
169
|
+
maxRows: Number(values["max-rows"] ?? 10),
|
|
170
|
+
};
|
|
141
171
|
else if (operation === "compare")
|
|
142
172
|
input = {
|
|
143
173
|
operation,
|
|
@@ -169,7 +199,12 @@ export async function runCli(args: string[]): Promise<number> {
|
|
|
169
199
|
};
|
|
170
200
|
}
|
|
171
201
|
const result = await execute(input);
|
|
172
|
-
const output =
|
|
202
|
+
const output =
|
|
203
|
+
values.format === "text"
|
|
204
|
+
? operation === "opportunities"
|
|
205
|
+
? renderOpportunities(result)
|
|
206
|
+
: renderAssessment(result)
|
|
207
|
+
: `${JSON.stringify(result, null, 2)}\n`;
|
|
173
208
|
if (values.out) await Bun.write(values.out, output);
|
|
174
209
|
process.stdout.write(output);
|
|
175
210
|
return result.status === "ok" ? 0 : result.status === "partial" ? 3 : 1;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Evidence } from "./api/evidence.js";
|
|
2
|
+
|
|
3
|
+
const text = (value: unknown) =>
|
|
4
|
+
JSON.stringify(value).replace(
|
|
5
|
+
/[\u007f-\u009f\u2028\u2029]/gu,
|
|
6
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
7
|
+
);
|
|
8
|
+
export function renderOpportunities(result: Evidence): string {
|
|
9
|
+
const out = result.pages[0]?.response as
|
|
10
|
+
| {
|
|
11
|
+
site: string;
|
|
12
|
+
policy: unknown;
|
|
13
|
+
observedSearchRows: number | null;
|
|
14
|
+
unusableSearchRows: number;
|
|
15
|
+
qualifyingObservedRows: number | null;
|
|
16
|
+
omittedCandidates: number;
|
|
17
|
+
requestedDates: unknown;
|
|
18
|
+
snapshotSha256: string;
|
|
19
|
+
limitations: string[];
|
|
20
|
+
unassociatedOrganic: unknown[];
|
|
21
|
+
candidates: Array<{
|
|
22
|
+
url: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
search: unknown;
|
|
25
|
+
organic: unknown;
|
|
26
|
+
technical: unknown;
|
|
27
|
+
unknowns: string[];
|
|
28
|
+
nextChecks: string[];
|
|
29
|
+
suggestedRequests: unknown[];
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
| undefined;
|
|
33
|
+
if (!out) return `${JSON.stringify(result, null, 2)}\n`;
|
|
34
|
+
const lines = [
|
|
35
|
+
`Pagesight opportunities: ${text(out.site)} (${result.status})`,
|
|
36
|
+
`Period: ${text(out.requestedDates)}; supplied snapshot, not reverified.`,
|
|
37
|
+
`Snapshot hash: ${out.snapshotSha256}`,
|
|
38
|
+
`Selection policy: ${text(out.policy)}`,
|
|
39
|
+
`Observed search rows: ${text(out.observedSearchRows)}; unusable: ${out.unusableSearchRows}; qualifying: ${text(out.qualifyingObservedRows)}; omitted candidates: ${out.omittedCandidates}`,
|
|
40
|
+
];
|
|
41
|
+
for (const candidate of out.candidates)
|
|
42
|
+
lines.push(
|
|
43
|
+
"",
|
|
44
|
+
text(candidate.url),
|
|
45
|
+
`Why: ${text(candidate.reason)}`,
|
|
46
|
+
`Search: ${text(candidate.search)}`,
|
|
47
|
+
`Organic associations: ${text(candidate.organic)}`,
|
|
48
|
+
`Technical evidence: ${text(candidate.technical)}`,
|
|
49
|
+
...candidate.unknowns.map((u) => `Unknown: ${text(u)}`),
|
|
50
|
+
...candidate.nextChecks.map((n) => `Next check: ${text(n)}`),
|
|
51
|
+
...candidate.suggestedRequests.map((request) => `Suggested API request: ${text(request)}`),
|
|
52
|
+
);
|
|
53
|
+
lines.push(
|
|
54
|
+
"",
|
|
55
|
+
...out.unassociatedOrganic.map(
|
|
56
|
+
(table) => `Organic evidence not associated with displayed candidates: ${text(table)}`,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
lines.push("", ...out.limitations.map((l) => `Limit: ${text(l)}`));
|
|
60
|
+
return `${lines.join("\n")}\n`;
|
|
61
|
+
}
|
package/src/tools/observe.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { RequestError } from "../shared/http.js";
|
|
|
5
5
|
export function registerObserveTool(server: McpServer): void {
|
|
6
6
|
server.tool(
|
|
7
7
|
"observe",
|
|
8
|
-
"Run the shared Pagesight API. Read-only operations: discover; bing.sites/queries/pages/traffic/crawl-stats/crawl-issues/url-info/link-counts/url-links; evidence.import (unverified UI findings); gsc.sites/sitemaps/inspect/report; ga.accounts/property/key-events/report; page (metadata and image ALT evidence); speed.psi/crux/history; doctor; snapshot; compare. Pass the API operation object as request. Returns structured evidence, exact effective requests, provider responses and partial errors. See the CLI --help and README for report/config shapes.",
|
|
8
|
+
"Run the shared Pagesight API. Read-only operations: discover; bing.sites/queries/pages/traffic/crawl-stats/crawl-issues/url-info/link-counts/url-links; evidence.import (unverified UI findings); gsc.sites/sitemaps/inspect/report; ga.accounts/property/key-events/report/realtime; page (metadata and image ALT evidence); speed.psi/crux/history; doctor; snapshot; assess (saved evidence summary); compare. Pass the API operation object as request. Returns structured evidence, exact effective requests, provider responses and partial errors. See the CLI --help and README for report/config shapes.",
|
|
9
9
|
{ request: operationSchema },
|
|
10
10
|
async ({ request }) => {
|
|
11
11
|
try {
|