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.
@@ -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
+ }
@@ -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 httpUrl = z
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) => ["http:", "https:"].includes(new URL(v).protocol) && !new URL(v).username && !new URL(v).password,
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(),
@@ -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";
@@ -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
- All data commands emit JSON. --json is accepted for clarity.
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 = `${JSON.stringify(result, null, 2)}\n`;
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
+ }
@@ -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 {