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.
- package/README.md +19 -0
- package/docs/changes.md +118 -0
- package/docs/cloudflare.md +31 -0
- package/docs/crawl.md +64 -0
- package/docs/investigation.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/monitoring.md +39 -0
- package/docs/opportunities.md +78 -0
- package/docs/rendering.md +102 -0
- package/docs/seo-agent-workflow.md +153 -0
- package/docs/snapshots.md +7 -0
- package/docs/usage.md +25 -17
- package/package.json +5 -2
- package/src/api/assessment.ts +315 -0
- package/src/api/change-record.ts +31 -0
- package/src/api/cloudflare.ts +201 -0
- package/src/api/compare-snapshots.ts +5 -215
- package/src/api/crawl.ts +53 -0
- package/src/api/evaluate-change.ts +189 -0
- package/src/api/execute.ts +42 -0
- package/src/api/followup-changes.ts +264 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/http-url.ts +13 -0
- package/src/api/investigation.ts +344 -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 +162 -11
- package/src/api/snapshot.ts +20 -1
- package/src/api/technical-changes.ts +124 -0
- package/src/api/ui-findings.ts +1 -1
- package/src/api/verify-render.ts +134 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +173 -38
- package/src/followup-manifest.ts +42 -0
- package/src/followup-text.ts +45 -0
- package/src/investigation-text.ts +32 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/cloudflare.ts +46 -0
- package/src/tools/observe.ts +1 -1
- package/src/web/fetch.ts +2 -1
- package/src/web/render-browser.ts +190 -0
- package/src/web/render-dom.ts +72 -0
- package/src/web/render-network.ts +122 -0
- 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
|
-
|
|
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 (
|
|
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
|
|
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 === "
|
|
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 = {
|
|
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 =
|
|
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
|
+
}
|
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.
|
|
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
|
});
|