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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { chromium, type Browser, type Page } from "playwright";
|
|
2
|
+
import { RequestError } from "../shared/http.js";
|
|
3
|
+
import { extractSeo, type SeoDom } from "./render-dom.js";
|
|
4
|
+
|
|
5
|
+
export interface RenderOptions {
|
|
6
|
+
url: string;
|
|
7
|
+
navigation?: { fromUrl: string; linkSelector: string };
|
|
8
|
+
settleMs: number;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
viewport: { width: number; height: number };
|
|
11
|
+
}
|
|
12
|
+
export async function launchRenderer(proxy: string) {
|
|
13
|
+
try {
|
|
14
|
+
return await chromium.launch({
|
|
15
|
+
timeout: 15000,
|
|
16
|
+
proxy: { server: proxy, bypass: "<-loopback>" },
|
|
17
|
+
args: ["--disable-quic", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"],
|
|
18
|
+
});
|
|
19
|
+
} catch {
|
|
20
|
+
throw new RequestError(
|
|
21
|
+
"Chromium unavailable. Run bunx playwright install chromium in the Pagesight installation and check browser OS dependencies.",
|
|
22
|
+
null,
|
|
23
|
+
"browser_unavailable",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function parseServer(browser: Browser, html: string, url: string) {
|
|
28
|
+
const context = await browser.newContext({ javaScriptEnabled: false, offline: true, serviceWorkers: "block" });
|
|
29
|
+
try {
|
|
30
|
+
const page = await context.newPage();
|
|
31
|
+
return await page.evaluate(extractSeo, { html, url });
|
|
32
|
+
} finally {
|
|
33
|
+
await context.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function isolated<T, I>(page: Page, fn: (input: I) => T, input: I): Promise<T> {
|
|
37
|
+
const session = await page.context().newCDPSession(page);
|
|
38
|
+
try {
|
|
39
|
+
const { frameTree } = await session.send("Page.getFrameTree");
|
|
40
|
+
const { executionContextId } = await session.send("Page.createIsolatedWorld", {
|
|
41
|
+
frameId: frameTree.frame.id,
|
|
42
|
+
worldName: "pagesight-evidence",
|
|
43
|
+
});
|
|
44
|
+
const result = await session.send("Runtime.callFunctionOn", {
|
|
45
|
+
functionDeclaration: fn.toString(),
|
|
46
|
+
executionContextId,
|
|
47
|
+
arguments: [{ value: input }],
|
|
48
|
+
returnByValue: true,
|
|
49
|
+
});
|
|
50
|
+
if (result.exceptionDetails) throw new Error("Isolated extraction failed");
|
|
51
|
+
return result.result.value as T;
|
|
52
|
+
} finally {
|
|
53
|
+
await session.detach();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function documentResponse(page: Page) {
|
|
57
|
+
const documents: Array<{ url: string; status: number; location: string | null; xRobotsTag: string | null }> = [];
|
|
58
|
+
const history = { documents, documentsTruncated: false };
|
|
59
|
+
page.on("response", (response) => {
|
|
60
|
+
if (response.request().isNavigationRequest() && response.frame() === page.mainFrame()) {
|
|
61
|
+
if (documents.length === 20) {
|
|
62
|
+
history.documentsTruncated = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const headers = response.headers();
|
|
66
|
+
documents.push({
|
|
67
|
+
url: response.url(),
|
|
68
|
+
status: response.status(),
|
|
69
|
+
location: headers.location ?? null,
|
|
70
|
+
xRobotsTag: headers["x-robots-tag"] ?? null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return history;
|
|
75
|
+
}
|
|
76
|
+
export async function captureRendered(browser: Browser, options: RenderOptions, navigate: boolean) {
|
|
77
|
+
const origin = new URL(options.url).origin;
|
|
78
|
+
const context = await browser.newContext({
|
|
79
|
+
viewport: options.viewport,
|
|
80
|
+
serviceWorkers: "block",
|
|
81
|
+
acceptDownloads: false,
|
|
82
|
+
});
|
|
83
|
+
let timedOut = false;
|
|
84
|
+
const deadline = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
void context.close().catch(() => {});
|
|
87
|
+
}, options.timeoutMs);
|
|
88
|
+
let blockedRequests = 0;
|
|
89
|
+
let requests = 0;
|
|
90
|
+
try {
|
|
91
|
+
await context.routeWebSocket("**/*", async (socket) => {
|
|
92
|
+
blockedRequests++;
|
|
93
|
+
await socket.close();
|
|
94
|
+
});
|
|
95
|
+
await context.route("**/*", async (route) => {
|
|
96
|
+
const request = route.request();
|
|
97
|
+
const url = new URL(request.url());
|
|
98
|
+
if (
|
|
99
|
+
++requests > 500 ||
|
|
100
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
101
|
+
url.username ||
|
|
102
|
+
url.password ||
|
|
103
|
+
!["GET", "HEAD"].includes(request.method()) ||
|
|
104
|
+
(request.isNavigationRequest() && request.frame().parentFrame() === null && url.origin !== origin)
|
|
105
|
+
) {
|
|
106
|
+
blockedRequests++;
|
|
107
|
+
await route.abort();
|
|
108
|
+
} else await route.continue();
|
|
109
|
+
});
|
|
110
|
+
const page = await context.newPage();
|
|
111
|
+
page.setDefaultTimeout(options.timeoutMs);
|
|
112
|
+
const history = documentResponse(page);
|
|
113
|
+
const consoleErrors: string[] = [];
|
|
114
|
+
page.on("pageerror", (error) => {
|
|
115
|
+
if (consoleErrors.length < 20) consoleErrors.push(error.name);
|
|
116
|
+
});
|
|
117
|
+
const initialUrl = navigate ? options.navigation!.fromUrl : options.url;
|
|
118
|
+
const initial = await page.goto(initialUrl, { waitUntil: "domcontentloaded" });
|
|
119
|
+
if (!initial || !initial.headers()["content-type"]?.includes("text/html"))
|
|
120
|
+
throw new RequestError("Browser document is not HTML.", initial?.status() ?? null, "not_html");
|
|
121
|
+
await page.waitForTimeout(options.settleMs);
|
|
122
|
+
let clickedLink: { selector: string; href: string } | null = null;
|
|
123
|
+
let source: SeoDom | null = null;
|
|
124
|
+
if (navigate) {
|
|
125
|
+
source = await isolated(page, extractSeo, { url: page.url() });
|
|
126
|
+
const link = page.locator(`css=${options.navigation!.linkSelector}`);
|
|
127
|
+
if ((await link.count()) !== 1)
|
|
128
|
+
throw new RequestError("Navigation selector must identify exactly one anchor.", null, "ambiguous_link");
|
|
129
|
+
const selected = await isolated(
|
|
130
|
+
page,
|
|
131
|
+
(selector: string) => {
|
|
132
|
+
const element = document.querySelector(selector)!;
|
|
133
|
+
return {
|
|
134
|
+
tag: element.tagName,
|
|
135
|
+
href: (element as HTMLAnchorElement).href,
|
|
136
|
+
target: element.getAttribute("target"),
|
|
137
|
+
download: element.hasAttribute("download"),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
options.navigation!.linkSelector,
|
|
141
|
+
);
|
|
142
|
+
if (
|
|
143
|
+
selected.tag !== "A" ||
|
|
144
|
+
selected.href !== options.url ||
|
|
145
|
+
selected.download ||
|
|
146
|
+
(selected.target && selected.target !== "_self")
|
|
147
|
+
)
|
|
148
|
+
throw new RequestError(
|
|
149
|
+
"Select a same-tab anchor whose resolved href exactly matches the requested URL.",
|
|
150
|
+
null,
|
|
151
|
+
"invalid_link",
|
|
152
|
+
);
|
|
153
|
+
clickedLink = { selector: options.navigation!.linkSelector, href: selected.href };
|
|
154
|
+
await link.click({ noWaitAfter: true });
|
|
155
|
+
await page.waitForURL((url) => url.href === options.url, { waitUntil: "domcontentloaded" });
|
|
156
|
+
await page.waitForTimeout(options.settleMs);
|
|
157
|
+
}
|
|
158
|
+
const dom = await isolated(page, extractSeo, { url: page.url() });
|
|
159
|
+
return {
|
|
160
|
+
path: navigate ? "internal_navigation" : "direct",
|
|
161
|
+
initialUrl,
|
|
162
|
+
requestedUrl: options.url,
|
|
163
|
+
finalUrl: page.url(),
|
|
164
|
+
capturedAt: new Date().toISOString(),
|
|
165
|
+
dom,
|
|
166
|
+
domSha256: Bun.CryptoHasher.hash("sha256", JSON.stringify(dom), "hex"),
|
|
167
|
+
source,
|
|
168
|
+
clickedLink,
|
|
169
|
+
...history,
|
|
170
|
+
blockedRequests,
|
|
171
|
+
requests,
|
|
172
|
+
pageErrorNames: consoleErrors,
|
|
173
|
+
targetDocumentResponse: history.documentsTruncated
|
|
174
|
+
? null
|
|
175
|
+
: ([...history.documents].reverse().find((d) => d.url === page.url().split("#")[0]) ?? null),
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof RequestError) throw error;
|
|
179
|
+
throw new RequestError(
|
|
180
|
+
timedOut
|
|
181
|
+
? "Browser capture exceeded its time budget."
|
|
182
|
+
: "Browser navigation or capture failed; no rendered evidence was inferred.",
|
|
183
|
+
null,
|
|
184
|
+
timedOut ? "render_timeout" : "render_failed",
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(deadline);
|
|
188
|
+
await context.close().catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Executed in Chromium for both detached server HTML and the rendered document.
|
|
2
|
+
export function extractSeo(input: { html?: string; url: string }) {
|
|
3
|
+
const doc = input.html === undefined ? document : new DOMParser().parseFromString(input.html, "text/html");
|
|
4
|
+
const maxItems = 200;
|
|
5
|
+
const maxText = 4000;
|
|
6
|
+
let truncated = false;
|
|
7
|
+
const clip = (value: string | null) => {
|
|
8
|
+
if (value !== null && value.length > maxText) truncated = true;
|
|
9
|
+
return value === null ? null : value.slice(0, maxText);
|
|
10
|
+
};
|
|
11
|
+
const select = (selector: string) => {
|
|
12
|
+
const matches: Element[] = [];
|
|
13
|
+
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT);
|
|
14
|
+
let node: Node | null;
|
|
15
|
+
while ((node = walker.nextNode())) {
|
|
16
|
+
if (!(node as Element).matches(selector)) continue;
|
|
17
|
+
if (matches.length === maxItems) {
|
|
18
|
+
truncated = true;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
matches.push(node as Element);
|
|
22
|
+
}
|
|
23
|
+
return matches;
|
|
24
|
+
};
|
|
25
|
+
let baseUrl = input.url;
|
|
26
|
+
const baseHref = doc.querySelector("base[href]")?.getAttribute("href");
|
|
27
|
+
try {
|
|
28
|
+
if (baseHref) baseUrl = new URL(baseHref, input.url).href;
|
|
29
|
+
} catch {
|
|
30
|
+
/* Invalid base remains visible below. */
|
|
31
|
+
}
|
|
32
|
+
const resolve = (href: string | null) => {
|
|
33
|
+
try {
|
|
34
|
+
return href === null ? null : new URL(href, baseUrl).href;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
url: input.url,
|
|
41
|
+
baseHref: clip(baseHref ?? null),
|
|
42
|
+
titles: select("title").map((e) => clip(e.textContent)),
|
|
43
|
+
descriptions: select('meta[name="description" i]').map((e) => clip(e.getAttribute("content"))),
|
|
44
|
+
canonicals: select('link[rel~="canonical" i]').map((e) => ({
|
|
45
|
+
raw: clip(e.getAttribute("href")),
|
|
46
|
+
resolved: clip(resolve(e.getAttribute("href"))),
|
|
47
|
+
})),
|
|
48
|
+
robots: select('meta[name="robots" i],meta[name="googlebot" i]').map((e) => ({
|
|
49
|
+
name: clip(e.getAttribute("name")),
|
|
50
|
+
content: clip(e.getAttribute("content")),
|
|
51
|
+
})),
|
|
52
|
+
h1: select("h1").map((e) => clip(e.textContent)),
|
|
53
|
+
jsonLd: select('script[type="application/ld+json" i]').map((e) => {
|
|
54
|
+
const raw = e.textContent ?? "";
|
|
55
|
+
let validJson = true;
|
|
56
|
+
try {
|
|
57
|
+
JSON.parse(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
validJson = false;
|
|
60
|
+
}
|
|
61
|
+
return { raw: clip(raw), validJson };
|
|
62
|
+
}),
|
|
63
|
+
links: select("a[href]").map((e) => ({
|
|
64
|
+
raw: clip(e.getAttribute("href")),
|
|
65
|
+
resolved: clip(resolve(e.getAttribute("href"))),
|
|
66
|
+
text: clip(e.textContent),
|
|
67
|
+
})),
|
|
68
|
+
truncated,
|
|
69
|
+
limits: { maxItemsPerField: maxItems, maxCharactersPerValue: maxText },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export type SeoDom = ReturnType<typeof extractSeo>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { createServer, request, type OutgoingHttpHeaders } from "node:http";
|
|
3
|
+
import { pipeline } from "node:stream";
|
|
4
|
+
import { connect, type Socket } from "node:net";
|
|
5
|
+
import ipaddr from "ipaddr.js";
|
|
6
|
+
|
|
7
|
+
const hostname = (url: URL) => url.hostname.replace(/^\[|\]$/g, "");
|
|
8
|
+
const port = (url: URL) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
9
|
+
export function publicAddress(address: string) {
|
|
10
|
+
if (address.includes("%") || !ipaddr.isValid(address)) return false;
|
|
11
|
+
return ipaddr.process(address).range() === "unicast";
|
|
12
|
+
}
|
|
13
|
+
export async function checkedRenderAddress(
|
|
14
|
+
url: URL,
|
|
15
|
+
target: URL,
|
|
16
|
+
resolve: (host: string) => Promise<Array<{ address: string }>> = (host) => lookup(host, { all: true }),
|
|
17
|
+
) {
|
|
18
|
+
const host = hostname(url);
|
|
19
|
+
const addresses = ipaddr.isValid(host) ? [{ address: host }] : await resolve(host);
|
|
20
|
+
const explicitLocal = ipaddr.isValid(hostname(target)) && host === hostname(target) && port(url) === port(target);
|
|
21
|
+
if (!addresses.length || (!explicitLocal && addresses.some(({ address }) => !publicAddress(address))))
|
|
22
|
+
throw new Error("Network destination blocked");
|
|
23
|
+
return addresses[0]!.address;
|
|
24
|
+
}
|
|
25
|
+
export async function startRenderNetwork(target: string) {
|
|
26
|
+
const permitted = new URL(target);
|
|
27
|
+
const sockets = new Set<Socket>();
|
|
28
|
+
let blockedRequests = 0;
|
|
29
|
+
let attempts = 0;
|
|
30
|
+
let closed = false;
|
|
31
|
+
const destination = async (url: URL) => {
|
|
32
|
+
try {
|
|
33
|
+
if (++attempts > 1500) throw new Error("Connection budget exceeded");
|
|
34
|
+
const address = await checkedRenderAddress(url, permitted);
|
|
35
|
+
if (closed) throw new Error("Network closed");
|
|
36
|
+
return address;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
blockedRequests++;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const track = (socket: Socket) => {
|
|
43
|
+
sockets.add(socket);
|
|
44
|
+
socket.setTimeout(60000, () => socket.destroy());
|
|
45
|
+
socket.on("close", () => sockets.delete(socket));
|
|
46
|
+
socket.on("error", () => socket.destroy());
|
|
47
|
+
return socket;
|
|
48
|
+
};
|
|
49
|
+
// CONNECT needs raw sockets; pin the validated IP at the socket, never resolve it again.
|
|
50
|
+
const server = createServer(async (incoming, outgoing) => {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(incoming.url!);
|
|
53
|
+
if (url.protocol !== "http:" || url.username || url.password || !["GET", "HEAD"].includes(incoming.method!))
|
|
54
|
+
throw new Error("Invalid proxy request");
|
|
55
|
+
const address = await destination(url);
|
|
56
|
+
const headers: OutgoingHttpHeaders = { ...incoming.headers, host: url.host };
|
|
57
|
+
delete headers["proxy-connection"];
|
|
58
|
+
delete headers["proxy-authorization"];
|
|
59
|
+
const upstream = request(
|
|
60
|
+
{
|
|
61
|
+
hostname: address,
|
|
62
|
+
port: port(url),
|
|
63
|
+
path: url.pathname + url.search,
|
|
64
|
+
method: incoming.method,
|
|
65
|
+
headers,
|
|
66
|
+
agent: false,
|
|
67
|
+
},
|
|
68
|
+
(response) => {
|
|
69
|
+
outgoing.writeHead(response.statusCode!, response.headers);
|
|
70
|
+
pipeline(response, outgoing, () => upstream.destroy());
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
upstream.on("socket", track);
|
|
74
|
+
upstream.on("error", () => {
|
|
75
|
+
outgoing.destroy();
|
|
76
|
+
});
|
|
77
|
+
outgoing.on("close", () => upstream.destroy());
|
|
78
|
+
pipeline(incoming, upstream, (error) => {
|
|
79
|
+
if (error) outgoing.destroy();
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
outgoing.writeHead(403).end();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
server.maxConnections = 256;
|
|
86
|
+
server.on("connection", track);
|
|
87
|
+
server.on("connect", async (incoming, client, head) => {
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(`https://${incoming.url}`);
|
|
90
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
91
|
+
throw new Error("Invalid tunnel");
|
|
92
|
+
const address = await destination(url);
|
|
93
|
+
const upstream = track(connect({ host: address, port: port(url) }));
|
|
94
|
+
upstream.once("connect", () => {
|
|
95
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
96
|
+
if (head.length) upstream.write(head);
|
|
97
|
+
client.pipe(upstream);
|
|
98
|
+
upstream.pipe(client);
|
|
99
|
+
});
|
|
100
|
+
client.on("close", () => upstream.destroy());
|
|
101
|
+
upstream.on("close", () => client.destroy());
|
|
102
|
+
} catch {
|
|
103
|
+
client.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await new Promise<void>((resolve, reject) => {
|
|
107
|
+
server.once("error", reject);
|
|
108
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
109
|
+
});
|
|
110
|
+
const address = server.address() as { port: number };
|
|
111
|
+
return {
|
|
112
|
+
proxy: `http://127.0.0.1:${address.port}`,
|
|
113
|
+
get blockedRequests() {
|
|
114
|
+
return blockedRequests;
|
|
115
|
+
},
|
|
116
|
+
async close() {
|
|
117
|
+
closed = true;
|
|
118
|
+
for (const socket of sockets) socket.destroy();
|
|
119
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|