Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve
- shared/ ports the worksheet's ledger math, time parsing, and reference tables (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported by both the server and the browser so the arithmetic can't drift between them. - server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts from a bean product URL, then derives worksheet field IDs deterministically from reference-data.js — the model never invents a first-crack anchor or a modifier. - server/alog.js ports the Python alog_parser.py's format handling, including the tokenizer-based Python-dict-literal-to-JSON conversion the real files need. - public/ is the worksheet reproduced as a live HTML form: worksheet.css is a verbatim copy of the paper worksheet's print CSS, print.js bakes values into the print layout so the same DOM renders both on screen and on paper. - Ledger math verified against both of the paper worksheet's worked examples; alog parser verified against all 14 real logs in ref/roasts/. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// 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;
|
||||
|
||||
function normalizeToUrl(input) {
|
||||
const trimmed = input.trim();
|
||||
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||
const candidate = hasScheme ? trimmed : `https://${trimmed}`;
|
||||
return new URL(candidate);
|
||||
}
|
||||
|
||||
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.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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user