196 lines
7.0 KiB
JavaScript
196 lines
7.0 KiB
JavaScript
// Guarded HTTPS fetch + HTML->text reduction, ported from
|
|
// /Users/shane/dev/hope_roaster/electron/src/agent/fetch-guard.ts (its `fetchUrlGuarded` +
|
|
// `extractTextFromHtml`), simplified for a standalone Node server using global fetch.
|
|
//
|
|
// Guarantees kept: https only (bare host assumed https), every redirect re-validated as
|
|
// https, bounded redirects, a hard byte cap enforced while streaming, a timeout, and a
|
|
// content-type allowlist. The loopback TLS test escape hatch from the source is dropped —
|
|
// this process has no test harness that needs it.
|
|
|
|
const MAX_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
const TIMEOUT_MS = 10_000;
|
|
const MAX_REDIRECTS = 5;
|
|
const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"];
|
|
const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)";
|
|
const MAX_EXTRACTED_CHARS = 40_000;
|
|
const BROWSERLESS_URL = process.env.BROWSERLESS_URL?.replace(/\/$/, "");
|
|
|
|
function normalizeToUrl(input) {
|
|
const trimmed = input.trim();
|
|
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
|
const candidate = hasScheme ? trimmed : `https://${trimmed}`;
|
|
try {
|
|
return new URL(candidate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function contentTypeOf(header) {
|
|
if (!header) return undefined;
|
|
return header.split(";")[0].trim().toLowerCase();
|
|
}
|
|
|
|
async function readBodyCapped(response) {
|
|
const reader = response.body?.getReader?.();
|
|
if (!reader) {
|
|
// Fallback for environments without a streaming body (shouldn't happen on Node 24 fetch).
|
|
const buf = new Uint8Array(await response.arrayBuffer());
|
|
const capped = buf.length > MAX_BYTES;
|
|
return { text: Buffer.from(buf.subarray(0, MAX_BYTES)).toString("utf8"), truncated: capped };
|
|
}
|
|
const chunks = [];
|
|
let total = 0;
|
|
let truncated = false;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (total + value.length > MAX_BYTES) {
|
|
chunks.push(value.subarray(0, MAX_BYTES - total));
|
|
truncated = true;
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
break;
|
|
}
|
|
chunks.push(value);
|
|
total += value.length;
|
|
}
|
|
return { text: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8"), truncated };
|
|
}
|
|
|
|
/**
|
|
* Fetches `rawUrl` under the guards above and returns { requestedUrl, finalUrl, httpStatus,
|
|
* contentType, bytes, truncated, text, html }. Throws an Error with a `.code` on any failure
|
|
* (`bad_url`, `scheme_rejected`, `redirect_downgrade_rejected`, `too_many_redirects`,
|
|
* `content_type_rejected`, `fetch_timeout`, `fetch_failed`) — the route handler maps these to
|
|
* HTTP status codes.
|
|
*/
|
|
export async function fetchPageText(rawUrl) {
|
|
let url;
|
|
try {
|
|
url = normalizeToUrl(rawUrl);
|
|
} catch {
|
|
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
|
|
}
|
|
if (!url) {
|
|
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
|
|
}
|
|
if (url.protocol !== "https:") {
|
|
throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected");
|
|
}
|
|
|
|
let response;
|
|
let redirects = 0;
|
|
let currentUrl = url;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
try {
|
|
for (;;) {
|
|
let res;
|
|
try {
|
|
res = await fetch(currentUrl, {
|
|
method: "GET",
|
|
redirect: "manual",
|
|
signal: controller.signal,
|
|
headers: {
|
|
"user-agent": USER_AGENT,
|
|
accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.1",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
if (err.name === "AbortError") throw withCode(new Error(`Request timed out after ${TIMEOUT_MS}ms.`), "fetch_timeout");
|
|
throw withCode(new Error(`Network error fetching ${currentUrl}: ${err.message}`), "fetch_failed");
|
|
}
|
|
|
|
if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
|
|
redirects += 1;
|
|
if (redirects > MAX_REDIRECTS) throw withCode(new Error("Too many redirects."), "too_many_redirects");
|
|
const next = new URL(res.headers.get("location"), currentUrl);
|
|
if (next.protocol !== "https:") {
|
|
throw withCode(new Error(`Redirect to non-https URL rejected (${next}).`), "redirect_downgrade_rejected");
|
|
}
|
|
currentUrl = next;
|
|
continue;
|
|
}
|
|
// Some storefronts (including Shopify sites protected by Cloudflare) rate-limit
|
|
// server-to-server requests while allowing a normal browser. Use the operator's
|
|
// local Browserless service only as a narrow fallback; all existing size and
|
|
// extraction limits still apply below.
|
|
if (res.status === 429 && BROWSERLESS_URL) {
|
|
response = await fetchWithBrowserless(currentUrl, controller.signal);
|
|
} else {
|
|
response = res;
|
|
}
|
|
break;
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
const contentType = contentTypeOf(response.headers.get("content-type"));
|
|
if (!contentType || !ALLOWED_CONTENT_TYPES.includes(contentType)) {
|
|
throw withCode(new Error(`Disallowed content-type: ${contentType ?? "(none)"}.`), "content_type_rejected");
|
|
}
|
|
if (!response.ok) {
|
|
throw withCode(new Error(`HTTP ${response.status} fetching ${currentUrl}.`), "fetch_failed");
|
|
}
|
|
|
|
const { text: html, truncated } = await readBodyCapped(response);
|
|
const extracted = extractTextFromHtml(html).slice(0, MAX_EXTRACTED_CHARS);
|
|
|
|
return {
|
|
requestedUrl: rawUrl,
|
|
finalUrl: currentUrl.toString(),
|
|
httpStatus: response.status,
|
|
contentType,
|
|
bytes: html.length,
|
|
truncated,
|
|
text: extracted,
|
|
};
|
|
}
|
|
|
|
async function fetchWithBrowserless(url, signal) {
|
|
try {
|
|
const response = await fetch(`${BROWSERLESS_URL}/content`, {
|
|
method: "POST",
|
|
signal,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ url: url.toString(), gotoOptions: { waitUntil: "networkidle2", timeout: TIMEOUT_MS } }),
|
|
});
|
|
if (!response.ok) throw new Error(`Browserless returned HTTP ${response.status}.`);
|
|
return response;
|
|
} catch (error) {
|
|
throw withCode(new Error(`Browser fallback failed for ${url}: ${error.message}`), "fetch_failed");
|
|
}
|
|
}
|
|
|
|
function withCode(err, code) {
|
|
err.code = code;
|
|
return err;
|
|
}
|
|
|
|
/** Ported verbatim (behaviour) from fetch-guard.ts's extractTextFromHtml. */
|
|
export function extractTextFromHtml(html) {
|
|
let s = html;
|
|
s = s.replace(/<!--[\s\S]*?-->/g, " ");
|
|
s = s.replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
s = s.replace(/<(nav|header|footer|aside)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
s = s.replace(/<br\s*\/?>/gi, "\n");
|
|
s = s.replace(/<\/(p|div|li|tr|h1|h2|h3|h4|h5|h6|section|article|table)\s*>/gi, "\n");
|
|
s = s.replace(/<[^>]+>/g, " ");
|
|
s = s
|
|
.replace(/ /gi, " ")
|
|
.replace(/&/gi, "&")
|
|
.replace(/</gi, "<")
|
|
.replace(/>/gi, ">")
|
|
.replace(/"/gi, '"')
|
|
.replace(/�?39;/gi, "'");
|
|
s = s.replace(/[ \t]+/g, " ");
|
|
s = s.replace(/[ \t]*\n[ \t]*/g, "\n");
|
|
s = s.replace(/\n{3,}/g, "\n\n");
|
|
return s.trim();
|
|
}
|