pagesight 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- package/src/tools/speed.ts +0 -956
package/docs/usage.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Using Pagesight
|
|
2
|
+
|
|
3
|
+
## API
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { execute } from "pagesight";
|
|
7
|
+
|
|
8
|
+
const report = await execute({
|
|
9
|
+
operation: "gsc.report",
|
|
10
|
+
site: "sc-domain:example.com",
|
|
11
|
+
request: {
|
|
12
|
+
startDate: "2026-08-01",
|
|
13
|
+
endDate: "2026-08-28",
|
|
14
|
+
dimensions: ["page"],
|
|
15
|
+
},
|
|
16
|
+
maxPages: 4,
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Each result contains `schemaVersion`, `provider`, `operation`, `target`, collection
|
|
21
|
+
timestamps, `status`, request/response `pages`, `warnings`, and any `error` and
|
|
22
|
+
`failedRequest`. Reports include `pagination` with `exhausted`, `nextOffset` and
|
|
23
|
+
`rowsReturned`. Raw provider metadata, aggregation, quota, sampling and thresholding
|
|
24
|
+
are retained. Exhausted pagination does **not** imply exhaustive search coverage.
|
|
25
|
+
GA evidence identifies the credential source variable, credential type, and service
|
|
26
|
+
account email when present. It never includes the token or private key. Aggregate
|
|
27
|
+
results include a concise per-observation `summary` alongside full observations.
|
|
28
|
+
|
|
29
|
+
| Operation | Required inputs |
|
|
30
|
+
| -------------------------------------------- | -------------------------------------------------------- |
|
|
31
|
+
| `discover` | `url`; optional `providers` (`["gsc", "ga"]` by default) |
|
|
32
|
+
| `bing.sites` | None |
|
|
33
|
+
| `bing.queries`, `bing.pages`, `bing.traffic` | `site` |
|
|
34
|
+
| `gsc.sites` | None |
|
|
35
|
+
| `gsc.sitemaps` | `site` |
|
|
36
|
+
| `gsc.inspect` | `site`, `url` |
|
|
37
|
+
| `gsc.report` | `site`, `request`; optional `maxPages` |
|
|
38
|
+
| `ga.accounts` | None |
|
|
39
|
+
| `ga.property`, `ga.key-events` | `property` |
|
|
40
|
+
| `ga.realtime` | `property`, `request`; moving window, no offset |
|
|
41
|
+
| `ga.report` | `property`, `request`; optional `maxPages` |
|
|
42
|
+
| `page` | `url` |
|
|
43
|
+
| `speed.psi` | `url`; optional `strategy` (`mobile` or `desktop`) |
|
|
44
|
+
| `speed.crux`, `speed.history` | `url`; optional `origin: true`, `formFactor` |
|
|
45
|
+
| `doctor` | `config` |
|
|
46
|
+
| `assess` | `snapshot`; optional `maxRows` (default 10, max 100) |
|
|
47
|
+
| `snapshot` | `config`, `startDate`, `endDate`; optional `maxPages` |
|
|
48
|
+
|
|
49
|
+
`operationSchema` and `configSchema` are exported for typed validation. The MCP
|
|
50
|
+
`observe` input uses the same schema. `page` observes fetched HTML, status,
|
|
51
|
+
redirects, canonical, robots directives, JSON-LD and a content hash. It does not
|
|
52
|
+
execute browser JavaScript. The original MCP page tool retains its additional
|
|
53
|
+
link, social-meta and contrast checks.
|
|
54
|
+
|
|
55
|
+
See [measurement and verification](measurement.md) for saved-snapshot assessments,
|
|
56
|
+
Realtime requests, freshness limits and repeatable browser checks.
|
|
57
|
+
|
|
58
|
+
## CLI
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pagesight --help
|
|
62
|
+
pagesight discover --url https://example.com/ --providers gsc,ga
|
|
63
|
+
pagesight doctor --config seo.config.json
|
|
64
|
+
pagesight gsc report --site sc-domain:example.com --request report.json --max-pages 4
|
|
65
|
+
pagesight ga report --property 123456 --request ga-report.json
|
|
66
|
+
pagesight page --url https://example.com/
|
|
67
|
+
pagesight speed psi --url https://example.com/
|
|
68
|
+
pagesight speed crux --url https://example.com/ --origin
|
|
69
|
+
pagesight snapshot --config seo.config.json --out observations/baseline.json
|
|
70
|
+
pagesight api --request operation.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A GSC `report.json` uses the provider request shape. Defaults are finalized data,
|
|
74
|
+
Web search, no dimensions, 25,000 rows, offset zero. Ad hoc commands fetch one page
|
|
75
|
+
unless `--max-pages` is supplied (maximum 20). Request examples:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "startDate": "2026-08-01", "endDate": "2026-08-28", "dimensions": ["page"] }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"dateRanges": [{ "startDate": "2026-08-01", "endDate": "2026-08-28" }],
|
|
84
|
+
"dimensions": [{ "name": "eventName" }],
|
|
85
|
+
"metrics": [{ "name": "eventCount" }, { "name": "keyEvents" }],
|
|
86
|
+
"dimensionFilter": {
|
|
87
|
+
"filter": { "fieldName": "hostName", "stringFilter": { "matchType": "EXACT", "value": "example.com" } }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
GA report defaults are 10,000 rows, offset zero and quota metadata requested.
|
|
93
|
+
`rowCount` determines remaining pages. For discovery/key-event lists, retain and
|
|
94
|
+
check any `nextPageToken`; these small metadata requests currently return one page.
|
|
95
|
+
They return `status: partial` when another metadata page is available.
|
|
96
|
+
|
|
97
|
+
All data commands output JSON; `--json` is accepted. `--out` writes the same result.
|
|
98
|
+
Exit codes: 0 success, 1 provider failure, 2 invalid input, 3 partial evidence.
|
|
99
|
+
Partial snapshots are saved. A successful report can still have provider coverage
|
|
100
|
+
limitations; inspect warnings and metadata before making comparisons.
|
|
101
|
+
|
|
102
|
+
## Local HTTP interface
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
# Set PAGESIGHT_API_TOKEN in your private environment file (at least 24 characters).
|
|
106
|
+
pagesight serve --port 6095
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Send the shared operation JSON to `POST http://127.0.0.1:6095/v1/query` with
|
|
110
|
+
`Authorization: Bearer <PAGESIGHT_API_TOKEN>`. The server binds only to loopback.
|
|
111
|
+
Missing/invalid authentication returns 401; invalid operations return 400; provider
|
|
112
|
+
failures return 502; partial evidence returns 200 with `status: partial`.
|
|
113
|
+
This interface is for local agents, not an Internet deployment.
|
|
114
|
+
|
|
115
|
+
## MCP compatibility
|
|
116
|
+
|
|
117
|
+
Run `pagesight mcp` to start the stdio MCP server. Configure your host to run
|
|
118
|
+
`bun /path/to/pagesight/packages/pagesight/src/index.ts mcp` with the environment above. Existing MCP
|
|
119
|
+
launch configurations must include the `mcp` argument; no arguments show CLI help.
|
|
120
|
+
|
|
121
|
+
The new `observe` tool accepts `{ "request": <operation object> }` and returns
|
|
122
|
+
structured API evidence. The original six tools remain available:
|
|
123
|
+
|
|
124
|
+
| Tool | Capability |
|
|
125
|
+
| -------- | ------------------------------------------------------------------------- |
|
|
126
|
+
| `audit` | PageSpeed, metadata, robots, sitemap processing errors and URL inspection |
|
|
127
|
+
| `page` | Metadata, links, JSON-LD checks, redirects and contrast |
|
|
128
|
+
| `speed` | PageSpeed and CrUX snapshots/history |
|
|
129
|
+
| `search` | GSC reports, sitemap metadata and selected URL inspection |
|
|
130
|
+
| `ai` | Robots and crawler-registry checks, llms.txt detection |
|
|
131
|
+
| `setup` | Existing GSC authentication helpers |
|
|
132
|
+
|
|
133
|
+
The legacy text tools do not invent indexed counts from deprecated sitemap fields,
|
|
134
|
+
extrapolate sample verdicts to the whole site, or treat missing comparison rows as zero.
|
|
135
|
+
Use `observe` for exact request/response evidence and pagination status.
|
package/package.json
CHANGED
|
@@ -1,54 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pagesight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "See your site the way search engines and AI see it.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
6
|
+
"ai-crawlers",
|
|
7
|
+
"core-web-vitals",
|
|
7
8
|
"geo",
|
|
8
9
|
"google-search-console",
|
|
10
|
+
"mcp",
|
|
9
11
|
"pagespeed-insights",
|
|
10
|
-
"core-web-vitals",
|
|
11
|
-
"web-performance",
|
|
12
12
|
"robots-txt",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
13
|
+
"seo",
|
|
14
|
+
"web-performance"
|
|
15
15
|
],
|
|
16
|
+
"homepage": "https://github.com/caiopizzol/pagesight",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Caio Pizzol",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/caiopizzol/pagesight.git",
|
|
22
|
+
"directory": "packages/pagesight"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"pagesight": "src/index.ts"
|
|
26
|
+
},
|
|
16
27
|
"files": [
|
|
17
28
|
"src",
|
|
18
29
|
"README.md",
|
|
19
|
-
"LICENSE"
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"docs"
|
|
20
32
|
],
|
|
21
33
|
"type": "module",
|
|
22
|
-
"main": "src/index.ts",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
34
|
+
"main": "src/api/index.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/api/index.ts"
|
|
25
37
|
},
|
|
26
|
-
"repository": {
|
|
27
|
-
"type": "git",
|
|
28
|
-
"url": "git+https://github.com/caiopizzol/pagesight.git"
|
|
29
|
-
},
|
|
30
|
-
"homepage": "https://github.com/caiopizzol/pagesight",
|
|
31
|
-
"author": "Caio Pizzol",
|
|
32
38
|
"scripts": {
|
|
33
39
|
"start": "bun run src/index.ts",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"format": "biome format --write src/"
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
41
|
+
"test": "bun test __tests__"
|
|
37
42
|
},
|
|
38
|
-
"license": "MIT",
|
|
39
43
|
"dependencies": {
|
|
40
44
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
45
|
+
"saxes": "^6.0.0",
|
|
41
46
|
"zod": "^3.24.4"
|
|
42
47
|
},
|
|
43
48
|
"devDependencies": {
|
|
44
|
-
"@
|
|
45
|
-
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
46
|
-
"@semantic-release/git": "^10.0.1",
|
|
47
|
-
"@semantic-release/github": "^12.0.6",
|
|
48
|
-
"@semantic-release/npm": "^13.1.5",
|
|
49
|
-
"@types/bun": "^1.3.11",
|
|
50
|
-
"lefthook": "^2.1.4",
|
|
51
|
-
"semantic-release": "^25.0.3",
|
|
52
|
-
"semantic-release-ai-notes": "^0.2.3"
|
|
49
|
+
"@types/bun": "^1.3.11"
|
|
53
50
|
}
|
|
54
51
|
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { capture, type Evidence } from "./evidence.js";
|
|
3
|
+
import type { ImportedSnapshot } from "./evidence-schema.js";
|
|
4
|
+
import { configSchema, gaRequestSchema, gscRequestSchema } from "./schema.js";
|
|
5
|
+
import { snapshotOperations, observationName } from "./snapshot.js";
|
|
6
|
+
import { canonical, normalizeReport, parseNumericValue } from "./report-table.js";
|
|
7
|
+
import { normalizeGaProperty } from "../providers/ga.js";
|
|
8
|
+
|
|
9
|
+
interface Finding {
|
|
10
|
+
code: string;
|
|
11
|
+
level: "attention" | "info" | "unknown";
|
|
12
|
+
message: string;
|
|
13
|
+
sources: string[];
|
|
14
|
+
nextCheck: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AssessmentTable {
|
|
17
|
+
observation: string;
|
|
18
|
+
scope: "search-property" | "production" | "production-organic" | "all-hostnames";
|
|
19
|
+
dimensions: string[];
|
|
20
|
+
metrics: string[];
|
|
21
|
+
rows: Array<{ keys: string[]; values: Array<string | number> }>;
|
|
22
|
+
observedRows: number;
|
|
23
|
+
displayedRows: number;
|
|
24
|
+
complete: boolean;
|
|
25
|
+
limitations: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function reportScope(provider: string, request: unknown) {
|
|
29
|
+
if (provider === "ga") {
|
|
30
|
+
const { offset: _offset, limit: _limit, returnPropertyQuota: _quota, ...scope } = gaRequestSchema.parse(request);
|
|
31
|
+
return scope;
|
|
32
|
+
}
|
|
33
|
+
const { startRow: _offset, rowLimit: _limit, ...scope } = gscRequestSchema.parse(request);
|
|
34
|
+
return scope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function assessSnapshot(snapshot: ImportedSnapshot, maxRows: number): Promise<Evidence> {
|
|
38
|
+
const { context, observations } = snapshot.pages[0].response;
|
|
39
|
+
const config = configSchema.parse(context.config);
|
|
40
|
+
const { startDate, endDate } = context.requestedDates;
|
|
41
|
+
const hash = Bun.CryptoHasher.hash("sha256", canonical(snapshot), "hex");
|
|
42
|
+
const result = await capture("pagesight", "assess", snapshot.target, { snapshotSha256: hash, maxRows }, async () => {
|
|
43
|
+
const findings: Finding[] = [];
|
|
44
|
+
const tables: AssessmentTable[] = [];
|
|
45
|
+
let configuredKeyEvents: Array<{ eventName: string; countingMethod?: string }> = [];
|
|
46
|
+
const keyEventSource = observations.find((o) => o.name === "ga.key-events");
|
|
47
|
+
const add = (code: string, level: Finding["level"], message: string, sources: string[], nextCheck: string) =>
|
|
48
|
+
findings.push({ code, level, message, sources, nextCheck });
|
|
49
|
+
if (config.gaProperty) {
|
|
50
|
+
if (!config.context.successEvents.length)
|
|
51
|
+
add(
|
|
52
|
+
"no-success-events",
|
|
53
|
+
"attention",
|
|
54
|
+
"No success events are designated in the site configuration. Reported key events are not validated product outcomes.",
|
|
55
|
+
["context.config.context.successEvents"],
|
|
56
|
+
"Define the useful visitor action, then verify its tracking before evaluating SEO outcomes.",
|
|
57
|
+
);
|
|
58
|
+
else
|
|
59
|
+
add(
|
|
60
|
+
"success-events-unverified",
|
|
61
|
+
"info",
|
|
62
|
+
`Site configuration designates ${config.context.successEvents.length} success event(s); showing ${Math.min(config.context.successEvents.length, maxRows)}: ${config.context.successEvents.slice(0, maxRows).join(", ")} as success events; Pagesight has not independently validated them.`,
|
|
63
|
+
["context.config.context.successEvents"],
|
|
64
|
+
"Verify each event against a real user action and provider reports.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (config.gaProperty) {
|
|
68
|
+
if (!keyEventSource || keyEventSource.status === "error" || !keyEventSource.pages.length)
|
|
69
|
+
add(
|
|
70
|
+
"key-event-metadata-unavailable",
|
|
71
|
+
"unknown",
|
|
72
|
+
"Configured GA key-event metadata is unavailable.",
|
|
73
|
+
["ga.key-events"],
|
|
74
|
+
"Read GA key-event metadata separately from observed event counts.",
|
|
75
|
+
);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
if (
|
|
79
|
+
keyEventSource.provider !== "ga" ||
|
|
80
|
+
keyEventSource.operation !== "key-events" ||
|
|
81
|
+
keyEventSource.target !== normalizeGaProperty(config.gaProperty)
|
|
82
|
+
)
|
|
83
|
+
throw new Error();
|
|
84
|
+
const metadata = z.object({
|
|
85
|
+
keyEvents: z
|
|
86
|
+
.array(z.object({ eventName: z.string().min(1), countingMethod: z.string().optional() }))
|
|
87
|
+
.default([]),
|
|
88
|
+
nextPageToken: z.string().optional(),
|
|
89
|
+
});
|
|
90
|
+
const pages = keyEventSource.pages.map((p) => metadata.parse(p.response));
|
|
91
|
+
configuredKeyEvents = pages.flatMap((p) => p.keyEvents);
|
|
92
|
+
if (pages.some((p) => p.nextPageToken) || keyEventSource.status !== "ok" || keyEventSource.error)
|
|
93
|
+
add(
|
|
94
|
+
"key-event-metadata-partial",
|
|
95
|
+
"unknown",
|
|
96
|
+
"Only part of the configured key-event list is available.",
|
|
97
|
+
["ga.key-events"],
|
|
98
|
+
"Retrieve the remaining metadata before claiming this is the complete configuration.",
|
|
99
|
+
);
|
|
100
|
+
add(
|
|
101
|
+
"configured-key-events",
|
|
102
|
+
"info",
|
|
103
|
+
`GA configuration contains ${configuredKeyEvents.length} observed key-event entries; showing ${Math.min(configuredKeyEvents.length, maxRows)}: ${
|
|
104
|
+
configuredKeyEvents
|
|
105
|
+
.slice(0, maxRows)
|
|
106
|
+
.map((e) => `${e.eventName}${e.countingMethod ? ` (${e.countingMethod})` : ""}`)
|
|
107
|
+
.join(", ") || "no observed key-event entries"
|
|
108
|
+
}. Configuration does not prove events occurred or outcomes are valid.`,
|
|
109
|
+
["ga.key-events"],
|
|
110
|
+
"Compare configured events with observed report rows and separately validate their business meaning.",
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
add(
|
|
114
|
+
"key-event-metadata-unusable",
|
|
115
|
+
"unknown",
|
|
116
|
+
"Key-event metadata does not match the configured property or supported shape.",
|
|
117
|
+
["ga.key-events"],
|
|
118
|
+
"Recollect key-event metadata for the configured property.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const operation of snapshotOperations(config, startDate, endDate, 1)) {
|
|
124
|
+
if (operation.operation !== "ga.report" && operation.operation !== "gsc.report") continue;
|
|
125
|
+
if (
|
|
126
|
+
operation.operation === "gsc.report" &&
|
|
127
|
+
operation.request.dimensions?.some((d) => ["date", "hour"].includes(d))
|
|
128
|
+
)
|
|
129
|
+
continue;
|
|
130
|
+
const name = observationName(operation);
|
|
131
|
+
const observation = observations.find((o) => o.name === name);
|
|
132
|
+
if (!observation || observation.status === "error" || !observation.pages.length) {
|
|
133
|
+
add(
|
|
134
|
+
"report-unavailable",
|
|
135
|
+
"unknown",
|
|
136
|
+
`${name}: no usable report is available.`,
|
|
137
|
+
[name],
|
|
138
|
+
"Collect or retry this report; absence is not zero activity.",
|
|
139
|
+
);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const provider = operation.operation === "ga.report" ? "ga" : "gsc";
|
|
144
|
+
const target = operation.operation === "ga.report" ? normalizeGaProperty(operation.property) : operation.site;
|
|
145
|
+
if (observation.provider !== provider || observation.operation !== "report" || observation.target !== target)
|
|
146
|
+
throw new Error("Provider or property does not match the site configuration.");
|
|
147
|
+
for (const page of observation.pages)
|
|
148
|
+
if (canonical(reportScope(provider, page.request)) !== canonical(reportScope(provider, operation.request)))
|
|
149
|
+
throw new Error("Report filters, dates or dimensions do not match the configured snapshot report.");
|
|
150
|
+
const normalized = normalizeReport(observation, context);
|
|
151
|
+
if (
|
|
152
|
+
provider === "gsc" &&
|
|
153
|
+
normalized.dimensions.length === 0 &&
|
|
154
|
+
observation.pages.some(
|
|
155
|
+
(p) => (p.response as { responseAggregationType?: string }).responseAggregationType !== "byProperty",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
throw new Error("GSC property totals require byProperty response aggregation.");
|
|
159
|
+
const allRows = [...normalized.rows.values()];
|
|
160
|
+
const limitations = [...new Set([...observation.warnings, ...normalized.warnings])];
|
|
161
|
+
const complete =
|
|
162
|
+
observation.status === "ok" && !observation.error && observation.pagination?.exhausted === true;
|
|
163
|
+
const sorted = [...allRows].sort((a, b) => {
|
|
164
|
+
const av = parseNumericValue(a.values[0]);
|
|
165
|
+
const bv = parseNumericValue(b.values[0]);
|
|
166
|
+
return (bv ?? -Infinity) - (av ?? -Infinity) || canonical(a.keys).localeCompare(canonical(b.keys));
|
|
167
|
+
});
|
|
168
|
+
const scope =
|
|
169
|
+
provider === "gsc"
|
|
170
|
+
? "search-property"
|
|
171
|
+
: normalized.dimensions[0] === "hostName"
|
|
172
|
+
? "all-hostnames"
|
|
173
|
+
: name.endsWith(".organic")
|
|
174
|
+
? "production-organic"
|
|
175
|
+
: "production";
|
|
176
|
+
tables.push({
|
|
177
|
+
observation: name,
|
|
178
|
+
scope,
|
|
179
|
+
dimensions: normalized.dimensions,
|
|
180
|
+
metrics: normalized.metrics,
|
|
181
|
+
rows: sorted.slice(0, maxRows),
|
|
182
|
+
observedRows: allRows.length,
|
|
183
|
+
displayedRows: Math.min(allRows.length, maxRows),
|
|
184
|
+
complete,
|
|
185
|
+
limitations,
|
|
186
|
+
});
|
|
187
|
+
if (name === "ga.report.landingPagePlusQueryString+sessionSource+eventName.organic")
|
|
188
|
+
add(
|
|
189
|
+
"organic-landing-events",
|
|
190
|
+
"info",
|
|
191
|
+
"These event occurrences are associated with organic sessions' landing pages, not necessarily the pages where the events occurred. Counts are not unique sessions, conversion rates or validated outcomes.",
|
|
192
|
+
[name, "ga.report.landingPagePlusQueryString+sessionSource.organic"],
|
|
193
|
+
"Inspect relevant event names alongside landing-page traffic. Preserve raw query strings and provider coverage; verify event meaning and instrumentation changes before prioritizing SEO work.",
|
|
194
|
+
);
|
|
195
|
+
if (normalized.dimensions[0] === "hostName") {
|
|
196
|
+
const other = allRows.filter(
|
|
197
|
+
(row) =>
|
|
198
|
+
row.keys[0] !== config.productionHostname && row.values.some((v) => (parseNumericValue(v) ?? 0) > 0),
|
|
199
|
+
);
|
|
200
|
+
if (other.length)
|
|
201
|
+
add(
|
|
202
|
+
"other-hostnames",
|
|
203
|
+
"info",
|
|
204
|
+
`The hostname census contains activity on ${other.length} other observed hostname(s): ${other
|
|
205
|
+
.slice(0, maxRows)
|
|
206
|
+
.map((r) => r.keys[0])
|
|
207
|
+
.join(
|
|
208
|
+
", ",
|
|
209
|
+
)}. This does not invalidate correctly production-filtered reports or identify internal visitors on production.`,
|
|
210
|
+
[name],
|
|
211
|
+
"Keep production reports filtered to the intended hostname; separately evaluate internal-traffic handling.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (name === "ga.report.eventName") {
|
|
215
|
+
const keyIndex = normalized.metrics.indexOf("keyEvents");
|
|
216
|
+
const eventRows = allRows.filter((r) => (parseNumericValue(r.values[keyIndex]) ?? 0) > 0);
|
|
217
|
+
if (eventRows.length > maxRows)
|
|
218
|
+
add(
|
|
219
|
+
"event-findings-capped",
|
|
220
|
+
"info",
|
|
221
|
+
`Showing ${maxRows} of ${eventRows.length} observed event rows with positive key-event counts.`,
|
|
222
|
+
[name],
|
|
223
|
+
"Increase maxRows or inspect the original report for other event names.",
|
|
224
|
+
);
|
|
225
|
+
for (const row of eventRows.slice(0, maxRows)) {
|
|
226
|
+
const event = row.keys[0];
|
|
227
|
+
const excluded = config.context.excludedKeyEvents.includes(event);
|
|
228
|
+
add(
|
|
229
|
+
excluded ? "excluded-key-event" : "reported-key-event",
|
|
230
|
+
excluded ? "attention" : "info",
|
|
231
|
+
`${event}: ${row.values[keyIndex]} reported key events${excluded ? "; excluded from success metrics by site configuration" : "; business meaning is not validated by its name or count"}.`,
|
|
232
|
+
[name],
|
|
233
|
+
"Inspect the event definition and verify the real user action; do not infer its generation rule from the name.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
for (const configured of configuredKeyEvents.slice(0, maxRows))
|
|
237
|
+
if (!allRows.some((r) => r.keys[0] === configured.eventName))
|
|
238
|
+
add(
|
|
239
|
+
"configured-event-not-observed",
|
|
240
|
+
"info",
|
|
241
|
+
`GA-configured key event ${configured.eventName} has no observed event row in this period and scope. This does not establish zero activity or broken tracking.`,
|
|
242
|
+
["ga.key-events", name],
|
|
243
|
+
"Check date, hostname and report coverage before investigating the event's instrumentation.",
|
|
244
|
+
);
|
|
245
|
+
for (const event of config.context.successEvents.slice(0, maxRows))
|
|
246
|
+
if (!allRows.some((r) => r.keys[0] === event))
|
|
247
|
+
add(
|
|
248
|
+
"success-event-not-observed",
|
|
249
|
+
"unknown",
|
|
250
|
+
`Configured success event ${event} has no observed row in this report; that is not proof of zero events or broken tracking.`,
|
|
251
|
+
[name, "context.config.context.successEvents"],
|
|
252
|
+
"Check report coverage, dates, filters and a real user flow.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
add(
|
|
257
|
+
"report-unusable",
|
|
258
|
+
"unknown",
|
|
259
|
+
`${name}: ${error instanceof Error && error.name !== "ZodError" ? error.message : "Request or response does not match the supported report shape."}`,
|
|
260
|
+
[name],
|
|
261
|
+
"Inspect the original report and recollect a compatible snapshot before drawing conclusions.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const observation of observations.filter((o) => o.status !== "ok" || o.error))
|
|
266
|
+
add(
|
|
267
|
+
"provider-incomplete",
|
|
268
|
+
"unknown",
|
|
269
|
+
`${observation.name}: ${observation.status}${observation.error ? ` (${observation.error.code})` : ""}.`,
|
|
270
|
+
[observation.name],
|
|
271
|
+
"Inspect the retained provider error or pagination before relying on missing data.",
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
assessmentVersion: 1,
|
|
275
|
+
site: config.site,
|
|
276
|
+
requestedDates: context.requestedDates,
|
|
277
|
+
collectedAt: snapshot.finishedAt,
|
|
278
|
+
basis: "saved-snapshot-not-reverified",
|
|
279
|
+
snapshotSha256: hash,
|
|
280
|
+
providerSelection: {
|
|
281
|
+
gsc: Boolean(config.gscSite),
|
|
282
|
+
ga: Boolean(config.gaProperty),
|
|
283
|
+
bing: Boolean(config.bingSite),
|
|
284
|
+
},
|
|
285
|
+
findings,
|
|
286
|
+
tables,
|
|
287
|
+
displayLimit: maxRows,
|
|
288
|
+
observations: observations.map((o) => ({
|
|
289
|
+
name: o.name,
|
|
290
|
+
provider: o.provider,
|
|
291
|
+
status: o.status,
|
|
292
|
+
warnings: o.warnings,
|
|
293
|
+
errorCode: o.error?.code ?? null,
|
|
294
|
+
})),
|
|
295
|
+
limitations: [
|
|
296
|
+
...new Set([
|
|
297
|
+
...snapshot.warnings,
|
|
298
|
+
"This assessment describes supplied snapshot evidence; it makes no new provider requests and does not authenticate imported claims. The hash identifies normalized snapshot JSON, not file bytes.",
|
|
299
|
+
"Configured success events are caller-designated, not independently validated. Aggregate reports cannot diagnose duplicate tags, prove a particular browser test arrived, or establish SEO causation.",
|
|
300
|
+
"Tables retain raw values for observed rows; capped lists are not exhaustive rankings. GSC property, page and query counts and GA sessions have different semantics and must not be reconciled as a funnel.",
|
|
301
|
+
]),
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const assessment = result.pages[0]?.response as { findings: Finding[]; tables: AssessmentTable[] } | undefined;
|
|
306
|
+
if (
|
|
307
|
+
result.status !== "error" &&
|
|
308
|
+
(snapshot.status !== "ok" ||
|
|
309
|
+
snapshot.error ||
|
|
310
|
+
assessment?.findings.some((f) => f.level === "unknown") ||
|
|
311
|
+
assessment?.tables.some((t) => !t.complete))
|
|
312
|
+
)
|
|
313
|
+
result.status = "partial";
|
|
314
|
+
return result;
|
|
315
|
+
}
|
package/src/api/bing.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type BingAction, bingFetch, bingMethods } from "../providers/bing.js";
|
|
2
|
+
import { capture, createEvidence, fail } from "./evidence.js";
|
|
3
|
+
|
|
4
|
+
export function bingObservation(action: BingAction, site?: string) {
|
|
5
|
+
return capture(
|
|
6
|
+
"bing",
|
|
7
|
+
action,
|
|
8
|
+
site ?? "accessible-sites",
|
|
9
|
+
{ method: bingMethods[action], ...(site === undefined ? {} : { siteUrl: site }) },
|
|
10
|
+
() => bingFetch(action, site),
|
|
11
|
+
[
|
|
12
|
+
"Bing returns one provider-defined response. These methods support no date range or pagination; completeness is unknown.",
|
|
13
|
+
...(action === "sites"
|
|
14
|
+
? ["Site verification is not evidence of indexing or traffic."]
|
|
15
|
+
: [
|
|
16
|
+
"Raw Bing Date strings are retained. Reporting timezone is unknown; snapshot requested dates do not constrain this response.",
|
|
17
|
+
action === "traffic"
|
|
18
|
+
? "Updated daily. Since 2023-03-24 traffic includes Web, Chat, News, Images, Videos and Knowledge Panel; it is not Google Web scope or isolated AI citation evidence."
|
|
19
|
+
: "Top-row statistics updated weekly; missing queries/pages are unknown, not zero. Do not assume exhaustive coverage or a Web-only scope.",
|
|
20
|
+
...(action === "pages" ? ["In GetPageStats, the Query field contains the page URL."] : []),
|
|
21
|
+
]),
|
|
22
|
+
],
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function bingDiagnostics(
|
|
27
|
+
action: "crawl-stats" | "crawl-issues" | "url-info" | "link-counts" | "url-links",
|
|
28
|
+
site: string,
|
|
29
|
+
url?: string,
|
|
30
|
+
maxPages = 1,
|
|
31
|
+
) {
|
|
32
|
+
const result = createEvidence("bing", action, site);
|
|
33
|
+
result.warnings.push(
|
|
34
|
+
"Provider dates, count periods and units are retained as reported. Empty reports are not proof of no issues or links.",
|
|
35
|
+
"Crawl issues are not the Bing UI recommendations list. Link counts do not measure domain quality. InIndex and sitemap counts have different scopes.",
|
|
36
|
+
"URL-info HttpStatus is provider metadata; zero is not a successful HTTP response. Indexed/crawled state is not a live fetch.",
|
|
37
|
+
);
|
|
38
|
+
const paginated = action === "link-counts" || action === "url-links";
|
|
39
|
+
let page = 0;
|
|
40
|
+
let rowsReturned = 0;
|
|
41
|
+
let exhausted = false;
|
|
42
|
+
let totalPages: number | undefined;
|
|
43
|
+
for (; page < (paginated ? maxPages : 1); page++) {
|
|
44
|
+
const parameters: Record<string, string> = {
|
|
45
|
+
...(url ? { [action === "url-links" ? "link" : "url"]: url } : {}),
|
|
46
|
+
...(paginated ? { page: String(page) } : {}),
|
|
47
|
+
};
|
|
48
|
+
const request = { method: bingMethods[action], siteUrl: site, ...parameters };
|
|
49
|
+
try {
|
|
50
|
+
const response = await bingFetch(action, site, parameters);
|
|
51
|
+
result.pages.push({ request, response });
|
|
52
|
+
if (!paginated) break;
|
|
53
|
+
const d = response.d as { TotalPages: number; Links?: unknown[]; Details?: unknown[] };
|
|
54
|
+
rowsReturned += (d.Links ?? d.Details ?? []).length;
|
|
55
|
+
if (totalPages !== undefined && totalPages !== d.TotalPages) {
|
|
56
|
+
result.status = "partial";
|
|
57
|
+
result.warnings.push(
|
|
58
|
+
"Provider TotalPages changed during pagination; coverage is unstable. Restart to obtain a fresh report.",
|
|
59
|
+
);
|
|
60
|
+
page++;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
totalPages = d.TotalPages;
|
|
64
|
+
if (page + 1 >= d.TotalPages) {
|
|
65
|
+
exhausted = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
result.failedRequest = request;
|
|
70
|
+
fail(result, error);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (paginated) {
|
|
75
|
+
result.pagination = { exhausted, nextOffset: exhausted ? null : page, rowsReturned };
|
|
76
|
+
result.warnings.push(
|
|
77
|
+
"nextOffset is a zero-based provider page, not a row offset. Exhaustion describes the returned report, not exhaustive backlink coverage.",
|
|
78
|
+
);
|
|
79
|
+
if (!exhausted && result.status === "ok") result.status = "partial";
|
|
80
|
+
}
|
|
81
|
+
result.finishedAt = new Date().toISOString();
|
|
82
|
+
return result;
|
|
83
|
+
}
|