Initial Context Extractor: extension + Playwright/Camoufox package
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:
2026-07-15 14:52:22 -04:00
commit c91cd221d1
30 changed files with 2611 additions and 0 deletions
+18
View File
@@ -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;
});
+219
View File
@@ -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
});
})();
+186
View File
@@ -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();
}
+99
View File
@@ -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");
}
+30
View File
@@ -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
}
]
}
+176
View File
@@ -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;
}
);
};
}
})();
+283
View File
@@ -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>
+512
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ---------------------------------------------------------------------------
// 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();
})();