Fallback to Browserless for rate-limited prefills
This commit is contained in:
@@ -10,6 +10,10 @@ services:
|
|||||||
BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-}
|
BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-}
|
||||||
# Leave unset unless a known reverse-proxy address/CIDR is configured.
|
# Leave unset unless a known reverse-proxy address/CIDR is configured.
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-}
|
TRUST_PROXY: ${TRUST_PROXY:-}
|
||||||
|
# Browser-rendered fallback for storefronts that rate-limit server fetches.
|
||||||
|
BROWSERLESS_URL: ${BROWSERLESS_URL:-http://host.docker.internal:9085}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
|
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
|
||||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
|
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ const MAX_REDIRECTS = 5;
|
|||||||
const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"];
|
const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"];
|
||||||
const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)";
|
const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)";
|
||||||
const MAX_EXTRACTED_CHARS = 40_000;
|
const MAX_EXTRACTED_CHARS = 40_000;
|
||||||
|
const BROWSERLESS_URL = process.env.BROWSERLESS_URL?.replace(/\/$/, "");
|
||||||
|
|
||||||
function normalizeToUrl(input) {
|
function normalizeToUrl(input) {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||||
const candidate = hasScheme ? trimmed : `https://${trimmed}`;
|
const candidate = hasScheme ? trimmed : `https://${trimmed}`;
|
||||||
|
try {
|
||||||
return new URL(candidate);
|
return new URL(candidate);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentTypeOf(header) {
|
function contentTypeOf(header) {
|
||||||
@@ -70,6 +75,9 @@ export async function fetchPageText(rawUrl) {
|
|||||||
} catch {
|
} catch {
|
||||||
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
|
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:") {
|
if (url.protocol !== "https:") {
|
||||||
throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected");
|
throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected");
|
||||||
}
|
}
|
||||||
@@ -107,7 +115,15 @@ export async function fetchPageText(rawUrl) {
|
|||||||
currentUrl = next;
|
currentUrl = next;
|
||||||
continue;
|
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;
|
response = res;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,6 +152,21 @@ export async function fetchPageText(rawUrl) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function withCode(err, code) {
|
||||||
err.code = code;
|
err.code = code;
|
||||||
return err;
|
return err;
|
||||||
|
|||||||
Reference in New Issue
Block a user