Files

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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0?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();
}