v0.2.0-alpha: DOM capture, all three output slices, lead gates
- Extension DOM mode: auth-context serializer — inlines CSS + images as data URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client) - API: extractDataUrls rewrites large inline resources into content-addressed assets (R3 server; verified 939KB inline -> 82-byte row + served asset) - Lead-gate steps: config on step, player overlay form blocks advance, submits to leads endpoint (editor toggle + MCP set_lead_gate) - Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide - Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional narration mux; DOM rasterization deferred to Chromium service (R4) - Editor: reorder arrows, embed-code copy, guide link, video export - REST reorder endpoint; MCP surface now 13 tools (export_guide, set_lead_gate, render_video) - ffmpeg added to runtime image E2E verified incl. R3 extraction round-trip, lead_gate in public JSON, guide renders, MCP agent exported guide + rendered video.
This commit is contained in:
parent
9b09f14916
commit
a535e15253
18 changed files with 541 additions and 22 deletions
|
|
@ -1,11 +1,17 @@
|
|||
// Screenshot-flow capture: on each click in the recorded tab, grab a screenshot
|
||||
// plus the click position, building image Steps with pre-placed hotspots.
|
||||
let recording = { active: false, tabId: null, steps: [] };
|
||||
let recording = { active: false, tabId: null, mode: "screenshot", steps: [] };
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "start") {
|
||||
recording = { active: true, tabId: msg.tabId, steps: [] };
|
||||
chrome.scripting.executeScript({ target: { tabId: msg.tabId }, files: ["content.js"] });
|
||||
recording = { active: true, tabId: msg.tabId, mode: msg.mode || "screenshot", steps: [] };
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: msg.tabId },
|
||||
files: [recording.mode === "dom" ? "dom-content.js" : "content.js"],
|
||||
});
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === "domstep" && recording.active && sender.tab?.id === recording.tabId) {
|
||||
recording.steps.push({ html: msg.html, xPct: msg.xPct, yPct: msg.yPct });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === "click" && recording.active && sender.tab?.id === recording.tabId) {
|
||||
chrome.tabs.captureVisibleTab({ format: "png" }, (dataUrl) => {
|
||||
|
|
@ -17,7 +23,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
sendResponse({ active: recording.active, count: recording.steps.length });
|
||||
} else if (msg.type === "stop") {
|
||||
recording.active = false;
|
||||
sendResponse({ steps: recording.steps });
|
||||
sendResponse({ steps: recording.steps, mode: recording.mode });
|
||||
} else if (msg.type === "discard") {
|
||||
recording = { active: false, tabId: null, steps: [] };
|
||||
sendResponse({ ok: true });
|
||||
|
|
|
|||
110
extension/dom-content.js
Normal file
110
extension/dom-content.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// DOM-mode capture: on each click, serialize the live document into a
|
||||
// self-contained HTML snapshot (R3 client half — runs inside the user's auth
|
||||
// context so protected images/styles resolve). Scripts stripped; form state
|
||||
// frozen; [data-dp-mask] elements masked.
|
||||
(function () {
|
||||
if (window.__dpDomCaptureInstalled) return;
|
||||
window.__dpDomCaptureInstalled = true;
|
||||
|
||||
var busy = Promise.resolve();
|
||||
|
||||
async function toDataUrl(url) {
|
||||
try {
|
||||
var res = await fetch(url, { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
var blob = await res.blob();
|
||||
if (blob.size > 4 * 1024 * 1024) return null; // cap single asset at 4MB
|
||||
return await new Promise(function (resolve) {
|
||||
var r = new FileReader();
|
||||
r.onload = function () { resolve(r.result); };
|
||||
r.onerror = function () { resolve(null); };
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function collectCss() {
|
||||
var parts = [];
|
||||
for (var i = 0; i < document.styleSheets.length; i++) {
|
||||
var sheet = document.styleSheets[i];
|
||||
try {
|
||||
var rules = sheet.cssRules;
|
||||
var buf = [];
|
||||
for (var j = 0; j < rules.length; j++) buf.push(rules[j].cssText);
|
||||
parts.push(buf.join("\n"));
|
||||
} catch (_) {
|
||||
// cross-origin: fetch the stylesheet ourselves
|
||||
if (sheet.href) {
|
||||
try {
|
||||
var res = await fetch(sheet.href, { credentials: "include" });
|
||||
if (res.ok) parts.push(await res.text());
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
var root = document.documentElement.cloneNode(true);
|
||||
|
||||
// strip live/script-ish elements
|
||||
root.querySelectorAll("script,noscript,iframe,link[rel=stylesheet],style,object,embed").forEach(function (el) { el.remove(); });
|
||||
|
||||
// mask sensitive elements
|
||||
root.querySelectorAll("[data-dp-mask]").forEach(function (el) { el.textContent = "•••••"; });
|
||||
|
||||
// freeze form state from the LIVE dom onto the clone (index-aligned)
|
||||
var liveInputs = document.querySelectorAll("input,textarea,select");
|
||||
var cloneInputs = root.querySelectorAll("input,textarea,select");
|
||||
liveInputs.forEach(function (live, i) {
|
||||
var c = cloneInputs[i];
|
||||
if (!c) return;
|
||||
if (live.tagName === "TEXTAREA") c.textContent = live.value;
|
||||
else if (live.tagName === "SELECT") {
|
||||
Array.prototype.forEach.call(c.options, function (o, k) { if (k === live.selectedIndex) o.setAttribute("selected", ""); else o.removeAttribute("selected"); });
|
||||
} else {
|
||||
if (live.type === "checkbox" || live.type === "radio") { if (live.checked) c.setAttribute("checked", ""); else c.removeAttribute("checked"); }
|
||||
else if (live.type === "password") c.setAttribute("value", "•••••");
|
||||
else c.setAttribute("value", live.value);
|
||||
}
|
||||
});
|
||||
|
||||
// inline images from the LIVE dom (auth context), index-aligned
|
||||
var liveImgs = document.querySelectorAll("img");
|
||||
var cloneImgs = root.querySelectorAll("img");
|
||||
for (var i = 0; i < liveImgs.length; i++) {
|
||||
var src = liveImgs[i].currentSrc || liveImgs[i].src;
|
||||
if (!src || src.startsWith("data:")) continue;
|
||||
var d = await toDataUrl(src);
|
||||
if (cloneImgs[i]) {
|
||||
if (d) { cloneImgs[i].setAttribute("src", d); cloneImgs[i].removeAttribute("srcset"); }
|
||||
else cloneImgs[i].setAttribute("src", src); // fall back to absolute URL
|
||||
}
|
||||
}
|
||||
|
||||
var css = await collectCss();
|
||||
var head = root.querySelector("head") || root.insertBefore(document.createElement("head"), root.firstChild);
|
||||
var style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
head.appendChild(style);
|
||||
var base = document.createElement("base");
|
||||
base.setAttribute("href", location.origin);
|
||||
head.insertBefore(base, head.firstChild);
|
||||
|
||||
return "<!doctype html>\n" + root.outerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var xPct = (e.clientX / window.innerWidth) * 100;
|
||||
var yPct = (e.clientY / window.innerHeight) * 100;
|
||||
busy = busy.then(async function () {
|
||||
var html = await serialize();
|
||||
chrome.runtime.sendMessage({ type: "domstep", html: html, xPct: xPct, yPct: yPct });
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
})();
|
||||
|
|
@ -12,6 +12,12 @@
|
|||
<body>
|
||||
<label>Platform URL <input id="base" placeholder="https://demo.example.com"></label>
|
||||
<label>API key <input id="key" type="password" placeholder="dp_..."></label>
|
||||
<label>Capture mode
|
||||
<select id="mode" style="width:100%;margin:4px 0 8px;padding:6px 8px;border:1px solid #e2e8f0;border-radius:6px">
|
||||
<option value="screenshot">Screenshot (works everywhere)</option>
|
||||
<option value="dom">DOM (editable, web apps)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="status">Not recording.</div>
|
||||
<button id="start" class="primary">Start recording this tab</button>
|
||||
<button id="finish" hidden>Finish & upload</button>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
chrome.storage.local.get(["base", "key"], (v) => {
|
||||
chrome.storage.local.get(["base", "key", "mode"], (v) => {
|
||||
if (v.base) $("base").value = v.base;
|
||||
if (v.key) $("key").value = v.key;
|
||||
if (v.mode) $("mode").value = v.mode;
|
||||
});
|
||||
$("mode").addEventListener("change", () => chrome.storage.local.set({ mode: $("mode").value }));
|
||||
const save = () => chrome.storage.local.set({ base: $("base").value.trim(), key: $("key").value.trim() });
|
||||
$("base").addEventListener("change", save);
|
||||
$("key").addEventListener("change", save);
|
||||
|
|
@ -21,13 +23,13 @@ refresh();
|
|||
$("start").addEventListener("click", async () => {
|
||||
save();
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
chrome.runtime.sendMessage({ type: "start", tabId: tab.id }, refresh);
|
||||
chrome.runtime.sendMessage({ type: "start", tabId: tab.id, mode: $("mode").value }, refresh);
|
||||
});
|
||||
|
||||
$("discard").addEventListener("click", () => chrome.runtime.sendMessage({ type: "discard" }, refresh));
|
||||
|
||||
$("finish").addEventListener("click", () => {
|
||||
chrome.runtime.sendMessage({ type: "stop" }, async ({ steps }) => {
|
||||
chrome.runtime.sendMessage({ type: "stop" }, async ({ steps, mode }) => {
|
||||
const base = $("base").value.trim().replace(/\/$/, "");
|
||||
const key = $("key").value.trim();
|
||||
if (!base || !key) { $("status").textContent = "Set platform URL and API key first."; return; }
|
||||
|
|
@ -41,15 +43,24 @@ $("finish").addEventListener("click", () => {
|
|||
body: JSON.stringify({ name: `Capture ${new Date().toLocaleString()}` }),
|
||||
})).json();
|
||||
for (const s of steps) {
|
||||
const blob = await (await fetch(s.dataUrl)).blob();
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "step.png");
|
||||
const asset = await (await fetch(`${base}/api/assets`, { method: "POST", headers: auth, body: fd })).json();
|
||||
const step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot_type: "image", asset_id: asset.id }),
|
||||
})).json();
|
||||
let step;
|
||||
if (mode === "dom") {
|
||||
step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot_type: "dom", dom_html: s.html }),
|
||||
})).json();
|
||||
} else {
|
||||
const blob = await (await fetch(s.dataUrl)).blob();
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "step.png");
|
||||
const asset = await (await fetch(`${base}/api/assets`, { method: "POST", headers: auth, body: fd })).json();
|
||||
step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot_type: "image", asset_id: asset.id }),
|
||||
})).json();
|
||||
}
|
||||
// pre-place a hotspot centred on the recorded click
|
||||
await fetch(`${base}/api/steps/${step.id}`, {
|
||||
method: "PATCH",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue