pagesight 0.18.0 → 0.20.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.
Files changed (46) hide show
  1. package/README.md +19 -0
  2. package/docs/changes.md +118 -0
  3. package/docs/cloudflare.md +31 -0
  4. package/docs/crawl.md +64 -0
  5. package/docs/investigation.md +85 -0
  6. package/docs/measurement.md +178 -0
  7. package/docs/monitoring.md +39 -0
  8. package/docs/opportunities.md +78 -0
  9. package/docs/rendering.md +102 -0
  10. package/docs/seo-agent-workflow.md +153 -0
  11. package/docs/snapshots.md +7 -0
  12. package/docs/usage.md +25 -17
  13. package/package.json +5 -2
  14. package/src/api/assessment.ts +315 -0
  15. package/src/api/change-record.ts +31 -0
  16. package/src/api/cloudflare.ts +201 -0
  17. package/src/api/compare-snapshots.ts +5 -215
  18. package/src/api/crawl.ts +53 -0
  19. package/src/api/evaluate-change.ts +189 -0
  20. package/src/api/execute.ts +42 -0
  21. package/src/api/followup-changes.ts +264 -0
  22. package/src/api/ga-freshness.ts +20 -0
  23. package/src/api/ga-realtime.ts +40 -0
  24. package/src/api/http-url.ts +13 -0
  25. package/src/api/investigation.ts +344 -0
  26. package/src/api/opportunities.ts +317 -0
  27. package/src/api/report-table.ts +216 -0
  28. package/src/api/reports.ts +19 -1
  29. package/src/api/schema.ts +162 -11
  30. package/src/api/snapshot.ts +20 -1
  31. package/src/api/technical-changes.ts +124 -0
  32. package/src/api/ui-findings.ts +1 -1
  33. package/src/api/verify-render.ts +134 -0
  34. package/src/assessment-text.ts +55 -0
  35. package/src/cli.ts +173 -38
  36. package/src/followup-manifest.ts +42 -0
  37. package/src/followup-text.ts +45 -0
  38. package/src/investigation-text.ts +32 -0
  39. package/src/opportunities-text.ts +61 -0
  40. package/src/providers/cloudflare.ts +46 -0
  41. package/src/tools/observe.ts +1 -1
  42. package/src/web/fetch.ts +2 -1
  43. package/src/web/render-browser.ts +190 -0
  44. package/src/web/render-dom.ts +72 -0
  45. package/src/web/render-network.ts +122 -0
  46. package/src/web/site-graph.ts +443 -0
package/src/cli.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { loadFollowupManifest } from "./followup-manifest.js";
2
+ import { renderFollowup } from "./followup-text.js";
3
+ import { renderInvestigation } from "./investigation-text.js";
4
+ import { renderAssessment } from "./assessment-text.js";
5
+ import { renderOpportunities } from "./opportunities-text.js";
1
6
  import { parseArgs } from "node:util";
2
7
  import { defaultDates } from "./shared/dates.js";
3
8
  import { execute } from "./api/index.js";
@@ -8,6 +13,11 @@ export const help = `Pagesight — read-only site evidence
8
13
  pagesight Show CLI help
9
14
  pagesight mcp Start MCP explicitly
10
15
  pagesight discover --url https://example.com/ [--providers gsc,ga]
16
+ pagesight cloudflare audit --zone ZONE_ID --hostname example.com --start UTC_TIME --end UTC_TIME [--limit 50]
17
+
18
+ pagesight change followup --manifest experiments.json [--as-of UTC_TIME] [--lag-days 3] [--format text]
19
+ pagesight change evaluate --record change.json --baseline before.json [--current after.json] [--max-rows 20]
20
+ pagesight technical compare --current current.json [--baseline previous.json]
11
21
  pagesight doctor --config seo.config.json
12
22
  pagesight gsc sites
13
23
  pagesight gsc sitemaps --site sc-domain:example.com
@@ -22,21 +32,27 @@ pagesight bing sites
22
32
  pagesight bing queries --site https://example.com/
23
33
  pagesight bing pages --site https://example.com/
24
34
  pagesight bing traffic --site https://example.com/
35
+ pagesight ga realtime --property 123456 --request realtime.json
25
36
  pagesight ga accounts
26
37
  pagesight ga property --property 123456
27
38
  pagesight ga key-events --property 123456
28
39
  pagesight ga report --property 123456 --request report.json [--max-pages 4]
40
+ pagesight render --url URL [--from-url URL --link-selector CSS] [--settle-ms 1000] [--timeout-ms 20000]
29
41
  pagesight page --url https://example.com/
30
42
  pagesight speed psi --url https://example.com/ [--strategy mobile]
31
43
  pagesight speed crux --url https://example.com/ [--origin] [--form-factor PHONE]
32
44
  pagesight speed history --url https://example.com/ [--origin]
33
45
  pagesight snapshot --config seo.config.json [--start YYYY-MM-DD --end YYYY-MM-DD]
46
+ pagesight crawl --config seo.config.json [--max-pages 20] [--max-depth 3] [--inspect-limit 3]
47
+ pagesight investigate --config seo.config.json --url https://example.com/page [--start YYYY-MM-DD --end YYYY-MM-DD] [--format text]
48
+ pagesight assess --snapshot saved.json [--format text] [--max-rows 10]
49
+ pagesight opportunities --snapshot saved.json [--min-impressions 20] [--max-clicks 2] [--max-rows 10] [--format text]
34
50
  pagesight compare --baseline before.json --current after.json [--max-rows 100]
35
51
  pagesight evidence import --request findings.json
36
52
  pagesight api --request operation.json
37
53
  pagesight serve [--port 6095] Local HTTP API; requires PAGESIGHT_API_TOKEN
38
54
 
39
- All data commands emit JSON. --json is accepted for clarity.
55
+ Data commands emit JSON by default; assess, opportunities, investigate and change followup accept --format text for readable summaries. --json is accepted for clarity.
40
56
  --out FILE saves the same evidence locally. Exit: 0 success, 1 provider failure,
41
57
  2 invalid input, 3 partial evidence. Snapshot defaults to 28 days ending Pacific
42
58
  today minus 3 days; max-pages defaults to 4 (ad hoc reports: 1; maximum: 20).
@@ -44,6 +60,48 @@ Credentials: GSC_* env, PAGESIGHT_GA_CREDENTIALS or GOOGLE_APPLICATION_CREDENTIA
44
60
  Requests, property metadata, provider limits and errors are preserved in evidence.
45
61
  `;
46
62
 
63
+ // Every CLI command and the flags it accepts; surface tests fail when one has no case.
64
+ export const commands: Record<string, string[]> = {
65
+ render: ["url", "from-url", "link-selector", "settle-ms", "timeout-ms"],
66
+ "cloudflare.audit": ["zone", "hostname", "start", "end", "limit"],
67
+ "change.followup": ["manifest", "as-of", "lag-days", "format"],
68
+ "change.evaluate": ["record", "baseline", "current", "max-rows"],
69
+ "technical.compare": ["baseline", "current"],
70
+ "evidence.import": ["request"],
71
+ crawl: ["config", "max-pages", "max-depth", "max-links", "inspect-limit", "include-query"],
72
+ investigate: ["config", "url", "start", "end", "max-pages", "max-rows", "format"],
73
+ assess: ["snapshot", "max-rows", "format"],
74
+ opportunities: ["snapshot", "max-rows", "format", "min-impressions", "max-clicks"],
75
+ discover: ["url", "providers"],
76
+ compare: ["baseline", "current", "max-rows"],
77
+ "bing.crawl-stats": ["site"],
78
+ "bing.crawl-issues": ["site"],
79
+ "bing.url-info": ["site", "url"],
80
+ "bing.link-counts": ["site", "max-pages"],
81
+ "bing.url-links": ["site", "url", "max-pages"],
82
+ "bing.sites": [],
83
+ "bing.queries": ["site"],
84
+ "bing.pages": ["site"],
85
+ "bing.traffic": ["site"],
86
+ "gsc.sites": [],
87
+ "gsc.sitemaps": ["site"],
88
+ "gsc.inspect": ["site", "url"],
89
+ "gsc.report": ["site", "request", "max-pages"],
90
+ "ga.realtime": ["property", "request"],
91
+ "ga.accounts": [],
92
+ "ga.property": ["property"],
93
+ "ga.key-events": ["property"],
94
+ "ga.report": ["property", "request", "max-pages"],
95
+ page: ["url"],
96
+ "speed.psi": ["url", "strategy"],
97
+ "speed.crux": ["url", "origin", "form-factor"],
98
+ "speed.history": ["url", "origin", "form-factor"],
99
+ doctor: ["config"],
100
+ snapshot: ["config", "start", "end", "max-pages"],
101
+ api: ["request"],
102
+ serve: ["port"],
103
+ };
104
+
47
105
  async function jsonFile(path: string | undefined, label: string): Promise<unknown> {
48
106
  if (!path) throw new RequestError(`Missing --${label}`, null, "invalid_input");
49
107
  try {
@@ -60,6 +118,17 @@ export async function runCli(args: string[]): Promise<number> {
60
118
  allowPositionals: true,
61
119
  strict: true,
62
120
  options: {
121
+ zone: { type: "string" },
122
+ hostname: { type: "string" },
123
+ "from-url": { type: "string" },
124
+ "link-selector": { type: "string" },
125
+ "settle-ms": { type: "string" },
126
+ "timeout-ms": { type: "string" },
127
+ limit: { type: "string" },
128
+ record: { type: "string" },
129
+ manifest: { type: "string" },
130
+ "as-of": { type: "string" },
131
+ "lag-days": { type: "string" },
63
132
  port: { type: "string" },
64
133
  help: { type: "boolean", short: "h" },
65
134
  json: { type: "boolean" },
@@ -76,9 +145,17 @@ export async function runCli(args: string[]): Promise<number> {
76
145
  "form-factor": { type: "string" },
77
146
  origin: { type: "boolean" },
78
147
  providers: { type: "string" },
148
+ snapshot: { type: "string" },
149
+ format: { type: "string" },
79
150
  baseline: { type: "string" },
80
151
  current: { type: "string" },
81
152
  "max-rows": { type: "string" },
153
+ "min-impressions": { type: "string" },
154
+ "max-clicks": { type: "string" },
155
+ "max-depth": { type: "string" },
156
+ "max-links": { type: "string" },
157
+ "inspect-limit": { type: "string" },
158
+ "include-query": { type: "boolean" },
82
159
  },
83
160
  });
84
161
  if (args.length === 0 || values.help || positionals[0] === "help") {
@@ -86,42 +163,15 @@ export async function runCli(args: string[]): Promise<number> {
86
163
  return 0;
87
164
  }
88
165
  const [family, action] = positionals;
89
- if (positionals.length > (["gsc", "ga", "bing", "speed", "evidence"].includes(family) ? 2 : 1))
166
+ if (
167
+ positionals.length >
168
+ (["gsc", "ga", "bing", "speed", "evidence", "cloudflare", "change", "technical"].includes(family) ? 2 : 1)
169
+ )
90
170
  throw new RequestError("Too many command arguments", null, "invalid_input");
91
- const operation = ["gsc", "ga", "bing", "speed", "evidence"].includes(family)
171
+ const operation = ["gsc", "ga", "bing", "speed", "evidence", "cloudflare", "change", "technical"].includes(family)
92
172
  ? `${family}.${action ?? ""}`
93
173
  : family;
94
- const flags: Record<string, string[]> = {
95
- "evidence.import": ["request"],
96
- discover: ["url", "providers"],
97
- compare: ["baseline", "current", "max-rows"],
98
- "bing.crawl-stats": ["site"],
99
- "bing.crawl-issues": ["site"],
100
- "bing.url-info": ["site", "url"],
101
- "bing.link-counts": ["site", "max-pages"],
102
- "bing.url-links": ["site", "url", "max-pages"],
103
- "bing.sites": [],
104
- "bing.queries": ["site"],
105
- "bing.pages": ["site"],
106
- "bing.traffic": ["site"],
107
- "gsc.sites": [],
108
- "gsc.sitemaps": ["site"],
109
- "gsc.inspect": ["site", "url"],
110
- "gsc.report": ["site", "request", "max-pages"],
111
- "ga.accounts": [],
112
- "ga.property": ["property"],
113
- "ga.key-events": ["property"],
114
- "ga.report": ["property", "request", "max-pages"],
115
- page: ["url"],
116
- "speed.psi": ["url", "strategy"],
117
- "speed.crux": ["url", "origin", "form-factor"],
118
- "speed.history": ["url", "origin", "form-factor"],
119
- doctor: ["config"],
120
- snapshot: ["config", "start", "end", "max-pages"],
121
- api: ["request"],
122
- serve: ["port"],
123
- };
124
- const allowed = [...(flags[operation] ?? []), ...(operation === "serve" ? [] : ["out", "json"])];
174
+ const allowed = [...(commands[operation] ?? []), ...(operation === "serve" ? [] : ["out", "json"])];
125
175
  for (const flag of Object.keys(values))
126
176
  if (!allowed.includes(flag))
127
177
  throw new RequestError(`--${flag} is not supported for ${operation}`, null, "invalid_input");
@@ -134,10 +184,80 @@ export async function runCli(args: string[]): Promise<number> {
134
184
  process.stderr.write(`Pagesight API listening on ${server.url}v1/query\n`);
135
185
  return 0;
136
186
  }
187
+ if (values.format && !["json", "text"].includes(values.format))
188
+ throw new RequestError("Use --format json or text", null, "invalid_input");
189
+ if (values.json && values.format === "text")
190
+ throw new RequestError("--json and --format text conflict", null, "invalid_input");
137
191
  let input: unknown;
138
192
  if (operation === "api") input = await jsonFile(values.request, "request");
139
- else if (operation === "evidence.import")
193
+ else if (operation === "render") {
194
+ if (Boolean(values["from-url"]) !== Boolean(values["link-selector"]))
195
+ throw new RequestError("Supply both --from-url and --link-selector", null, "invalid_input");
196
+ input = {
197
+ operation: "page.verify",
198
+ url: values.url,
199
+ ...(values["from-url"]
200
+ ? { navigation: { fromUrl: values["from-url"], linkSelector: values["link-selector"] } }
201
+ : {}),
202
+ settleMs: Number(values["settle-ms"] ?? 1000),
203
+ timeoutMs: Number(values["timeout-ms"] ?? 20000),
204
+ };
205
+ } else if (operation === "evidence.import")
140
206
  input = { operation, document: await jsonFile(values.request, "request") };
207
+ else if (operation === "cloudflare.audit")
208
+ input = {
209
+ operation,
210
+ zone: values.zone,
211
+ hostname: values.hostname,
212
+ startTime: values.start,
213
+ endTime: values.end,
214
+ limit: Number(values.limit ?? 50),
215
+ };
216
+ else if (operation === "change.followup")
217
+ input = {
218
+ operation,
219
+ experiments: await loadFollowupManifest(values.manifest),
220
+ asOf: values["as-of"],
221
+ lagDays: Number(values["lag-days"] ?? 3),
222
+ };
223
+ else if (operation === "change.evaluate")
224
+ input = {
225
+ operation,
226
+ record: await jsonFile(values.record, "record"),
227
+ baseline: await jsonFile(values.baseline, "baseline"),
228
+ ...(values.current ? { current: await jsonFile(values.current, "current") } : {}),
229
+ maxRows: Number(values["max-rows"] ?? 20),
230
+ };
231
+ else if (operation === "crawl")
232
+ input = {
233
+ operation,
234
+ config: await jsonFile(values.config, "config"),
235
+ maxPages: Number(values["max-pages"] ?? 20),
236
+ maxDepth: Number(values["max-depth"] ?? 3),
237
+ maxLinks: Number(values["max-links"] ?? 500),
238
+ inspectLimit: Number(values["inspect-limit"] ?? 3),
239
+ includeQuery: values["include-query"] ?? false,
240
+ };
241
+ else if (operation === "technical.compare")
242
+ input = {
243
+ operation,
244
+ current: await jsonFile(values.current, "current"),
245
+ ...(values.baseline ? { baseline: await jsonFile(values.baseline, "baseline") } : {}),
246
+ };
247
+ else if (operation === "opportunities")
248
+ input = {
249
+ operation,
250
+ snapshot: await jsonFile(values.snapshot, "snapshot"),
251
+ minImpressions: Number(values["min-impressions"] ?? 20),
252
+ maxClicks: Number(values["max-clicks"] ?? 2),
253
+ maxRows: Number(values["max-rows"] ?? 10),
254
+ };
255
+ else if (operation === "assess")
256
+ input = {
257
+ operation,
258
+ snapshot: await jsonFile(values.snapshot, "snapshot"),
259
+ maxRows: Number(values["max-rows"] ?? 10),
260
+ };
141
261
  else if (operation === "compare")
142
262
  input = {
143
263
  operation,
@@ -145,14 +265,20 @@ export async function runCli(args: string[]): Promise<number> {
145
265
  current: await jsonFile(values.current, "current"),
146
266
  maxRows: Number(values["max-rows"] ?? 100),
147
267
  };
148
- else if (operation === "snapshot" || operation === "doctor") {
268
+ else if (operation === "snapshot" || operation === "doctor" || operation === "investigate") {
149
269
  const config = await jsonFile(values.config, "config");
150
270
  if (operation === "doctor") input = { operation, config };
151
271
  else {
152
272
  if (Boolean(values.start) !== Boolean(values.end))
153
273
  throw new RequestError("Supply both --start and --end, or neither", null, "invalid_input");
154
274
  const dates = values.start ? { startDate: values.start, endDate: values.end } : defaultDates();
155
- input = { operation, config, ...dates, maxPages: Number(values["max-pages"] ?? 4) };
275
+ input = {
276
+ operation,
277
+ config,
278
+ ...dates,
279
+ maxPages: Number(values["max-pages"] ?? (operation === "investigate" ? 1 : 4)),
280
+ ...(operation === "investigate" ? { url: values.url, maxRows: Number(values["max-rows"] ?? 28) } : {}),
281
+ };
156
282
  }
157
283
  } else {
158
284
  input = {
@@ -169,7 +295,16 @@ export async function runCli(args: string[]): Promise<number> {
169
295
  };
170
296
  }
171
297
  const result = await execute(input);
172
- const output = `${JSON.stringify(result, null, 2)}\n`;
298
+ const output =
299
+ values.format === "text"
300
+ ? operation === "change.followup"
301
+ ? renderFollowup(result)
302
+ : operation === "investigate"
303
+ ? renderInvestigation(result)
304
+ : operation === "opportunities"
305
+ ? renderOpportunities(result)
306
+ : renderAssessment(result)
307
+ : `${JSON.stringify(result, null, 2)}\n`;
173
308
  if (values.out) await Bun.write(values.out, output);
174
309
  process.stdout.write(output);
175
310
  return result.status === "ok" ? 0 : result.status === "partial" ? 3 : 1;
@@ -0,0 +1,42 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { RequestError } from "./shared/http.js";
3
+
4
+ export async function loadFollowupManifest(path: string | undefined): Promise<unknown[]> {
5
+ if (!path) throw new RequestError("Missing --manifest", null, "invalid_input");
6
+ let manifest: unknown;
7
+ try {
8
+ manifest = await Bun.file(path).json();
9
+ } catch {
10
+ throw new RequestError("Cannot read --manifest JSON file", null, "invalid_input");
11
+ }
12
+ if (!Array.isArray(manifest) || !manifest.length || manifest.length > 50)
13
+ throw new RequestError("Manifest must contain 1–50 experiment entries", null, "invalid_input");
14
+ const base = dirname(resolve(path));
15
+ const read = async (value: unknown) => {
16
+ if (typeof value !== "string" || !value) return null;
17
+ try {
18
+ return await Bun.file(resolve(base, value)).json();
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+ const entries = [];
24
+ for (const item of manifest) {
25
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
26
+ entries.push(null);
27
+ continue;
28
+ }
29
+ const entry = item as Record<string, unknown>;
30
+ if (Object.keys(entry).some((key) => !["label", "record", "baseline", "current"].includes(key))) {
31
+ entries.push(null);
32
+ continue;
33
+ }
34
+ entries.push({
35
+ label: entry.label,
36
+ record: await read(entry.record),
37
+ baseline: await read(entry.baseline),
38
+ ...(Object.hasOwn(entry, "current") ? { current: await read(entry.current) } : {}),
39
+ });
40
+ }
41
+ return entries;
42
+ }
@@ -0,0 +1,45 @@
1
+ import type { Evidence } from "./api/evidence.js";
2
+
3
+ export function renderFollowup(evidence: Evidence): string {
4
+ const data = evidence.pages[0]?.response as
5
+ | {
6
+ asOf: string;
7
+ entries: Array<{
8
+ index: number;
9
+ label?: string;
10
+ id?: string;
11
+ status: string;
12
+ reason?: string;
13
+ confounded?: boolean;
14
+ reports?: Array<{
15
+ name: string;
16
+ status: string;
17
+ reason?: string;
18
+ nextCollection?: { date: string; timezone: string };
19
+ }>;
20
+ }>;
21
+ }
22
+ | undefined;
23
+ if (!data) return `Experiment follow-up unavailable: ${evidence.error?.message ?? evidence.status}\n`;
24
+ const lines = [
25
+ `Experiment follow-up as of ${data.asOf}`,
26
+ "Collection dates are planning estimates; no data is collected automatically.",
27
+ "",
28
+ ];
29
+ for (const entry of data.entries) {
30
+ lines.push(`${entry.label ?? `Entry ${entry.index + 1}`}${entry.id ? ` (${entry.id})` : ""}: ${entry.status}`);
31
+ if (entry.reason) lines.push(` ${entry.reason}`);
32
+ if (entry.confounded) lines.push(" Declared overlapping changes confound attribution.");
33
+ for (const report of entry.reports ?? []) {
34
+ lines.push(
35
+ ` ${report.name}: ${report.status}${report.nextCollection ? ` — collect ${report.nextCollection.date} (${report.nextCollection.timezone})` : ""}`,
36
+ );
37
+ if (report.reason) lines.push(` ${report.reason}`);
38
+ }
39
+ lines.push("");
40
+ }
41
+ lines.push(
42
+ "Readiness is per report. Review raw JSON warnings and source hashes; no causal SEO conclusion is implied.",
43
+ );
44
+ return lines.join("\n") + "\n";
45
+ }
@@ -0,0 +1,32 @@
1
+ import type { Evidence } from "./api/evidence.js";
2
+ import type { investigationBrief } from "./api/investigation.js";
3
+
4
+ export function renderInvestigation(evidence: Evidence): string {
5
+ const response = evidence.pages[0]?.response as {
6
+ requestedDates: unknown;
7
+ context: unknown;
8
+ brief: ReturnType<typeof investigationBrief>;
9
+ };
10
+ const { brief } = response;
11
+ const quote = (value: unknown) =>
12
+ JSON.stringify(value).replace(
13
+ /[\u007f-\u009f\u2028\u2029]/gu,
14
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
15
+ );
16
+ return [
17
+ `Pagesight investigation: ${quote(evidence.target)} (${evidence.status})`,
18
+ `Reporting window: ${quote(response.requestedDates)}`,
19
+ `Caller context (unverified): ${quote(response.context)}`,
20
+ ...brief.findings.map((f) => `Finding [${f.source}]: ${quote(f.statement)}`),
21
+ ...brief.tables.flatMap((t) => [
22
+ `Evidence [${t.source}]: ${quote({ displayPolicy: t.displayPolicy, dimensions: t.dimensions, metrics: t.metrics, rows: t.rows, observedRows: t.observedRows, omittedRows: t.omittedRows, unusableRows: t.unusableRows, paginationExhausted: t.paginationExhausted, collectedAt: t.collectedAt, metadata: t.metadata })}`,
23
+ ...t.warnings.map((w) => `Warning [${t.source}]: ${quote(w)}`),
24
+ ]),
25
+ ...brief.technical.map((t) => `Technical [${t.source}]: ${quote(t)}`),
26
+ "Full raw requests/responses and errors are available in JSON output.",
27
+ ...brief.unknowns.map((v) => `Unknown: ${quote(v)}`),
28
+ ...brief.nextChecks.map((v) => `Next check: ${quote(v)}`),
29
+ ...brief.limitations.map((v) => `Limitation: ${quote(v)}`),
30
+ "",
31
+ ].join("\n");
32
+ }
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import { RequestError, readBounded } from "../shared/http.js";
2
+
3
+ export type CloudflareQuery = { query: string; variables: Record<string, unknown> };
4
+ export async function cloudflareGraphql(request: CloudflareQuery): Promise<unknown> {
5
+ const token = process.env.CLOUDFLARE_API_TOKEN;
6
+ if (!token)
7
+ throw new RequestError(
8
+ "Set CLOUDFLARE_API_TOKEN with read access to the selected zone's analytics",
9
+ null,
10
+ "not_configured",
11
+ );
12
+ const signal = AbortSignal.timeout(30_000);
13
+ let response: Response;
14
+ try {
15
+ response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
16
+ method: "POST",
17
+ signal,
18
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
19
+ body: JSON.stringify(request),
20
+ });
21
+ } catch {
22
+ throw new RequestError("Cloudflare request failed or timed out", null, "network_error");
23
+ }
24
+ if (!response.ok) {
25
+ await response.body?.cancel();
26
+ throw new RequestError(
27
+ `Cloudflare returned HTTP ${response.status}`,
28
+ response.status,
29
+ response.status === 401
30
+ ? "unauthenticated"
31
+ : response.status === 403
32
+ ? "forbidden"
33
+ : response.status === 429
34
+ ? "quota_exceeded"
35
+ : "provider_error",
36
+ );
37
+ }
38
+ try {
39
+ return JSON.parse(await readBounded(response, 8_000_000));
40
+ } catch (error) {
41
+ if (error instanceof RequestError) throw error;
42
+ if (error instanceof SyntaxError)
43
+ throw new RequestError("Cloudflare response could not be read as JSON", response.status, "invalid_response");
44
+ throw new RequestError("Cloudflare response body failed or timed out", null, "network_error");
45
+ }
46
+ }
@@ -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. Evidence operations: page.verify (anonymous browser SEO evidence and one validated internal anchor navigation; may generate site requests); cloudflare.audit (bounded zone analytics/settings/security evidence); technical.compare (saved exact-page technical changes); change.followup (saved experiment collection dates and per-report blockers); change.evaluate (deployment context and comparable snapshot evidence); 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; crawl (bounded robots-aware site graph and indexing sample); investigate (fresh exact-page search, organic and technical investigation); opportunities (saved search candidates); 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 {
package/src/web/fetch.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readBounded, RequestError } from "../shared/http.js";
2
2
 
3
- export async function fetchText(url: string, maxBytes: number, origin?: string) {
3
+ export async function fetchText(url: string, maxBytes: number, origin?: string, proxy?: string) {
4
4
  const redirects: Array<{ url: string; status: number; location: string }> = [];
5
5
  let current = url;
6
6
  const signal = AbortSignal.timeout(20_000);
@@ -15,6 +15,7 @@ export async function fetchText(url: string, maxBytes: number, origin?: string)
15
15
  throw new RequestError("Redirect left the permitted origin or protocol", null, "invalid_redirect");
16
16
  const response = await fetch(current, {
17
17
  signal,
18
+ ...(proxy ? { proxy } : {}),
18
19
  redirect: "manual",
19
20
  headers: { "User-Agent": "Pagesight/0.17", Accept: "text/html,application/xml,text/plain" },
20
21
  });