2026-02-18 13:18:33 +00:00

529 lines
19 KiB
JavaScript

const DEFAULT_ENDPOINT = "http://127.0.0.1:8765/screenshot";
const HISTORY_KEY = "capture_history_v1";
const HISTORY_LIMIT = 25;
const EXTRA_INSTRUCTIONS_KEY = "extra_instructions_v1";
function $(id) {
return document.getElementById(id);
}
function setStatus(msg, kind) {
const el = $("status");
el.textContent = msg;
el.className = kind || "";
}
async function storageGet(key) {
return new Promise((resolve) => {
chrome.storage.local.get([key], (res) => resolve(res[key]));
});
}
async function storageSet(obj) {
return new Promise((resolve) => {
chrome.storage.local.set(obj, () => resolve());
});
}
function clampString(s, maxLen) {
const t = String(s || "");
if (t.length <= maxLen) return t;
return t.slice(0, Math.max(0, maxLen - 1)) + "…";
}
function renderResult(entryOrResp) {
const resp = entryOrResp && entryOrResp.png_path ? entryOrResp : entryOrResp && entryOrResp.resp ? entryOrResp.resp : entryOrResp;
const meta = entryOrResp && entryOrResp.title ? entryOrResp : null;
const lines = [];
lines.push("Saved:");
lines.push(` PNG: ${resp.png_path || "(unknown)"}`);
lines.push(` META: ${resp.meta_path || "(unknown)"}`);
if (resp.content_path) lines.push(` CONTENT: ${resp.content_path}`);
if (meta && meta.url) lines.push(` URL: ${meta.url}`);
if (meta && meta.extra_instructions) lines.push(` EXTRA: ${clampString(meta.extra_instructions, 220)}`);
if (resp.ai_result) {
if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) {
lines.push("");
{
const ms = resp.ai_result.took_ms || "?";
const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : "";
const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : "";
const status = resp.ai_result.status && resp.ai_result.status !== "completed" ? ` status=${resp.ai_result.status}` : "";
const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : "";
lines.push(`AI (${ms}ms${model}${rid}${status}${inc}):`);
}
for (const p of resp.ai_result.ai.posts) {
const idx = typeof p.index === "number" ? p.index : "?";
const postText = (p.post_text || "").replace(/\s+/g, " ").trim();
// Back-compat with older saved results.
const hasNew =
"improved_short" in p ||
"improved_medium" in p ||
"critical_short" in p ||
"critical_medium" in p ||
"suggested_short" in p ||
"suggested_medium" in p;
const improvedShort = (p.improved_short || "").replace(/\s+/g, " ").trim();
const improvedMedium = (p.improved_medium || "").replace(/\s+/g, " ").trim();
const criticalShort = (p.critical_short || "").replace(/\s+/g, " ").trim();
const criticalMedium = (p.critical_medium || "").replace(/\s+/g, " ").trim();
const suggestedShort = (p.suggested_short || "").replace(/\s+/g, " ").trim();
const suggestedMedium = (p.suggested_medium || "").replace(/\s+/g, " ").trim();
const shortLegacy = (p.short_response || "").replace(/\s+/g, " ").trim();
const mediumLegacy = (p.medium_response || "").replace(/\s+/g, " ").trim();
lines.push("");
lines.push(`#${idx} Post: ${clampString(postText, 180)}`);
if (hasNew) {
lines.push(` improved (short): ${improvedShort}`);
lines.push(` improved (medium): ${improvedMedium}`);
lines.push(` Critical (short): ${criticalShort}`);
lines.push(` Critical (medium): ${criticalMedium}`);
lines.push(` Suggested (short): ${suggestedShort}`);
lines.push(` Suggested (medium): ${suggestedMedium}`);
} else {
lines.push(` Short: ${shortLegacy}`);
lines.push(` Medium: ${mediumLegacy}`);
}
}
if (resp.ai_result.ai_path) lines.push(`\nAI file: ${resp.ai_result.ai_path}`);
if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") {
const u = resp.ai_result.usage;
const ins = typeof u.input_tokens === "number" ? u.input_tokens : null;
const outs = typeof u.output_tokens === "number" ? u.output_tokens : null;
const tots = typeof u.total_tokens === "number" ? u.total_tokens : null;
const parts = [];
if (ins != null) parts.push(`in=${ins}`);
if (outs != null) parts.push(`out=${outs}`);
if (tots != null) parts.push(`total=${tots}`);
if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`);
}
} else {
lines.push("");
const ms = resp.ai_result.took_ms || "?";
const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : "";
const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : "";
const status = resp.ai_result.status ? ` status=${resp.ai_result.status}` : "";
const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : "";
const err = resp.ai_result.error || (resp.ai_result.ai && resp.ai_result.ai.error) || "unknown";
lines.push(`AI error (${ms}ms${model}${rid}${status}${inc}): ${err}`);
if (resp.ai_result.detail) lines.push(`Detail: ${clampString(resp.ai_result.detail, 600)}`);
if (resp.ai_result.raw_preview) lines.push(`Raw: ${clampString(resp.ai_result.raw_preview, 600)}`);
if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") {
const u = resp.ai_result.usage;
const ins = typeof u.input_tokens === "number" ? u.input_tokens : null;
const outs = typeof u.output_tokens === "number" ? u.output_tokens : null;
const tots = typeof u.total_tokens === "number" ? u.total_tokens : null;
const parts = [];
if (ins != null) parts.push(`in=${ins}`);
if (outs != null) parts.push(`out=${outs}`);
if (tots != null) parts.push(`total=${tots}`);
if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`);
}
if (resp.ai_result.ai_path) lines.push(`AI file: ${resp.ai_result.ai_path}`);
}
}
if (resp.ran) {
lines.push("Ran:");
if (resp.ran.error) {
lines.push(` error: ${resp.ran.error}`);
} else {
lines.push(` exit: ${resp.ran.exit_code}`);
if (resp.ran.stdout) lines.push(` stdout: ${resp.ran.stdout.trim()}`);
if (resp.ran.stderr) lines.push(` stderr: ${resp.ran.stderr.trim()}`);
}
}
return lines.join("\n");
}
async function loadHistory() {
const h = (await storageGet(HISTORY_KEY)) || [];
return Array.isArray(h) ? h : [];
}
async function saveHistory(history) {
await storageSet({ [HISTORY_KEY]: history });
}
function historyLabel(item) {
const when = item.saved_at ? item.saved_at.replace("T", " ").replace("Z", "Z") : "";
const t = item.title ? clampString(item.title, 36) : "";
const u = item.url ? clampString(item.url, 46) : "";
const base = t || u || item.id || "";
return when ? `${when} | ${base}` : base;
}
async function refreshHistoryUI() {
const sel = $("history");
const history = await loadHistory();
sel.textContent = "";
if (!history.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(empty)";
sel.appendChild(opt);
sel.disabled = true;
return;
}
sel.disabled = false;
for (const item of history) {
const opt = document.createElement("option");
opt.value = item.id || item.png_path || item.meta_path || "";
opt.textContent = historyLabel(item);
sel.appendChild(opt);
}
}
async function getActiveTab() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return tabs[0] || null;
}
async function captureVisibleTab() {
// Defaults to current window when windowId is null.
return await chrome.tabs.captureVisibleTab(null, { format: "png" });
}
async function extractPageContent(tabId) {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const SKIP_TAGS = new Set(["script", "style", "noscript", "template", "head", "meta", "link", "svg", "canvas"]);
const MAX_DEPTH = 14;
const MAX_NODES = 1800;
const MAX_TEXT_NODE_LEN = 500;
let nodeCount = 0;
let truncated = false;
function cleanText(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim();
}
function rectIntersectsViewport(r) {
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
return r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
}
function isVisibleElement(el) {
try {
const cs = window.getComputedStyle(el);
if (!cs) return false;
if (cs.display === "none" || cs.visibility === "hidden") return false;
const op = Number(cs.opacity || "1");
if (!Number.isNaN(op) && op <= 0.02) return false;
const r = el.getBoundingClientRect();
if ((r.width || 0) < 1 || (r.height || 0) < 1) return false;
if (!rectIntersectsViewport(r)) return false;
return true;
} catch {
return false;
}
}
function hasMeaningfulAttrs(el) {
const role = (el.getAttribute("role") || "").trim();
if (role) return true;
const aria = (el.getAttribute("aria-label") || "").trim();
if (aria) return true;
const dt = (el.getAttribute("data-testid") || "").trim();
if (dt) return true;
const tag = el.tagName.toLowerCase();
if (tag === "a" && (el.getAttribute("href") || el.href)) return true;
if (tag === "button" || tag === "input" || tag === "textarea" || tag === "select") return true;
return false;
}
function directVisibleText(el) {
// Only include immediate text nodes and form values. This avoids duplicating text up the tree.
const parts = [];
for (const n of Array.from(el.childNodes || [])) {
if (n.nodeType === Node.TEXT_NODE) {
const t = cleanText(n.nodeValue || "");
if (t) parts.push(t.length > MAX_TEXT_NODE_LEN ? t.slice(0, MAX_TEXT_NODE_LEN - 1) + "…" : t);
}
}
const tag = el.tagName.toLowerCase();
if (tag === "input") {
const type = (el.getAttribute("type") || "text").toLowerCase();
if (!["hidden", "submit", "button"].includes(type)) {
const v = cleanText(el.value || el.getAttribute("value") || el.getAttribute("placeholder") || "");
if (v) parts.push(v);
}
} else if (tag === "textarea") {
const v = cleanText(el.value || el.getAttribute("placeholder") || "");
if (v) parts.push(v);
}
return cleanText(parts.join(" "));
}
function simplifyTag(tag) {
// Keep some semantics; treat most wrapper tags as "div".
if (["main", "article", "section", "ul", "ol", "li", "p"].includes(tag)) return tag;
if (tag.match(/^h[1-6]$/)) return tag;
if (["a", "button", "label"].includes(tag)) return tag;
if (["header", "footer", "nav", "aside"].includes(tag)) return tag;
return "div";
}
function shouldSkip(el, tag) {
if (SKIP_TAGS.has(tag)) return true;
// Skip common overlay noise.
const id = (el.id || "").toLowerCase();
if (id.includes("cookie") || id.includes("consent")) return true;
return false;
}
function collapseWrappers(node) {
// Collapse div/span wrappers: div > div > div ... with a single child and no text/attrs.
// We only collapse when the wrapper has exactly one child.
while (
node &&
node.tag === "div" &&
!node.text &&
!node.attrs &&
Array.isArray(node.children) &&
node.children.length === 1 &&
node.children[0] &&
node.children[0].tag
) {
node = node.children[0];
}
return node;
}
function maybeHoistInlineText(node) {
// Common pattern: <a><span>Text</span></a> or <button><div><span>...</span></div></button>
if (!node || node.text) return node;
if (!["a", "button", "label", "p", "li"].includes(node.tag)) return node;
if (!Array.isArray(node.children) || node.children.length !== 1) return node;
const ch = node.children[0];
if (ch && ch.text && (!ch.children || ch.children.length === 0) && (!ch.attrs || Object.keys(ch.attrs).length === 0)) {
node.text = ch.text;
delete node.children;
}
return node;
}
function build(el, depth) {
if (truncated) return null;
if (!el || depth > MAX_DEPTH) return null;
if (!(el instanceof Element)) return null;
const rawTag = el.tagName.toLowerCase();
if (shouldSkip(el, rawTag)) return null;
if (!isVisibleElement(el)) return null;
nodeCount += 1;
if (nodeCount > MAX_NODES) {
truncated = true;
return null;
}
const tag = simplifyTag(rawTag);
const text = directVisibleText(el);
const children = [];
for (const ch of Array.from(el.children || [])) {
const n = build(ch, depth + 1);
if (n) children.push(n);
if (truncated) break;
}
if (!text && children.length === 0) return null;
const node = { tag };
if (text) node.text = text;
// Only keep attrs when they help identify purpose/action.
if (hasMeaningfulAttrs(el)) {
const attrs = {};
const role = (el.getAttribute("role") || "").trim();
const aria = (el.getAttribute("aria-label") || "").trim();
const dt = (el.getAttribute("data-testid") || "").trim();
if (role) attrs.role = role;
if (aria) attrs.aria_label = aria;
if (dt) attrs.data_testid = dt;
if (rawTag === "a") {
const href = (el.href || el.getAttribute("href") || "").trim();
if (href) attrs.href = href;
}
if (Object.keys(attrs).length) node.attrs = attrs;
}
if (children.length) node.children = children;
return maybeHoistInlineText(collapseWrappers(node));
}
const extractedAt = new Date().toISOString();
const url = String(location.href || "");
const title = String(document.title || "");
const root = document.body ? build(document.body, 0) : null;
return {
extracted_at: extractedAt,
url,
title,
hostname: location.hostname,
visible_tree: root,
truncated,
stats: { nodes: nodeCount },
};
},
});
return result;
}
async function postScreenshot(endpoint, payload) {
const r = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const text = await r.text();
let data = null;
try {
data = JSON.parse(text);
} catch {
// ignore
}
if (!r.ok) {
throw new Error(`HTTP ${r.status}: ${text}`);
}
return data;
}
async function ping(endpoint) {
const base = endpoint.replace(/\/screenshot\s*$/, "");
const r = await fetch(`${base}/health`, { method: "GET" });
if (!r.ok) return `HTTP ${r.status}`;
const j = await r.json();
return j && j.ok ? "ok" : "unexpected_response";
}
async function main() {
const endpointEl = $("endpoint");
const extraEl = $("extra_instructions");
const captureBtn = $("capture");
const pingBtn = $("ping");
const historySel = $("history");
const showHistoryBtn = $("show_history");
const clearHistoryBtn = $("clear_history");
endpointEl.value = (await storageGet("endpoint")) || DEFAULT_ENDPOINT;
extraEl.value = (await storageGet(EXTRA_INSTRUCTIONS_KEY)) || "";
await refreshHistoryUI();
endpointEl.addEventListener("change", async () => {
await storageSet({ endpoint: endpointEl.value.trim() });
});
extraEl.addEventListener("change", async () => {
await storageSet({ [EXTRA_INSTRUCTIONS_KEY]: extraEl.value });
});
pingBtn.addEventListener("click", async () => {
const endpoint = endpointEl.value.trim() || DEFAULT_ENDPOINT;
setStatus("Pinging...", "");
const msg = await ping(endpoint);
setStatus(`Ping result: ${msg}`, msg === "ok" ? "ok" : "err");
});
showHistoryBtn.addEventListener("click", async () => {
const history = await loadHistory();
if (!history.length) {
setStatus("History is empty.", "err");
return;
}
const id = historySel.value;
const item = history.find((x) => (x.id || x.png_path || x.meta_path) === id) || history[0];
if (!item) {
setStatus("No history item selected.", "err");
return;
}
setStatus(renderResult(item), "ok");
});
clearHistoryBtn.addEventListener("click", async () => {
await saveHistory([]);
await refreshHistoryUI();
setStatus("History cleared.", "ok");
});
captureBtn.addEventListener("click", async () => {
const endpoint = endpointEl.value.trim() || DEFAULT_ENDPOINT;
const extraInstructions = (extraEl.value || "").trim();
captureBtn.disabled = true;
setStatus("Extracting page content...", "");
try {
const tab = await getActiveTab();
if (!tab) throw new Error("No active tab found");
let content = null;
try {
content = await extractPageContent(tab.id);
} catch (e) {
// Some URLs (chrome://*, Web Store, PDF viewer) cannot be scripted.
content = { error: String(e && e.message ? e.message : e) };
}
setStatus("Capturing visible tab...", "");
const dataUrl = await captureVisibleTab();
setStatus("Uploading to local server...", "");
const resp = await postScreenshot(endpoint, {
data_url: dataUrl,
title: tab.title || "",
url: tab.url || "",
ts: new Date().toISOString(),
content,
extra_instructions: extraInstructions,
});
// Persist for later viewing in the popup.
const history = await loadHistory();
const entry = {
id: resp.png_path || resp.meta_path || String(Date.now()),
saved_at: new Date().toISOString(),
title: tab.title || "",
url: tab.url || "",
extra_instructions: extraInstructions,
resp: {
png_path: resp.png_path || "",
meta_path: resp.meta_path || "",
content_path: resp.content_path || "",
ai_result: resp.ai_result || null,
},
};
const deduped = [entry, ...history.filter((x) => (x.id || x.png_path || x.meta_path) !== entry.id)].slice(0, HISTORY_LIMIT);
await saveHistory(deduped);
await refreshHistoryUI();
setStatus(renderResult(entry), "ok");
} catch (e) {
setStatus(String(e && e.message ? e.message : e), "err");
} finally {
captureBtn.disabled = false;
}
});
}
main();