Initial commit
This commit is contained in:
375
skills/browser-tools/scripts/element.js
Executable file
375
skills/browser-tools/scripts/element.js
Executable file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
createLogger,
|
||||
fail,
|
||||
getActivePage,
|
||||
parseArgs,
|
||||
printJSON,
|
||||
resolveBrowserConnection,
|
||||
normalizeNumber,
|
||||
} from "./config.js";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2), {
|
||||
boolean: ["click", "scroll", "json", "quiet"],
|
||||
string: ["text", "ws", "host"],
|
||||
number: ["port", "timeout"],
|
||||
alias: {
|
||||
j: "json",
|
||||
q: "quiet",
|
||||
},
|
||||
defaults: {
|
||||
port: DEFAULT_PORT,
|
||||
timeout: 15000,
|
||||
},
|
||||
});
|
||||
|
||||
const jsonOutput = Boolean(args.json);
|
||||
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
|
||||
|
||||
const selector = args._[0] ?? null;
|
||||
const textSearch = args.text ?? null;
|
||||
const timeout = normalizeNumber(args.timeout, 15000);
|
||||
const port = normalizeNumber(args.port, DEFAULT_PORT);
|
||||
|
||||
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.connect({
|
||||
...connectionOptions,
|
||||
timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await getActivePage(browser, { index: -1 });
|
||||
if (!page) {
|
||||
fail("No active page found. Navigate first.", { json: jsonOutput });
|
||||
}
|
||||
|
||||
const elementResult = await resolveElement(page, { selector, textSearch, timeout });
|
||||
if (!elementResult || !elementResult.handle) {
|
||||
const message = selector
|
||||
? `Selector not found: ${selector}`
|
||||
: textSearch
|
||||
? `No element found containing text: ${textSearch}`
|
||||
: "No element selected.";
|
||||
fail(message, { json: jsonOutput });
|
||||
}
|
||||
|
||||
const { handle, info } = elementResult;
|
||||
|
||||
if (args.scroll) {
|
||||
await handle.evaluate((el) => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
||||
});
|
||||
logger.info("↕️ Element scrolled into view");
|
||||
}
|
||||
|
||||
if (args.click) {
|
||||
try {
|
||||
await handle.click({ delay: 20 });
|
||||
logger.info("🖱️ Element clicked");
|
||||
} catch (error) {
|
||||
fail(`Click failed: ${error.message}`, { json: jsonOutput });
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
ok: true,
|
||||
selector: info.selector,
|
||||
tag: info.tag,
|
||||
id: info.id,
|
||||
classes: info.classes,
|
||||
text: info.text,
|
||||
attributes: info.attributes,
|
||||
rect: info.rect,
|
||||
visible: info.visible,
|
||||
children: info.children,
|
||||
};
|
||||
|
||||
if (jsonOutput) {
|
||||
printJSON(output);
|
||||
} else {
|
||||
logger.info(`✅ Element ${info.selector}`);
|
||||
process.stdout.write(formatHumanOutput(output));
|
||||
}
|
||||
|
||||
await handle.dispose();
|
||||
} catch (error) {
|
||||
fail(`Element lookup failed: ${error.message}`, { json: jsonOutput });
|
||||
} finally {
|
||||
if (browser) await browser.disconnect();
|
||||
}
|
||||
|
||||
async function resolveElement(page, { selector: rawSelector, textSearch: rawText, timeout: timeoutMs }) {
|
||||
if (rawSelector) {
|
||||
const handle = await page.$(rawSelector);
|
||||
if (!handle) return null;
|
||||
const info = await collectElementInfo(page, handle, rawSelector);
|
||||
return { handle, info };
|
||||
}
|
||||
|
||||
if (rawText) {
|
||||
const handle = await findByText(page, rawText);
|
||||
if (!handle) return null;
|
||||
const info = await collectElementInfo(page, handle);
|
||||
return { handle, info };
|
||||
}
|
||||
|
||||
return await pickElement(page, timeoutMs);
|
||||
}
|
||||
|
||||
async function collectElementInfo(page, handle, selectorOverride) {
|
||||
return await page.evaluate((el, selectorHint) => {
|
||||
const escapeIdent = (value) => {
|
||||
if (window.CSS && typeof window.CSS.escape === "function") {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`);
|
||||
};
|
||||
|
||||
const toSelector = (element) => {
|
||||
const parts = [];
|
||||
let current = element;
|
||||
while (current && current.nodeType === 1) {
|
||||
let part = current.nodeName.toLowerCase();
|
||||
if (current.id) {
|
||||
part = `#${escapeIdent(current.id)}`;
|
||||
parts.unshift(part);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.classList.length > 0) {
|
||||
part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`;
|
||||
}
|
||||
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName);
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1;
|
||||
part += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
parts.unshift(part);
|
||||
current = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const attributes = Object.fromEntries(
|
||||
el.getAttributeNames().map((name) => [name, el.getAttribute(name)]),
|
||||
);
|
||||
|
||||
const textContent = el.innerText ?? el.textContent ?? "";
|
||||
|
||||
return {
|
||||
selector: selectorHint ?? toSelector(el),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
classes: el.classList.length ? Array.from(el.classList) : [],
|
||||
text: textContent.trim().slice(0, 160),
|
||||
attributes,
|
||||
rect: {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
visible: Boolean(el.offsetParent),
|
||||
children: el.children.length,
|
||||
};
|
||||
}, handle, selectorOverride ?? null);
|
||||
}
|
||||
|
||||
async function findByText(page, text) {
|
||||
const escaped = text.replace(/"/g, '\\"');
|
||||
const handles = await page.$x(`//*[contains(normalize-space(text()), "${escaped}")]`);
|
||||
if (!handles || handles.length === 0) return null;
|
||||
const [first, ...rest] = handles;
|
||||
await Promise.all(rest.map((handle) => handle.dispose()));
|
||||
return first;
|
||||
}
|
||||
|
||||
async function pickElement(page, timeoutMs) {
|
||||
const result = await page.evaluate(async (timeout) => {
|
||||
const originalCursor = document.body?.style?.cursor ?? "";
|
||||
const state = {
|
||||
highlight: null,
|
||||
previousOutline: null,
|
||||
resolved: false,
|
||||
};
|
||||
let timerId = null;
|
||||
|
||||
const escapeIdent = (value) => {
|
||||
if (window.CSS && typeof window.CSS.escape === "function") {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (state.highlight) {
|
||||
state.highlight.style.outline = state.previousOutline ?? "";
|
||||
}
|
||||
document.body.style.cursor = originalCursor;
|
||||
document.removeEventListener("mouseover", onHover, true);
|
||||
document.removeEventListener("click", onClick, true);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const highlight = (element) => {
|
||||
if (state.highlight === element) return;
|
||||
if (state.highlight) {
|
||||
state.highlight.style.outline = state.previousOutline ?? "";
|
||||
}
|
||||
state.highlight = element;
|
||||
state.previousOutline = element.style.outline;
|
||||
element.style.outline = "3px solid #ff4444";
|
||||
};
|
||||
|
||||
const buildInfo = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
text: (element.innerText ?? element.textContent ?? "").trim().slice(0, 160),
|
||||
rect: {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toSelector = (element) => {
|
||||
const parts = [];
|
||||
let current = element;
|
||||
while (current && current.nodeType === 1) {
|
||||
let part = current.nodeName.toLowerCase();
|
||||
if (current.id) {
|
||||
part = `#${escapeIdent(current.id)}`;
|
||||
parts.unshift(part);
|
||||
break;
|
||||
}
|
||||
if (current.classList.length > 0) {
|
||||
part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`;
|
||||
}
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName);
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1;
|
||||
part += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
parts.unshift(part);
|
||||
current = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
|
||||
const resolveSelection = (element) => {
|
||||
if (!element) return null;
|
||||
const info = buildInfo(element);
|
||||
const selector = toSelector(element);
|
||||
window.__BT_PICKED_ELEMENT = element;
|
||||
return { ...info, selector };
|
||||
};
|
||||
|
||||
const onHover = (event) => {
|
||||
if (state.resolved) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
highlight(target);
|
||||
};
|
||||
|
||||
const onClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.resolved) return;
|
||||
state.resolved = true;
|
||||
cleanup();
|
||||
resolve(resolveSelection(event.target));
|
||||
};
|
||||
|
||||
document.body.style.cursor = "crosshair";
|
||||
document.addEventListener("mouseover", onHover, true);
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
timerId = timeout
|
||||
? setTimeout(() => {
|
||||
if (state.resolved) return;
|
||||
state.resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, timeout)
|
||||
: null;
|
||||
|
||||
window.addEventListener(
|
||||
"blur",
|
||||
() => {
|
||||
if (state.resolved) return;
|
||||
state.resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
window.__BT_PICKER_CANCEL = () => {
|
||||
if (state.resolved) return;
|
||||
state.resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}, timeoutMs || 60000);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const handle = await page.evaluateHandle(() => {
|
||||
const element = window.__BT_PICKED_ELEMENT ?? null;
|
||||
delete window.__BT_PICKED_ELEMENT;
|
||||
delete window.__BT_PICKER_CANCEL;
|
||||
return element;
|
||||
});
|
||||
|
||||
const element = handle.asElement();
|
||||
if (!element) {
|
||||
await handle.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = await collectElementInfo(page, element, result.selector);
|
||||
return { handle: element, info };
|
||||
}
|
||||
|
||||
function formatHumanOutput(info) {
|
||||
const lines = [];
|
||||
lines.push(`selector: ${info.selector}`);
|
||||
if (info.tag) lines.push(`tag: ${info.tag}`);
|
||||
if (info.id) lines.push(`id: ${info.id}`);
|
||||
if (info.classes?.length) lines.push(`classes: ${info.classes.join(" ")}`);
|
||||
if (info.text) lines.push(`text: ${info.text}`);
|
||||
lines.push(`visible: ${info.visible}`);
|
||||
lines.push(`children: ${info.children}`);
|
||||
if (info.rect) {
|
||||
lines.push(`position: (${info.rect.x}, ${info.rect.y})`);
|
||||
lines.push(`size: ${info.rect.width}x${info.rect.height}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user