pagesight 0.19.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 +15 -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/monitoring.md +39 -0
- package/docs/rendering.md +102 -0
- package/docs/seo-agent-workflow.md +153 -0
- package/docs/usage.md +22 -19
- package/package.json +5 -2
- package/src/api/change-record.ts +31 -0
- package/src/api/cloudflare.ts +201 -0
- package/src/api/compare-snapshots.ts +1 -1
- package/src/api/crawl.ts +53 -0
- package/src/api/evaluate-change.ts +189 -0
- package/src/api/execute.ts +29 -0
- package/src/api/followup-changes.ts +264 -0
- package/src/api/http-url.ts +13 -0
- package/src/api/investigation.ts +344 -0
- package/src/api/schema.ts +103 -16
- package/src/api/technical-changes.ts +124 -0
- package/src/api/verify-render.ts +134 -0
- package/src/cli.ts +143 -43
- package/src/followup-manifest.ts +42 -0
- package/src/followup-text.ts +45 -0
- package/src/investigation-text.ts +32 -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,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,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
|
});
|
|
@@ -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
|
+
}
|