Initial Context Extractor: extension + Playwright/Camoufox package
CI / Lint + tests (push) Has been cancelled
CI / Lint + tests (push) Has been cancelled
Ship a shared markdown/prompt core used by a Brave/Chrome MV3 extension and a Python automation API, with pytest coverage, CI, and packaging smoke.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
// background.js — Context Extractor service worker
|
||||
// Persists the selector chosen via the in-page element picker so the popup
|
||||
// can pre-fill it the next time it opens.
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (!msg || !msg.type) return false;
|
||||
|
||||
if (msg.type === "PICKER_SELECTED") {
|
||||
const selector = msg.selector || "";
|
||||
chrome.storage.session
|
||||
.set({ pickerSelector: selector })
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch((err) => sendResponse({ ok: false, error: String(err) }));
|
||||
return true; // async response
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// content.js — Context Extractor (isolated world)
|
||||
//
|
||||
// Loaded together with core/dom.js in the same content_scripts entry (see
|
||||
// manifest.json), so getSelector() and extractMarkdown() are already in
|
||||
// scope here — no import needed. Also injected on-demand by the popup via
|
||||
// chrome.scripting.executeScript when a tab was open before the extension
|
||||
// loaded (or after a reload of the extension without a page reload).
|
||||
//
|
||||
// Capture (console/fetch/XHR/errors) happens in page-patcher.js, which runs
|
||||
// in the MAIN world and forwards entries here via a CustomEvent, since the
|
||||
// isolated and main worlds don't share JS globals.
|
||||
(() => {
|
||||
// Guard against double injection (manifest auto-inject + popup inject).
|
||||
if (window.__ctxExtractorIsolated) return;
|
||||
window.__ctxExtractorIsolated = true;
|
||||
|
||||
const MAX_ENTRIES = 200;
|
||||
const BRIDGE_EVENT = "__ctxExtractorEvent";
|
||||
|
||||
const store = {
|
||||
console: [], // { level, ts, msg }
|
||||
errors: [], // { type, ts, msg, source, line, col, stack }
|
||||
network: [] // { type, method, url, status, ts, duration, error }
|
||||
};
|
||||
|
||||
function push(arr, entry) {
|
||||
arr.push(entry);
|
||||
while (arr.length > MAX_ENTRIES) arr.shift();
|
||||
}
|
||||
|
||||
document.addEventListener(BRIDGE_EVENT, (ev) => {
|
||||
const detail = ev && ev.detail;
|
||||
if (!detail) return;
|
||||
const { kind, payload } = detail;
|
||||
if (!payload) return;
|
||||
if (kind === "console") push(store.console, payload);
|
||||
else if (kind === "error") push(store.errors, payload);
|
||||
else if (kind === "network") push(store.network, payload);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element picker
|
||||
// ---------------------------------------------------------------------------
|
||||
let pickerActive = false;
|
||||
let pickerNodes = null;
|
||||
|
||||
function startPicker() {
|
||||
if (pickerActive) return;
|
||||
pickerActive = true;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = [
|
||||
"position:fixed", "inset:0", "z-index:2147483646",
|
||||
"background:transparent", "cursor:crosshair", "pointer-events:auto"
|
||||
].join(";");
|
||||
|
||||
const highlight = document.createElement("div");
|
||||
highlight.style.cssText = [
|
||||
"position:fixed", "z-index:2147483647",
|
||||
"border:2px solid #4f98a3",
|
||||
"background:rgba(79,152,163,0.18)",
|
||||
"pointer-events:none",
|
||||
"transition:all 40ms linear",
|
||||
"box-sizing:border-box",
|
||||
"border-radius:2px"
|
||||
].join(";");
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.style.cssText = [
|
||||
"position:fixed", "z-index:2147483647",
|
||||
"background:#1c1b19", "color:#e8e6e3",
|
||||
"font:12px/1.4 ui-monospace,Menlo,Consolas,monospace",
|
||||
"padding:4px 8px", "border-radius:4px",
|
||||
"border:1px solid #4f98a3",
|
||||
"pointer-events:none",
|
||||
"max-width:60vw", "overflow:hidden",
|
||||
"text-overflow:ellipsis", "white-space:nowrap"
|
||||
].join(";");
|
||||
label.textContent = "Pick an element — ESC to cancel";
|
||||
|
||||
document.documentElement.appendChild(overlay);
|
||||
document.documentElement.appendChild(highlight);
|
||||
document.documentElement.appendChild(label);
|
||||
|
||||
let currentEl = null;
|
||||
|
||||
function positionHighlight(el) {
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
highlight.style.left = r.left + "px";
|
||||
highlight.style.top = r.top + "px";
|
||||
highlight.style.width = r.width + "px";
|
||||
highlight.style.height = r.height + "px";
|
||||
|
||||
const sel = getSelector(el);
|
||||
label.textContent = sel || el.tagName.toLowerCase();
|
||||
const lx = Math.min(r.left, window.innerWidth - 320);
|
||||
const ly = r.top > 28 ? r.top - 24 : r.bottom + 4;
|
||||
label.style.left = Math.max(4, lx) + "px";
|
||||
label.style.top = Math.max(4, ly) + "px";
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
overlay.style.pointerEvents = "none";
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
overlay.style.pointerEvents = "auto";
|
||||
if (el && el !== highlight && el !== label && el !== overlay) {
|
||||
currentEl = el;
|
||||
positionHighlight(el);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
pickerActive = false;
|
||||
overlay.removeEventListener("mousemove", onMove, true);
|
||||
overlay.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
overlay.remove();
|
||||
highlight.remove();
|
||||
label.remove();
|
||||
pickerNodes = null;
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
overlay.style.pointerEvents = "none";
|
||||
const el = currentEl || document.elementFromPoint(e.clientX, e.clientY);
|
||||
const sel = el ? getSelector(el) : "";
|
||||
try {
|
||||
chrome.runtime.sendMessage({ type: "PICKER_SELECTED", selector: sel });
|
||||
} catch (_) {}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener("mousemove", onMove, true);
|
||||
overlay.addEventListener("click", onClick, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
pickerNodes = { overlay, highlight, label, cleanup };
|
||||
}
|
||||
|
||||
function stopPicker() {
|
||||
if (pickerNodes && pickerNodes.cleanup) pickerNodes.cleanup();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message handling
|
||||
// ---------------------------------------------------------------------------
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (!msg || !msg.type) {
|
||||
sendResponse({ ok: false, error: "No message type" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case "GET_STORE": {
|
||||
sendResponse({ ok: true, store });
|
||||
break;
|
||||
}
|
||||
case "EXTRACT_CONTENT": {
|
||||
const selector = (msg.selector || "").trim();
|
||||
let target = null;
|
||||
if (selector) {
|
||||
try { target = document.querySelector(selector); } catch (_) { target = null; }
|
||||
}
|
||||
if (!target) target = document.body;
|
||||
const markdown = extractMarkdown(target);
|
||||
sendResponse({
|
||||
ok: true,
|
||||
meta: {
|
||||
url: location.href,
|
||||
title: document.title || "",
|
||||
ts: Date.now(),
|
||||
selector: selector || "body"
|
||||
},
|
||||
markdown
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "START_PICKER": {
|
||||
startPicker();
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
}
|
||||
case "STOP_PICKER": {
|
||||
stopPicker();
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
}
|
||||
case "CLEAR_STORE": {
|
||||
const which = msg.which;
|
||||
if (which && store[which]) store[which].length = 0;
|
||||
else {
|
||||
store.console.length = 0;
|
||||
store.errors.length = 0;
|
||||
store.network.length = 0;
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sendResponse({ ok: false, error: "Unknown type: " + msg.type });
|
||||
}
|
||||
} catch (err) {
|
||||
sendResponse({ ok: false, error: String(err && err.message || err) });
|
||||
}
|
||||
return true; // keep channel open for async
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,186 @@
|
||||
// core/dom.js — shared DOM helpers (Context Extractor)
|
||||
//
|
||||
// Pure, read-only DOM logic with no chrome.* / node dependency. Loaded two ways:
|
||||
// 1. Extension: listed before content.js in manifest.json's content_scripts,
|
||||
// so these top-level declarations land in the same isolated-world scope.
|
||||
// 2. Automation (Playwright/Camoufox): file contents are read from disk and
|
||||
// evaluated in-page via page.evaluate(). Safe under Camoufox's default
|
||||
// isolated world since it only *reads* the DOM (getComputedStyle included)
|
||||
// — it never writes to the live page, and no longer even clones it.
|
||||
//
|
||||
// Do not add chrome.*, window.close(), or any write to the live document here.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS selector synthesis
|
||||
// ---------------------------------------------------------------------------
|
||||
function getSelector(el) {
|
||||
if (!(el instanceof Element)) return "";
|
||||
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) return "#" + el.id;
|
||||
|
||||
const parts = [];
|
||||
let node = el;
|
||||
while (node && node.nodeType === 1 && node !== document.documentElement) {
|
||||
let part = node.tagName.toLowerCase();
|
||||
if (node.id && /^[A-Za-z][\w-]*$/.test(node.id)) {
|
||||
parts.unshift("#" + node.id);
|
||||
break;
|
||||
}
|
||||
if (node.classList && node.classList.length) {
|
||||
const cls = Array.from(node.classList)
|
||||
.filter((c) => /^[A-Za-z_][\w-]*$/.test(c))
|
||||
.slice(0, 2)
|
||||
.map((c) => "." + c)
|
||||
.join("");
|
||||
part += cls;
|
||||
}
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const sameTag = Array.from(parent.children).filter(
|
||||
(c) => c.tagName === node.tagName
|
||||
);
|
||||
if (sameTag.length > 1) {
|
||||
const idx = sameTag.indexOf(node) + 1;
|
||||
part += ":nth-of-type(" + idx + ")";
|
||||
}
|
||||
}
|
||||
parts.unshift(part);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return parts.join(" > ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown extraction
|
||||
//
|
||||
// Walks the *live* node directly — no cloning needed. Only ever reads
|
||||
// (textContent, attributes, getComputedStyle); never mutates. Skips tags that
|
||||
// never carry visible content, plus anything actually invisible to a human
|
||||
// (display:none / visibility:hidden / [hidden] / [aria-hidden="true"]).
|
||||
// SPAs routinely stash large hydration/experiment JSON payloads in hidden DOM
|
||||
// nodes (not just <script> tags) — without this check that JSON gets read as
|
||||
// if it were visible page text.
|
||||
// ---------------------------------------------------------------------------
|
||||
const NEVER_RENDERED_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "canvas"]);
|
||||
|
||||
function isHidden(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
if (el.hidden) return true;
|
||||
const ariaHidden = el.getAttribute && el.getAttribute("aria-hidden");
|
||||
if (ariaHidden === "true") return true;
|
||||
try {
|
||||
const cs = window.getComputedStyle(el);
|
||||
if (cs && (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse")) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// getComputedStyle can throw on detached/foreign nodes; treat as visible.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSkip(el) {
|
||||
const tag = el.tagName ? el.tagName.toLowerCase() : "";
|
||||
return NEVER_RENDERED_TAGS.has(tag) || isHidden(el);
|
||||
}
|
||||
|
||||
function extractMarkdown(node) {
|
||||
if (!node) return "";
|
||||
|
||||
const out = [];
|
||||
|
||||
function emit(s) { out.push(s); }
|
||||
|
||||
function inline(el) {
|
||||
const tag = el.tagName ? el.tagName.toLowerCase() : "";
|
||||
const kids = walkInline(el);
|
||||
switch (tag) {
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") || "";
|
||||
return "[" + kids + "](" + href + ")";
|
||||
}
|
||||
case "strong":
|
||||
case "b":
|
||||
return "**" + kids + "**";
|
||||
case "em":
|
||||
case "i":
|
||||
return "_" + kids + "_";
|
||||
case "code":
|
||||
return "`" + kids + "`";
|
||||
case "br":
|
||||
return "\n";
|
||||
default:
|
||||
return kids;
|
||||
}
|
||||
}
|
||||
|
||||
function walkInline(el) {
|
||||
let s = "";
|
||||
el.childNodes.forEach((c) => {
|
||||
if (c.nodeType === 3) s += c.nodeValue;
|
||||
else if (c.nodeType === 1 && !shouldSkip(c)) s += inline(c);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
function block(el) {
|
||||
if (shouldSkip(el)) return;
|
||||
const tag = el.tagName ? el.tagName.toLowerCase() : "";
|
||||
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
const level = Number(tag[1]);
|
||||
emit("\n" + "#".repeat(level) + " " + walkInline(el).trim() + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case "p":
|
||||
emit("\n" + walkInline(el).trim() + "\n");
|
||||
return;
|
||||
case "br":
|
||||
emit("\n");
|
||||
return;
|
||||
case "hr":
|
||||
emit("\n---\n");
|
||||
return;
|
||||
case "li":
|
||||
emit("- " + walkInline(el).trim() + "\n");
|
||||
return;
|
||||
case "pre":
|
||||
emit("\n```\n" + (el.textContent || "").replace(/\n+$/, "") + "\n```\n");
|
||||
return;
|
||||
case "blockquote":
|
||||
emit("\n> " + walkInline(el).trim().replace(/\n/g, "\n> ") + "\n");
|
||||
return;
|
||||
case "ul":
|
||||
case "ol":
|
||||
case "div":
|
||||
case "section":
|
||||
case "article":
|
||||
case "main":
|
||||
case "header":
|
||||
case "footer":
|
||||
case "nav":
|
||||
case "aside":
|
||||
case "body":
|
||||
el.childNodes.forEach((c) => {
|
||||
if (c.nodeType === 1) block(c);
|
||||
else if (c.nodeType === 3) {
|
||||
const t = c.nodeValue;
|
||||
if (t && t.trim()) emit(t);
|
||||
}
|
||||
});
|
||||
return;
|
||||
default: {
|
||||
const text = walkInline(el);
|
||||
if (text) emit(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
block(node);
|
||||
|
||||
let md = out.join("");
|
||||
md = md.replace(/[ \t]+\n/g, "\n");
|
||||
md = md.replace(/\n{3,}/g, "\n\n");
|
||||
return md.trim();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// core/prompt.js — shared AI-prompt formatter (Context Extractor)
|
||||
//
|
||||
// Pure string formatting, no DOM/chrome.* dependency. Shared by:
|
||||
// 1. Extension popup (popup.html loads this before popup.js).
|
||||
// 2. Automation (evaluated in-page via page.evaluate so both surfaces
|
||||
// produce byte-identical output for the same inputs).
|
||||
//
|
||||
// store shape: { console: [{level,ts,msg}], errors: [{type,ts,msg,source,line,col,stack}],
|
||||
// network: [{type,method,url,status,ts,duration,error}] }
|
||||
// meta shape: { url, title, ts, selector }
|
||||
//
|
||||
// markdown is capped at maxChars (default 20,000) before it goes in the
|
||||
// prompt. Extracting the whole <body> of a JS-heavy SPA easily produces
|
||||
// hundreds of thousands of characters (LinkedIn: 700k+) — most LLM context
|
||||
// windows can't take that, and it's rarely what you want to paste anyway.
|
||||
// Pass a tighter selector (or a larger maxChars) instead of relying on this
|
||||
// cap for real content; it exists as a safety net, not a summarizer.
|
||||
const DEFAULT_MAX_MARKDOWN_CHARS = 20000;
|
||||
|
||||
function buildAIPrompt(meta, markdown, store, maxChars) {
|
||||
const limit = maxChars || DEFAULT_MAX_MARKDOWN_CHARS;
|
||||
const parts = [];
|
||||
|
||||
parts.push("# Page Context");
|
||||
if (meta) {
|
||||
if (meta.url) parts.push(`- URL: ${meta.url}`);
|
||||
if (meta.title) parts.push(`- Title: ${meta.title}`);
|
||||
if (meta.ts) parts.push(`- Captured: ${new Date(meta.ts).toISOString()}`);
|
||||
if (meta.selector) parts.push(`- Selector: \`${meta.selector}\``);
|
||||
}
|
||||
parts.push("");
|
||||
|
||||
const trimmed = markdown && markdown.trim();
|
||||
if (trimmed) {
|
||||
const truncated = trimmed.length > limit;
|
||||
const heading = truncated
|
||||
? `## Page Content (truncated to ${limit.toLocaleString()} of ${trimmed.length.toLocaleString()} chars — use a narrower selector to see more)`
|
||||
: "## Page Content";
|
||||
parts.push(heading);
|
||||
parts.push("");
|
||||
parts.push(truncated ? trimmed.slice(0, limit) + "\n…" : trimmed);
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
const errors = (store && store.errors) || [];
|
||||
if (errors.length) {
|
||||
parts.push("## JavaScript Errors");
|
||||
parts.push("");
|
||||
errors.forEach((e, i) => {
|
||||
const where = [e.source, e.line ? "line " + e.line : "", e.col ? "col " + e.col : ""]
|
||||
.filter(Boolean).join(" ");
|
||||
parts.push(`### ${i + 1}. ${e.type || "error"}: ${e.msg || ""}`);
|
||||
if (where) parts.push(`- Location: ${where}`);
|
||||
if (e.ts) parts.push(`- Time: ${new Date(e.ts).toISOString()}`);
|
||||
if (e.stack) {
|
||||
parts.push("");
|
||||
parts.push("```");
|
||||
parts.push(e.stack);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
});
|
||||
}
|
||||
|
||||
const consoleEntries = (store && store.console) || [];
|
||||
const consoleIssues = consoleEntries.filter((c) => c.level === "error" || c.level === "warn");
|
||||
if (consoleIssues.length) {
|
||||
parts.push("## Console Errors & Warnings");
|
||||
parts.push("");
|
||||
consoleIssues.forEach((c) => {
|
||||
parts.push(`- [${(c.level || "log").toUpperCase()}] ${c.msg || ""}`);
|
||||
});
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
const network = (store && store.network) || [];
|
||||
const failed = network.filter((n) => (n.status >= 400) || n.error);
|
||||
if (failed.length) {
|
||||
parts.push("## Failed Requests");
|
||||
parts.push("");
|
||||
failed.forEach((n) => {
|
||||
const status = n.error ? "ERR" : n.status;
|
||||
parts.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
|
||||
});
|
||||
parts.push("");
|
||||
} else if (network.length) {
|
||||
parts.push("## Recent Requests");
|
||||
parts.push("");
|
||||
network.slice(-15).forEach((n) => {
|
||||
parts.push(`- ${n.method || "GET"} ${n.status || "—"} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}`);
|
||||
});
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push("---");
|
||||
parts.push("_Extracted by Context Extractor_");
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Context Extractor",
|
||||
"version": "1.3.2",
|
||||
"description": "Extract clean, AI-ready context from any webpage — page content, console logs, network requests, and JS errors. Works unpacked in Chrome and Brave.",
|
||||
"permissions": ["activeTab", "scripting", "storage"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Context Extractor"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["core/dom.js", "content.js"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": false
|
||||
},
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["page-patcher.js"],
|
||||
"world": "MAIN",
|
||||
"run_at": "document_start",
|
||||
"all_frames": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// page-patcher.js — Context Extractor
|
||||
//
|
||||
// Declared in manifest.json with "world": "MAIN" (Chrome/Brave 111+), so this
|
||||
// file executes directly in the page's own JS realm — no inline <script>
|
||||
// element is created, which means it is NOT subject to the page's script-src
|
||||
// CSP the way a page-inserted inline script would be. This replaces the old
|
||||
// approach (content.js injecting a <script> tag), which silently failed to
|
||||
// capture page-script activity on strict-CSP sites.
|
||||
//
|
||||
// MAIN world scripts cannot call chrome.runtime.* directly, so results are
|
||||
// handed to the isolated-world content.js via a CustomEvent bridge.
|
||||
(() => {
|
||||
if (window.__ctxExtractorMain) return;
|
||||
window.__ctxExtractorMain = true;
|
||||
|
||||
const BRIDGE_EVENT = "__ctxExtractorEvent";
|
||||
|
||||
const SEND = (kind, payload) => {
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent(BRIDGE_EVENT, { detail: { kind, payload } }));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const safeStringify = (v) => {
|
||||
if (v === null) return "null";
|
||||
if (v === undefined) return "undefined";
|
||||
const t = typeof v;
|
||||
if (t === "string") return v;
|
||||
if (t === "number" || t === "boolean" || t === "bigint") return String(v);
|
||||
if (t === "function") return "[Function" + (v.name ? " " + v.name : "") + "]";
|
||||
if (t === "symbol") return v.toString();
|
||||
if (v instanceof Error) return v.stack || (v.name + ": " + v.message);
|
||||
try {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(v, (k, val) => {
|
||||
if (typeof val === "object" && val !== null) {
|
||||
if (seen.has(val)) return "[Circular]";
|
||||
seen.add(val);
|
||||
}
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (typeof val === "bigint") return val.toString() + "n";
|
||||
return val;
|
||||
});
|
||||
} catch (_) {
|
||||
try { return String(v); } catch (__) { return "[Unserializable]"; }
|
||||
}
|
||||
};
|
||||
|
||||
const fmtArgs = (args) => Array.from(args).map(safeStringify).join(" ");
|
||||
|
||||
// --- console ---
|
||||
const levels = ["log", "warn", "error", "info", "debug"];
|
||||
for (const lvl of levels) {
|
||||
const orig = console[lvl];
|
||||
if (typeof orig !== "function") continue;
|
||||
console[lvl] = function (...args) {
|
||||
try {
|
||||
SEND("console", { level: lvl, ts: Date.now(), msg: fmtArgs(args) });
|
||||
} catch (_) {}
|
||||
return orig.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
// --- window errors ---
|
||||
window.addEventListener("error", (e) => {
|
||||
SEND("error", {
|
||||
type: "error",
|
||||
ts: Date.now(),
|
||||
msg: (e && e.message) || "Unknown error",
|
||||
source: (e && e.filename) || "",
|
||||
line: (e && e.lineno) || 0,
|
||||
col: (e && e.colno) || 0,
|
||||
stack: (e && e.error && e.error.stack) || ""
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
let msg = "Unhandled promise rejection";
|
||||
let stack = "";
|
||||
const r = e && e.reason;
|
||||
if (r instanceof Error) { msg = r.message; stack = r.stack || ""; }
|
||||
else if (r !== undefined) { msg = safeStringify(r); }
|
||||
SEND("error", { type: "unhandledrejection", ts: Date.now(), msg, source: "", line: 0, col: 0, stack });
|
||||
});
|
||||
|
||||
// --- XHR ---
|
||||
const XHRProto = XMLHttpRequest.prototype;
|
||||
const origOpen = XHRProto.open;
|
||||
const origSend = XHRProto.send;
|
||||
XHRProto.open = function (method, url) {
|
||||
try {
|
||||
this.__ctx = { method: String(method || "GET").toUpperCase(), url: String(url || ""), start: 0 };
|
||||
} catch (_) {}
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
XHRProto.send = function () {
|
||||
try {
|
||||
if (this.__ctx) this.__ctx.start = performance.now();
|
||||
this.addEventListener("loadend", () => {
|
||||
try {
|
||||
const ctx = this.__ctx || {};
|
||||
SEND("network", {
|
||||
type: "xhr",
|
||||
method: ctx.method || "GET",
|
||||
url: ctx.url || "",
|
||||
status: this.status || 0,
|
||||
ts: Date.now(),
|
||||
duration: ctx.start ? Math.round(performance.now() - ctx.start) : 0,
|
||||
error: null
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
this.addEventListener("error", () => {
|
||||
try {
|
||||
const ctx = this.__ctx || {};
|
||||
SEND("network", {
|
||||
type: "xhr",
|
||||
method: ctx.method || "GET",
|
||||
url: ctx.url || "",
|
||||
status: 0,
|
||||
ts: Date.now(),
|
||||
duration: ctx.start ? Math.round(performance.now() - ctx.start) : 0,
|
||||
error: "Network error"
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
} catch (_) {}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// --- fetch ---
|
||||
const origFetch = window.fetch;
|
||||
if (typeof origFetch === "function") {
|
||||
window.fetch = function (input, init) {
|
||||
const start = performance.now();
|
||||
let method = "GET";
|
||||
let url = "";
|
||||
try {
|
||||
if (typeof input === "string") { url = input; }
|
||||
else if (input && typeof input.url === "string") { url = input.url; method = input.method || method; }
|
||||
if (init && init.method) method = init.method;
|
||||
method = String(method).toUpperCase();
|
||||
} catch (_) {}
|
||||
return origFetch.apply(this, arguments).then(
|
||||
(res) => {
|
||||
try {
|
||||
SEND("network", {
|
||||
type: "fetch",
|
||||
method,
|
||||
url,
|
||||
status: res.status || 0,
|
||||
ts: Date.now(),
|
||||
duration: Math.round(performance.now() - start),
|
||||
error: null
|
||||
});
|
||||
} catch (_) {}
|
||||
return res;
|
||||
},
|
||||
(err) => {
|
||||
try {
|
||||
SEND("network", {
|
||||
type: "fetch",
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
ts: Date.now(),
|
||||
duration: Math.round(performance.now() - start),
|
||||
error: (err && err.message) || String(err)
|
||||
});
|
||||
} catch (_) {}
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,283 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Context Extractor</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #171614;
|
||||
--surface: #1c1b19;
|
||||
--surface-2: #232220;
|
||||
--border: #2e2c29;
|
||||
--text: #e8e6e3;
|
||||
--muted: #8a8580;
|
||||
--primary: #4f98a3;
|
||||
--primary-hover: #5fb0bd;
|
||||
--ok: #6ecf7a;
|
||||
--warn: #e7c46a;
|
||||
--err: #e96b8a;
|
||||
--redir: #e7c46a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 440px;
|
||||
background: var(--bg); color: var(--text);
|
||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0; font-size: 13px; font-weight: 600; color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.header .sub { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.tabs {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
position: relative;
|
||||
background: transparent; color: var(--muted);
|
||||
border: 0; border-bottom: 2px solid transparent;
|
||||
padding: 9px 4px; font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--primary); }
|
||||
.tab .count {
|
||||
color: var(--muted); font-size: 11px; margin-left: 4px;
|
||||
}
|
||||
.tab .dot {
|
||||
display: none;
|
||||
position: absolute; top: 6px; right: 8px;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--err);
|
||||
}
|
||||
.tab.has-issue .dot { display: block; }
|
||||
|
||||
.panel { display: none; padding: 12px; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.row { display: flex; gap: 6px; align-items: center; }
|
||||
.row + .row { margin-top: 8px; }
|
||||
|
||||
input[type="text"] {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 6px 8px; font: 12px ui-monospace, Menlo, Consolas, monospace;
|
||||
outline: none;
|
||||
}
|
||||
input[type="text"]:focus { border-color: var(--primary); }
|
||||
|
||||
button {
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
button:hover { background: #2a2825; border-color: #3a3733; }
|
||||
button.primary {
|
||||
background: var(--primary); border-color: var(--primary); color: #0f1f22;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
button.ghost { background: transparent; }
|
||||
button.danger { color: var(--err); border-color: #3a2a2f; }
|
||||
button.danger:hover { background: #2a1f23; }
|
||||
|
||||
.actions {
|
||||
display: flex; gap: 6px; margin-top: 10px;
|
||||
padding-top: 10px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.actions .spacer { flex: 1; }
|
||||
|
||||
.preview {
|
||||
margin-top: 10px;
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 8px;
|
||||
max-height: 280px; overflow: auto;
|
||||
font: 11.5px/1.45 ui-monospace, Menlo, Consolas, monospace;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
color: #d6d3cf;
|
||||
}
|
||||
.preview.empty { color: var(--muted); }
|
||||
|
||||
.meta {
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.list {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
max-height: 320px; overflow: auto;
|
||||
}
|
||||
.list .empty {
|
||||
padding: 18px; text-align: center; color: var(--muted); font-size: 12px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 11.5px ui-monospace, Menlo, Consolas, monospace;
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
.log-row .lvl {
|
||||
flex: 0 0 44px; text-transform: uppercase;
|
||||
font-size: 10px; font-weight: 700; padding-top: 1px;
|
||||
}
|
||||
.log-row .msg {
|
||||
flex: 1; min-width: 0; word-break: break-word; white-space: pre-wrap;
|
||||
color: #d6d3cf;
|
||||
}
|
||||
.log-row.error .lvl { color: var(--err); }
|
||||
.log-row.warn .lvl { color: var(--warn); }
|
||||
.log-row.info .lvl { color: var(--primary); }
|
||||
.log-row.log .lvl { color: var(--muted); }
|
||||
.log-row.debug .lvl { color: var(--muted); }
|
||||
|
||||
.net-row {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 50px 1fr 60px;
|
||||
gap: 8px; align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 11px ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
.net-row:last-child { border-bottom: 0; }
|
||||
.net-row .method { color: var(--muted); font-weight: 700; }
|
||||
.net-row .status { font-weight: 700; }
|
||||
.net-row .url {
|
||||
color: #d6d3cf; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.net-row .dur { color: var(--muted); text-align: right; }
|
||||
.net-row.ok .status { color: var(--ok); }
|
||||
.net-row.redir .status { color: var(--redir); }
|
||||
.net-row.err .status { color: var(--err); }
|
||||
|
||||
.err-row {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 11.5px ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
.err-row:last-child { border-bottom: 0; }
|
||||
.err-row .head {
|
||||
color: var(--err); font-weight: 700; margin-bottom: 4px; word-break: break-word;
|
||||
}
|
||||
.err-row .sub { color: var(--muted); font-size: 10.5px; margin-bottom: 4px; }
|
||||
.err-row .stack {
|
||||
color: #c4c1bd; white-space: pre-wrap; word-break: break-word;
|
||||
max-height: 120px; overflow: auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary b { color: var(--text); font-weight: 600; }
|
||||
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%);
|
||||
background: #2a3a3d; color: var(--text);
|
||||
border: 1px solid var(--primary);
|
||||
padding: 6px 12px; border-radius: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 140ms ease;
|
||||
z-index: 10;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #2e2c29; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a3733; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Context Extractor</h1>
|
||||
<span class="sub" id="pageHint">—</span>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="content" role="tab">
|
||||
Content<span class="count" id="count-content"></span><span class="dot"></span>
|
||||
</button>
|
||||
<button class="tab" data-tab="console" role="tab">
|
||||
Console<span class="count" id="count-console"></span><span class="dot"></span>
|
||||
</button>
|
||||
<button class="tab" data-tab="network" role="tab">
|
||||
Network<span class="count" id="count-network"></span><span class="dot"></span>
|
||||
</button>
|
||||
<button class="tab" data-tab="errors" role="tab">
|
||||
Errors<span class="count" id="count-errors"></span><span class="dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content tab -->
|
||||
<div class="panel active" data-panel="content">
|
||||
<div class="row">
|
||||
<input type="text" id="selectorInput" placeholder="CSS selector (blank = body)" />
|
||||
<button id="pickBtn" title="Pick an element on the page">Pick</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="extractBtn" class="primary" style="flex:1">Extract content</button>
|
||||
</div>
|
||||
<div class="meta" id="extractMeta"></div>
|
||||
<div class="preview empty" id="contentPreview">No content extracted yet.</div>
|
||||
<div class="actions">
|
||||
<button id="copyContent" class="primary">Copy</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="clearContent" class="danger ghost">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console tab -->
|
||||
<div class="panel" data-panel="console">
|
||||
<div class="summary" id="consoleSummary">No console activity captured.</div>
|
||||
<div class="list" id="consoleList"></div>
|
||||
<div class="actions">
|
||||
<button id="copyConsole" class="primary">Copy</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="clearConsole" class="danger ghost">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network tab -->
|
||||
<div class="panel" data-panel="network">
|
||||
<div class="summary" id="networkSummary">No requests captured.</div>
|
||||
<div class="list" id="networkList"></div>
|
||||
<div class="actions">
|
||||
<button id="copyNetwork" class="primary">Copy</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="clearNetwork" class="danger ghost">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors tab -->
|
||||
<div class="panel" data-panel="errors">
|
||||
<div class="summary" id="errorsSummary">No JavaScript errors captured.</div>
|
||||
<div class="list" id="errorsList"></div>
|
||||
<div class="actions">
|
||||
<button id="copyErrors" class="primary">Copy</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="clearErrors" class="danger ghost">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">Copied ✓</div>
|
||||
|
||||
<script src="core/prompt.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,512 @@
|
||||
// popup.js — Context Extractor
|
||||
//
|
||||
// buildAIPrompt() comes from core/prompt.js, loaded via a <script> tag in
|
||||
// popup.html before this file, so it's already in scope here.
|
||||
|
||||
const state = {
|
||||
tabId: null,
|
||||
store: { console: [], network: [], errors: [] },
|
||||
lastExtract: null // { meta, markdown }
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messaging helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function getActiveTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab || null;
|
||||
}
|
||||
|
||||
function isRestrictedUrl(url) {
|
||||
if (!url) return true;
|
||||
return /^(chrome|brave|edge|about|devtools|chrome-extension|brave-extension|moz-extension):/i.test(url)
|
||||
|| url.startsWith("https://chrome.google.com/webstore")
|
||||
|| url.startsWith("https://chromewebstore.google.com");
|
||||
}
|
||||
|
||||
function sendRaw(message) {
|
||||
return new Promise((resolve) => {
|
||||
if (state.tabId == null) {
|
||||
return resolve({ ok: false, error: "No active tab." });
|
||||
}
|
||||
try {
|
||||
chrome.tabs.sendMessage(state.tabId, message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return resolve({
|
||||
ok: false,
|
||||
error: chrome.runtime.lastError.message || "No content script on this tab."
|
||||
});
|
||||
}
|
||||
resolve(response || { ok: false, error: "Empty response from content script." });
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: String(err && err.message || err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function injectContentScripts() {
|
||||
if (state.tabId == null) throw new Error("No active tab.");
|
||||
// Isolated world: DOM helpers + messaging/store/picker.
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: state.tabId },
|
||||
files: ["core/dom.js", "content.js"]
|
||||
});
|
||||
// MAIN world: console/fetch/XHR patches (may miss early page activity if
|
||||
// injected late, but still useful going forward).
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: state.tabId },
|
||||
files: ["page-patcher.js"],
|
||||
world: "MAIN"
|
||||
});
|
||||
} catch (_) {
|
||||
// MAIN-world inject can fail on some pages; extraction still works.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the content script. If it isn't present yet (tab was open
|
||||
* before the extension loaded/reloaded), inject it and retry once.
|
||||
*/
|
||||
async function sendToContent(message) {
|
||||
const first = await sendRaw(message);
|
||||
if (first && first.ok) return first;
|
||||
|
||||
const err = (first && first.error) || "";
|
||||
const needsInject = /Receiving end does not exist|Could not establish connection|No content script/i.test(err)
|
||||
|| first == null;
|
||||
|
||||
if (!needsInject) return first;
|
||||
|
||||
try {
|
||||
await injectContentScripts();
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Could not inject into this tab: " + String(e && e.message || e)
|
||||
};
|
||||
}
|
||||
|
||||
// Tiny pause so the listener is registered before we message again.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return sendRaw(message);
|
||||
}
|
||||
|
||||
function showExtractError(preview, meta, message) {
|
||||
preview.classList.add("empty");
|
||||
preview.textContent = message || "Could not extract content from this page.";
|
||||
meta.textContent = "";
|
||||
state.lastExtract = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((t) => {
|
||||
t.classList.toggle("active", t.dataset.tab === name);
|
||||
});
|
||||
document.querySelectorAll(".panel").forEach((p) => {
|
||||
p.classList.toggle("active", p.dataset.panel === name);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach((t) => {
|
||||
t.addEventListener("click", () => activateTab(t.dataset.tab));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast
|
||||
// ---------------------------------------------------------------------------
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const el = document.getElementById("toast");
|
||||
el.textContent = msg || "Copied ✓";
|
||||
el.classList.add("show");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove("show"), 1800);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("Copied ✓");
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); toast("Copied ✓"); }
|
||||
catch (_) { toast("Copy failed"); }
|
||||
ta.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtTime(ts) {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour12: false });
|
||||
} catch (_) { return ""; }
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (s == null) return "";
|
||||
s = String(s);
|
||||
return s.length > n ? s.slice(0, n) + "…" : s;
|
||||
}
|
||||
|
||||
function statusClass(status, error) {
|
||||
if (error || !status) return "err";
|
||||
if (status >= 400) return "err";
|
||||
if (status >= 300) return "redir";
|
||||
if (status >= 200) return "ok";
|
||||
return "err";
|
||||
}
|
||||
|
||||
function renderConsole() {
|
||||
const list = document.getElementById("consoleList");
|
||||
const summary = document.getElementById("consoleSummary");
|
||||
const entries = state.store.console || [];
|
||||
|
||||
const counts = { error: 0, warn: 0, info: 0, log: 0, debug: 0 };
|
||||
entries.forEach((e) => { if (counts[e.level] != null) counts[e.level]++; });
|
||||
summary.innerHTML = `<b>${entries.length}</b> entries · ` +
|
||||
`<span style="color:var(--err)">err ${counts.error}</span> · ` +
|
||||
`<span style="color:var(--warn)">warn ${counts.warn}</span> · ` +
|
||||
`<span style="color:var(--primary)">info ${counts.info}</span> · ` +
|
||||
`log ${counts.log} · debug ${counts.debug}`;
|
||||
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="empty">No console activity captured.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = entries.slice().reverse().map((e) => {
|
||||
const lvl = (e.level || "log").toLowerCase();
|
||||
return `<div class="log-row ${lvl}">
|
||||
<div class="lvl">${lvl}</div>
|
||||
<div class="msg">${escapeHtml(truncate(e.msg, 800))}</div>
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = rows.join("");
|
||||
}
|
||||
|
||||
function renderNetwork() {
|
||||
const list = document.getElementById("networkList");
|
||||
const summary = document.getElementById("networkSummary");
|
||||
const entries = state.store.network || [];
|
||||
|
||||
const fails = entries.filter((n) => (n.status >= 400) || n.error).length;
|
||||
summary.innerHTML = `<b>${entries.length}</b> requests · ` +
|
||||
`<span style="color:var(--err)">${fails} failed</span>`;
|
||||
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="empty">No requests captured.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = entries.slice().reverse().map((n) => {
|
||||
const cls = statusClass(n.status, n.error);
|
||||
const statusText = n.error ? "ERR" : (n.status || "—");
|
||||
return `<div class="net-row ${cls}">
|
||||
<div class="method">${escapeHtml(n.method || "")}</div>
|
||||
<div class="status">${escapeHtml(String(statusText))}</div>
|
||||
<div class="url" title="${escapeHtml(n.url || "")}">${escapeHtml(truncate(n.url, 200))}</div>
|
||||
<div class="dur">${n.duration ? n.duration + "ms" : ""}</div>
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = rows.join("");
|
||||
}
|
||||
|
||||
function renderErrors() {
|
||||
const list = document.getElementById("errorsList");
|
||||
const summary = document.getElementById("errorsSummary");
|
||||
const entries = state.store.errors || [];
|
||||
|
||||
summary.innerHTML = `<b>${entries.length}</b> JavaScript errors captured.`;
|
||||
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="empty">No JavaScript errors captured.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = entries.slice().reverse().map((e) => {
|
||||
const where = [e.source, e.line ? "line " + e.line : "", e.col ? "col " + e.col : ""]
|
||||
.filter(Boolean).join(" · ");
|
||||
return `<div class="err-row">
|
||||
<div class="head">${escapeHtml(e.type || "error")}: ${escapeHtml(truncate(e.msg, 300))}</div>
|
||||
<div class="sub">${escapeHtml(where || fmtTime(e.ts))}</div>
|
||||
${e.stack ? `<div class="stack">${escapeHtml(truncate(e.stack, 1500))}</div>` : ""}
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = rows.join("");
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
const cs = state.store.console || [];
|
||||
const ns = state.store.network || [];
|
||||
const es = state.store.errors || [];
|
||||
|
||||
document.getElementById("count-console").textContent = cs.length ? " " + cs.length : "";
|
||||
document.getElementById("count-network").textContent = ns.length ? " " + ns.length : "";
|
||||
document.getElementById("count-errors").textContent = es.length ? " " + es.length : "";
|
||||
|
||||
const consoleHasIssue = cs.some((e) => e.level === "error" || e.level === "warn");
|
||||
const networkHasIssue = ns.some((n) => (n.status >= 400) || n.error);
|
||||
const errorsHasIssue = es.length > 0;
|
||||
|
||||
setIssue("console", consoleHasIssue);
|
||||
setIssue("network", networkHasIssue);
|
||||
setIssue("errors", errorsHasIssue);
|
||||
}
|
||||
|
||||
function setIssue(tab, on) {
|
||||
const el = document.querySelector(`.tab[data-tab="${tab}"]`);
|
||||
if (el) el.classList.toggle("has-issue", !!on);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
async function refreshStore() {
|
||||
const res = await sendToContent({ type: "GET_STORE" });
|
||||
if (res && res.ok && res.store) {
|
||||
state.store = {
|
||||
console: res.store.console || [],
|
||||
network: res.store.network || [],
|
||||
errors: res.store.errors || []
|
||||
};
|
||||
} else {
|
||||
state.store = { console: [], network: [], errors: [] };
|
||||
}
|
||||
renderConsole();
|
||||
renderNetwork();
|
||||
renderErrors();
|
||||
updateBadges();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
async function doExtract() {
|
||||
const selector = document.getElementById("selectorInput").value.trim();
|
||||
const preview = document.getElementById("contentPreview");
|
||||
const meta = document.getElementById("extractMeta");
|
||||
|
||||
const tab = await getActiveTab();
|
||||
state.tabId = tab ? tab.id : null;
|
||||
if (!tab) {
|
||||
showExtractError(preview, meta, "No active tab.");
|
||||
return;
|
||||
}
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
showExtractError(
|
||||
preview,
|
||||
meta,
|
||||
"Can't run on this page (" + (tab.url || "unknown") + "). Open a normal http(s) tab and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
preview.classList.add("empty");
|
||||
preview.textContent = "Extracting…";
|
||||
|
||||
const res = await sendToContent({ type: "EXTRACT_CONTENT", selector });
|
||||
|
||||
if (!res || !res.ok) {
|
||||
showExtractError(
|
||||
preview,
|
||||
meta,
|
||||
(res && res.error)
|
||||
? "Could not extract: " + res.error + "\n\nTip: reload the extension on brave://extensions, then click Extract again (injection is automatic now)."
|
||||
: "Could not extract content from this page."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastExtract = { meta: res.meta, markdown: res.markdown || "" };
|
||||
meta.textContent = `${res.meta.selector} · ${res.markdown.length.toLocaleString()} chars · ${fmtTime(res.meta.ts)}`;
|
||||
if (res.markdown && res.markdown.length) {
|
||||
preview.classList.remove("empty");
|
||||
preview.textContent = res.markdown.length > 5000
|
||||
? res.markdown.slice(0, 5000) + "\n\n… [truncated — full content included when you click Copy]"
|
||||
: res.markdown;
|
||||
} else {
|
||||
preview.classList.add("empty");
|
||||
preview.textContent = "No content found for that selector.";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire up buttons
|
||||
// ---------------------------------------------------------------------------
|
||||
function wire() {
|
||||
document.getElementById("extractBtn").addEventListener("click", doExtract);
|
||||
|
||||
document.getElementById("pickBtn").addEventListener("click", async () => {
|
||||
const tab = await getActiveTab();
|
||||
state.tabId = tab ? tab.id : null;
|
||||
if (!tab || isRestrictedUrl(tab.url)) {
|
||||
toast("Can't pick on this page");
|
||||
return;
|
||||
}
|
||||
const res = await sendToContent({ type: "START_PICKER" });
|
||||
if (!res || !res.ok) {
|
||||
toast((res && res.error) || "Picker failed");
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
});
|
||||
|
||||
document.getElementById("copyContent").addEventListener("click", async () => {
|
||||
// Make sure we have an extraction
|
||||
if (!state.lastExtract) await doExtract();
|
||||
const ex = state.lastExtract || { meta: null, markdown: "" };
|
||||
await refreshStore(); // ensure store fresh for prompt
|
||||
const text = buildAIPrompt(ex.meta, ex.markdown, state.store);
|
||||
copyText(text);
|
||||
});
|
||||
|
||||
document.getElementById("copyConsole").addEventListener("click", async () => {
|
||||
await refreshStore();
|
||||
const tab = await getActiveTab();
|
||||
const url = tab && tab.url ? tab.url : "";
|
||||
const out = [];
|
||||
out.push("# Console Output");
|
||||
if (url) out.push(`- URL: ${url}`);
|
||||
out.push(`- Captured: ${new Date().toISOString()}`);
|
||||
out.push(`- Entries: ${state.store.console.length}`);
|
||||
out.push("");
|
||||
state.store.console.forEach((c) => {
|
||||
out.push(`[${new Date(c.ts).toISOString()}] [${(c.level || "log").toUpperCase()}] ${c.msg || ""}`);
|
||||
});
|
||||
out.push("");
|
||||
out.push("---");
|
||||
out.push("_Extracted by Context Extractor_");
|
||||
copyText(out.join("\n"));
|
||||
});
|
||||
|
||||
document.getElementById("copyNetwork").addEventListener("click", async () => {
|
||||
await refreshStore();
|
||||
const tab = await getActiveTab();
|
||||
const url = tab && tab.url ? tab.url : "";
|
||||
const out = [];
|
||||
out.push("# Network Activity");
|
||||
if (url) out.push(`- URL: ${url}`);
|
||||
out.push(`- Captured: ${new Date().toISOString()}`);
|
||||
out.push(`- Requests: ${state.store.network.length}`);
|
||||
out.push("");
|
||||
state.store.network.forEach((n) => {
|
||||
const status = n.error ? "ERR" : (n.status || "—");
|
||||
out.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`);
|
||||
});
|
||||
out.push("");
|
||||
out.push("---");
|
||||
out.push("_Extracted by Context Extractor_");
|
||||
copyText(out.join("\n"));
|
||||
});
|
||||
|
||||
document.getElementById("copyErrors").addEventListener("click", async () => {
|
||||
await refreshStore();
|
||||
const tab = await getActiveTab();
|
||||
const url = tab && tab.url ? tab.url : "";
|
||||
const out = [];
|
||||
out.push("# JavaScript Errors");
|
||||
if (url) out.push(`- URL: ${url}`);
|
||||
out.push(`- Captured: ${new Date().toISOString()}`);
|
||||
out.push(`- Errors: ${state.store.errors.length}`);
|
||||
out.push("");
|
||||
state.store.errors.forEach((e, i) => {
|
||||
const where = [e.source, e.line ? "line " + e.line : "", e.col ? "col " + e.col : ""]
|
||||
.filter(Boolean).join(" ");
|
||||
out.push(`## ${i + 1}. ${e.type || "error"}: ${e.msg || ""}`);
|
||||
if (where) out.push(`- Location: ${where}`);
|
||||
out.push(`- Time: ${new Date(e.ts).toISOString()}`);
|
||||
if (e.stack) {
|
||||
out.push("");
|
||||
out.push("```");
|
||||
out.push(e.stack);
|
||||
out.push("```");
|
||||
}
|
||||
out.push("");
|
||||
});
|
||||
out.push("---");
|
||||
out.push("_Extracted by Context Extractor_");
|
||||
copyText(out.join("\n"));
|
||||
});
|
||||
|
||||
// Clear buttons
|
||||
document.getElementById("clearContent").addEventListener("click", async () => {
|
||||
state.lastExtract = null;
|
||||
document.getElementById("contentPreview").classList.add("empty");
|
||||
document.getElementById("contentPreview").textContent = "No content extracted yet.";
|
||||
document.getElementById("extractMeta").textContent = "";
|
||||
toast("Cleared");
|
||||
});
|
||||
|
||||
document.getElementById("clearConsole").addEventListener("click", async () => {
|
||||
await sendToContent({ type: "CLEAR_STORE", which: "console" });
|
||||
await refreshStore();
|
||||
toast("Cleared");
|
||||
});
|
||||
|
||||
document.getElementById("clearNetwork").addEventListener("click", async () => {
|
||||
await sendToContent({ type: "CLEAR_STORE", which: "network" });
|
||||
await refreshStore();
|
||||
toast("Cleared");
|
||||
});
|
||||
|
||||
document.getElementById("clearErrors").addEventListener("click", async () => {
|
||||
await sendToContent({ type: "CLEAR_STORE", which: "errors" });
|
||||
await refreshStore();
|
||||
toast("Cleared");
|
||||
});
|
||||
|
||||
document.getElementById("selectorInput").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") doExtract();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
(async function init() {
|
||||
wire();
|
||||
|
||||
const tab = await getActiveTab();
|
||||
state.tabId = tab ? tab.id : null;
|
||||
|
||||
// Set page hint
|
||||
if (tab && tab.url) {
|
||||
try {
|
||||
const u = new URL(tab.url);
|
||||
document.getElementById("pageHint").textContent = u.hostname;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Pre-fill selector from session storage
|
||||
try {
|
||||
const data = await chrome.storage.session.get("pickerSelector");
|
||||
if (data && data.pickerSelector) {
|
||||
document.getElementById("selectorInput").value = data.pickerSelector;
|
||||
// Clear it after consuming so it doesn't stick forever
|
||||
await chrome.storage.session.remove("pickerSelector");
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
await refreshStore();
|
||||
})();
|
||||
Reference in New Issue
Block a user