UNPKG

camoufox-mcp-server

Version:

MCP server for browser automation using Camoufox - a privacy-focused Firefox fork with advanced anti-detection features

185 lines (184 loc) 7.14 kB
import { describeError, redactUrl, truncateString } from "../utils.js"; import { extractPageContent } from "./content.js"; export async function extractSnapshotElements(page, maxElements, selector) { return page.evaluate(({ maxItems, cssSelector }) => { const root = cssSelector ? document.querySelector(cssSelector) : document.body ?? document.documentElement; if (!root) { return { elements: [], truncated: false, found: false }; } function textOf(element) { return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300); } function cssIdent(value) { if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { return CSS.escape(value); } return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function selectorFor(element) { if (element.id) { return `#${cssIdent(element.id)}`; } const path = []; let current = element; while (current && current !== document.documentElement && path.length < 8) { let part = current.tagName.toLowerCase(); if (current.classList.length > 0) { part += `.${Array.from(current.classList).slice(0, 2).map(cssIdent).join(".")}`; } const currentTagName = current.tagName; const parent = current.parentElement; if (parent) { const sameTagSiblings = Array.from(parent.children).filter((child) => child.tagName === currentTagName); if (sameTagSiblings.length > 1) { part += `:nth-of-type(${sameTagSiblings.indexOf(current) + 1})`; } } path.unshift(part); current = parent; } return path.join(" > "); } function inferredRole(element) { const explicit = element.getAttribute("role"); if (explicit) { return explicit; } const tagName = element.tagName.toLowerCase(); if (tagName === "a" && element.hasAttribute("href")) { return "link"; } if (tagName === "button") { return "button"; } if (tagName === "select") { return "combobox"; } if (tagName === "textarea") { return "textbox"; } if (tagName === "input") { const type = (element.getAttribute("type") ?? "text").toLowerCase(); if (["button", "submit", "reset"].includes(type)) { return "button"; } if (type === "checkbox") { return "checkbox"; } if (type === "radio") { return "radio"; } return "textbox"; } return undefined; } function accessibleName(element) { const direct = element.getAttribute("aria-label") ?? element.getAttribute("alt") ?? element.getAttribute("title") ?? element.getAttribute("placeholder"); if (direct?.trim()) { return direct.trim().slice(0, 300); } const labelledBy = element.getAttribute("aria-labelledby"); if (labelledBy) { const text = labelledBy .split(/\s+/) .map((id) => document.getElementById(id)?.textContent ?? "") .join(" ") .replace(/\s+/g, " ") .trim(); if (text) { return text.slice(0, 300); } } return textOf(element) || undefined; } function isVisible(element) { const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return false; } const style = window.getComputedStyle(element); return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; } const candidateSelector = [ "a[href]", "button", "input", "select", "textarea", "[role]", "[tabindex]:not([tabindex='-1'])", "[contenteditable='true']", ].join(","); const candidates = [ ...(root.matches(candidateSelector) ? [root] : []), ...Array.from(root.querySelectorAll(candidateSelector)), ]; const elements = []; let truncated = false; for (const element of candidates) { if (!isVisible(element)) { continue; } if (elements.length >= maxItems) { truncated = true; break; } const rect = element.getBoundingClientRect(); elements.push({ tag: element.tagName.toLowerCase(), selector: selectorFor(element), role: inferredRole(element), name: accessibleName(element), text: textOf(element) || undefined, bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), }, }); } return { elements, truncated, found: true, }; }, { maxItems: maxElements, cssSelector: selector }); } export async function buildSnapshotPayload(page, response, maxChars, maxElements, selector) { const text = await extractPageContent(page, "text", maxChars, selector); const elementSnapshot = await extractSnapshotElements(page, maxElements, selector); const payload = { url: redactUrl(page.url()), title: await page.title(), status: response?.status(), contentType: response?.headers()["content-type"], selector, selectorFound: text.found && elementSnapshot.found, maxChars, maxElements, text: text.value, textTruncated: text.truncated, elements: elementSnapshot.elements, elementsTruncated: elementSnapshot.truncated, }; if (!payload.selectorFound) { return payload; } try { const target = selector ? page.locator(selector).first() : page.locator("body").first(); const aria = await target.ariaSnapshot({ timeout: 3000 }); const truncated = truncateString(aria, maxChars); payload.ariaSnapshot = truncated.value; payload.ariaSnapshotTruncated = truncated.truncated; } catch (snapshotError) { payload.ariaSnapshotError = describeError(snapshotError); } return payload; }