529 lines
19 KiB
JavaScript
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();
|