supapup
Version:
ā” Lightning-fast MCP browser dev tool. Navigate ā Get instant structured data. No screenshots needed! Puppeteer: šø ā CSS selectors ā JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.
912 lines (911 loc) ⢠46.5 kB
JavaScript
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { DevToolsAgentPageGenerator } from './../generators/devtools-agent-page-generator.js';
import { randomUUID } from 'crypto';
/**
* Environment Variables for Browser Configuration:
*
* SUPAPUP_HEADLESS - Set to 'true' for headless mode (default: false - shows browser window)
* SUPAPUP_DEBUG_PORT - Chrome remote debugging port (default: 9222)
* SUPAPUP_DEVTOOLS - Set to 'true' to open DevTools (default: false)
*
* Example MCP config for headless server:
* {
* "mcpServers": {
* "supapup": {
* "command": "supapup",
* "env": {
* "SUPAPUP_HEADLESS": "true"
* }
* }
* }
* }
*/
// Apply stealth plugin
puppeteer.use(StealthPlugin());
export class BrowserTools {
browser = null;
page = null;
browserRecovery;
currentManifest = null;
forcedHeadless = null; // null = use env var, true/false = override
agentPageChunkData = new Map();
constructor(browserRecovery) {
this.browserRecovery = browserRecovery;
}
// Getters for external access
getBrowser() {
return this.browser;
}
getPage() {
return this.page;
}
getCurrentManifest() {
return this.currentManifest;
}
setCurrentManifest(manifest) {
this.currentManifest = manifest;
}
// Called by main server after browser crash
clearBrowserReferences() {
this.browser = null;
this.page = null;
this.currentManifest = null;
}
async navigate(url, visible) {
try {
// Set visibility if provided
if (visible !== undefined) {
this.forcedHeadless = !visible;
}
// Launch browser if needed
if (!this.browser) {
// Environment variable configuration
const headless = this.forcedHeadless !== null ? this.forcedHeadless : process.env.SUPAPUP_HEADLESS === 'true'; // Default false (visible browser)
const debugPort = process.env.SUPAPUP_DEBUG_PORT || '9222';
const devtools = process.env.SUPAPUP_DEVTOOLS === 'true';
console.log(`š Launching browser (headless: ${headless})...`);
// Base arguments for all environments
const baseArgs = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
`--remote-debugging-port=${debugPort}`
];
// Additional arguments for headless environments
const headlessArgs = [
'--no-zygote',
'--single-process',
'--disable-gpu',
'--disable-dev-shm-usage'
];
// Arguments for non-headless (development) environments
const devArgs = [
'--start-maximized'
];
this.browser = await puppeteer.launch({
headless,
defaultViewport: null,
devtools,
args: [
...baseArgs,
...(headless ? headlessArgs : devArgs)
]
});
}
// Create page if needed
if (!this.page) {
// Check if there's an existing blank page we can reuse
const pages = await this.browser.pages();
const blankPage = pages.find(page => page.url() === 'about:blank' || page.url() === '');
if (blankPage) {
this.page = blankPage;
}
else {
this.page = await this.browser.newPage();
}
await this.page.setViewport({ width: 1920, height: 1080 });
// Set user agent
await this.page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
// Inject dialog handling script on every new document
await this.page.evaluateOnNewDocument(() => {
// Only inject if not already present
if (typeof window.__MCP_DIALOGS_INSTALLED__ === 'undefined') {
window.__MCP_DIALOGS_INSTALLED__ = true;
// Helper functions for agents
window.click_alert = function (index) {
const alerts = document.querySelectorAll('[data-mcp-type="alert"]');
if (alerts[index - 1]) {
const okBtn = alerts[index - 1].querySelector('[data-mcp-id="alert-ok"]');
if (okBtn) {
okBtn.click();
return true;
}
}
return false;
};
window.fill_prompt = function (index, value) {
const prompts = document.querySelectorAll('[data-mcp-type="prompt"]');
if (prompts[index - 1]) {
const input = prompts[index - 1].querySelector('[data-mcp-id="prompt-input"]');
if (input) {
// Use React-compatible value setting
if (typeof window.__AGENT_PAGE__?.triggerReactChange === 'function') {
window.__AGENT_PAGE__.triggerReactChange(input, value);
}
else {
// Fallback for non-React forms
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
input.style.background = '#ffffaa';
return true;
}
}
return false;
};
window.click_prompt = function (index, accept) {
const prompts = document.querySelectorAll('[data-mcp-type="prompt"]');
if (prompts[index - 1]) {
const btn = accept ?
prompts[index - 1].querySelector('[data-mcp-id="prompt-ok"]') :
prompts[index - 1].querySelector('[data-mcp-id="prompt-cancel"]');
if (btn) {
btn.click();
return true;
}
}
return false;
};
window.click_confirm = function (index, accept) {
const confirms = document.querySelectorAll('[data-mcp-type="confirm"]');
if (confirms[index - 1]) {
const btn = accept ?
confirms[index - 1].querySelector('[data-mcp-id="confirm-ok"]') :
confirms[index - 1].querySelector('[data-mcp-id="confirm-cancel"]');
if (btn) {
btn.click();
return true;
}
}
return false;
};
window.list_dialogs = function () {
const alerts = document.querySelectorAll('[data-mcp-type="alert"]');
const prompts = document.querySelectorAll('[data-mcp-type="prompt"]');
const confirms = document.querySelectorAll('[data-mcp-type="confirm"]');
return { alerts: alerts.length, prompts: prompts.length, confirms: confirms.length };
};
// Enhanced dialog overrides
const originalAlert = window.alert;
const originalConfirm = window.confirm;
const originalPrompt = window.prompt;
window.alert = function (message) {
console.log('[MCP] Alert intercepted:', message);
const alertNum = document.querySelectorAll('[data-mcp-type="alert"]').length + 1;
const alertDiv = document.createElement('div');
alertDiv.id = 'mcp-alert-' + Date.now();
alertDiv.setAttribute('data-mcp-id', 'alert-dialog-' + alertNum);
alertDiv.setAttribute('data-mcp-type', 'alert');
alertDiv.style.cssText = 'position:fixed;top:20px;right:20px;background:#ff4444;color:white;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:300px;font-family:monospace;';
alertDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;">šØ Alert #' + alertNum + '</div><div style="margin-bottom:10px;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.8;">Agent: Use click_alert(' + alertNum + ') to dismiss</div><button data-mcp-id="alert-ok" onclick="this.parentElement.remove();" style="background:white;color:#ff4444;border:none;padding:5px 10px;border-radius:3px;">OK</button>';
document.body.appendChild(alertDiv);
console.log('[MCP] Alert helper: click_alert(' + alertNum + ')');
return undefined;
};
window.prompt = function (message, defaultValue) {
console.log('[MCP] Prompt intercepted:', message);
const promptNum = document.querySelectorAll('[data-mcp-type="prompt"]').length + 1;
const promptDiv = document.createElement('div');
promptDiv.id = 'mcp-prompt-' + Date.now();
promptDiv.setAttribute('data-mcp-id', 'prompt-dialog-' + promptNum);
promptDiv.setAttribute('data-mcp-type', 'prompt');
promptDiv.style.cssText = 'position:fixed;top:20px;left:20px;background:#44ff44;color:black;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:350px;font-family:monospace;';
promptDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;color:black;">š Prompt #' + promptNum + '</div><div style="margin-bottom:10px;color:black;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.7;color:black;">Agent: fill_prompt(' + promptNum + ', "text") then click_prompt(' + promptNum + ', true)</div><input type="text" data-mcp-id="prompt-input" value="' + (defaultValue || '') + '" style="width:250px;padding:5px;margin-bottom:10px;border:1px solid #ccc;border-radius:3px;"><br><button data-mcp-id="prompt-ok" onclick="this.parentElement.remove();" style="background:#44ff44;color:white;border:none;padding:5px 10px;border-radius:3px;margin-right:5px;">OK</button><button data-mcp-id="prompt-cancel" onclick="this.parentElement.remove();" style="background:#ff4444;color:white;border:none;padding:5px 10px;border-radius:3px;">Cancel</button>';
document.body.appendChild(promptDiv);
console.log('[MCP] Prompt helpers: fill_prompt(' + promptNum + ', "text"), click_prompt(' + promptNum + ', true/false)');
return null;
};
window.confirm = function (message) {
console.log('[MCP] Confirm intercepted:', message);
const confirmNum = document.querySelectorAll('[data-mcp-type="confirm"]').length + 1;
const confirmDiv = document.createElement('div');
confirmDiv.id = 'mcp-confirm-' + Date.now();
confirmDiv.setAttribute('data-mcp-id', 'confirm-dialog-' + confirmNum);
confirmDiv.setAttribute('data-mcp-type', 'confirm');
confirmDiv.style.cssText = 'position:fixed;top:80px;right:20px;background:#4444ff;color:white;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:300px;font-family:monospace;';
confirmDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;">ā Confirm #' + confirmNum + '</div><div style="margin-bottom:10px;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.8;">Agent: click_confirm(' + confirmNum + ', true) or click_confirm(' + confirmNum + ', false)</div><button data-mcp-id="confirm-ok" onclick="this.parentElement.remove();" style="background:white;color:#4444ff;border:none;padding:5px 10px;border-radius:3px;margin-right:5px;">OK</button><button data-mcp-id="confirm-cancel" onclick="this.parentElement.remove();" style="background:white;color:#4444ff;border:none;padding:5px 10px;border-radius:3px;">Cancel</button>';
document.body.appendChild(confirmDiv);
console.log('[MCP] Confirm helper: click_confirm(' + confirmNum + ', true/false)');
return false;
};
console.log('[MCP] Enhanced dialog overrides with helper functions installed');
}
});
}
console.log(`š Navigating to: ${url}`);
// Determine redirect limit based on URL
const isLocalhost = url.includes('localhost') || url.includes('127.0.0.1') || url.includes('0.0.0.0');
const maxRedirects = isLocalhost ? 30 : 15;
// Navigate with redirect tracking
let redirectCount = 0;
let currentUrl = url;
const startTime = Date.now();
try {
const response = await this.page.goto(currentUrl, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Track redirects
while (this.page.url() !== currentUrl && redirectCount < maxRedirects) {
redirectCount++;
currentUrl = this.page.url();
console.log(`āŖļø Redirect ${redirectCount}: ${currentUrl}`);
if (redirectCount >= maxRedirects) {
throw new Error(`Too many redirects (${redirectCount}). Possible infinite redirect attack or complex SPA flow. URL: ${currentUrl}`);
}
}
const finalUrl = this.page.url();
console.log(`ā
Navigation completed. Final URL: ${finalUrl}`);
console.log(`š Redirects: ${redirectCount}`);
console.log(`ā±ļø Time: ${Date.now() - startTime}ms`);
// Wait for JavaScript to complete and page to settle
await new Promise(resolve => setTimeout(resolve, 2000));
// Additional check for common dynamic content patterns
try {
// Wait for common loading indicators to disappear
await this.page.waitForFunction(() => {
const loadingIndicators = document.querySelectorAll('.loading, .spinner, [data-loading="true"]');
return loadingIndicators.length === 0;
}, { timeout: 5000 });
}
catch (e) {
// If loading indicators don't disappear in 5 seconds, continue anyway
console.log('ā ļø Some loading indicators may still be present, continuing...');
}
// Check for bot detection and CAPTCHAs
const botCheckResult = await this.checkForBotDetection();
if (botCheckResult.hasIssues) {
return this.generateCaptchaResponse(finalUrl, botCheckResult);
}
// Use DevToolsAgentPageGenerator directly
const agentPage = await this.generateAgentPage();
return {
content: [{ type: 'text', text: agentPage }]
};
}
catch (error) {
console.error('ā Navigation error:', error.message);
if (error.message.includes('Too many redirects')) {
throw error; // Re-throw redirect errors
}
// Handle other navigation errors
return {
content: [{
type: 'text',
text: `ā Navigation failed: ${error.message}\nš URL: ${url}`
}]
};
}
}
catch (error) {
console.error('ā Browser navigation error:', error);
return {
content: [{
type: 'text',
text: `ā Navigation failed: ${error.message}\nš URL: ${url}`
}]
};
}
}
async closeBrowser() {
try {
if (this.browser) {
console.log('š Closing browser...');
await this.browser.close();
this.browser = null;
this.page = null;
this.currentManifest = null;
console.log('ā
Browser closed successfully');
return {
content: [{ type: 'text', text: 'ā
Browser closed successfully' }]
};
}
else {
return {
content: [{ type: 'text', text: 'ā ļø No browser instance to close' }]
};
}
}
catch (error) {
console.error('ā Error closing browser:', error);
return {
content: [{ type: 'text', text: `ā Error closing browser: ${error.message}` }]
};
}
}
async setBrowserVisibility(visible, restart = true) {
try {
const currentState = this.forcedHeadless !== null ? !this.forcedHeadless : process.env.SUPAPUP_HEADLESS !== 'true';
const newHeadless = !visible;
// Set the forced headless state
this.forcedHeadless = newHeadless;
let responseText = '';
if (currentState === visible) {
responseText = `š§ Browser visibility already set to ${visible ? 'visible' : 'headless'} mode`;
}
else {
responseText = `š§ Browser visibility changed from ${currentState ? 'visible' : 'headless'} to ${visible ? 'visible' : 'headless'} mode`;
}
if (restart && this.browser) {
responseText += '\n\nš Restarting browser to apply changes...';
// Close current browser
await this.browser.close();
this.browser = null;
this.page = null;
this.currentManifest = null;
responseText += '\nā
Browser will restart with new visibility settings on next navigation';
}
else if (this.browser) {
responseText += '\n\nā ļø Browser is currently running. Use restart=true or navigate to a new page to apply changes.';
}
else {
responseText += '\n\nā
Settings will be applied when browser launches on next navigation';
}
return {
content: [{
type: 'text',
text: responseText
}]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `ā Error setting browser visibility: ${error.message}` }]
};
}
}
async openInTab(content, contentType = 'text/html', title) {
try {
if (!this.browser) {
throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.');
}
// Create a new page
const newPage = await this.browser.newPage();
// Set title if provided
if (title) {
await newPage.evaluateOnNewDocument((title) => {
document.title = title;
}, title);
}
// Handle different content types
if (contentType.startsWith('text/html')) {
await newPage.setContent(content, { waitUntil: 'domcontentloaded' });
}
else if (contentType.startsWith('text/')) {
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>${title || 'Content'}</title>
<style>
body { font-family: monospace; white-space: pre-wrap; padding: 20px; }
</style>
</head>
<body>${content}</body>
</html>
`;
await newPage.setContent(htmlContent, { waitUntil: 'domcontentloaded' });
}
else {
// For other content types, create a data URL
const dataUrl = `data:${contentType};base64,${Buffer.from(content).toString('base64')}`;
await newPage.goto(dataUrl);
}
const url = newPage.url();
console.log(`ā
Opened content in new tab: ${url}`);
return {
content: [{
type: 'text',
text: `ā
Opened content in new tab\nš URL: ${url}\nš Content type: ${contentType}`
}]
};
}
catch (error) {
console.error('ā Error opening content in tab:', error);
return {
content: [{ type: 'text', text: `ā Error opening content in tab: ${error.message}` }]
};
}
}
async listTabs() {
try {
if (!this.browser) {
throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.');
}
const pages = await this.browser.pages();
const tabList = await Promise.all(pages.map(async (page, index) => {
try {
const title = await page.title();
const url = page.url();
return `${index}: "${title}" - ${url}`;
}
catch (error) {
return `${index}: [Error getting tab info] - ${error}`;
}
}));
const response = `š Open browser tabs (${tabList.length}):\n${tabList.join('\n')}`;
return {
content: [{ type: 'text', text: response }]
};
}
catch (error) {
console.error('ā Error listing tabs:', error);
return {
content: [{ type: 'text', text: `ā Error listing tabs: ${error.message}` }]
};
}
}
async switchTab(index) {
try {
if (!this.browser) {
throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.');
}
const pages = await this.browser.pages();
if (index < 0 || index >= pages.length) {
throw new Error(`Invalid tab index: ${index}. Available tabs: 0-${pages.length - 1}. Use browser_list_tabs to see all open tabs and their indices.`);
}
const targetPage = pages[index];
await targetPage.bringToFront();
// Update current page reference
this.page = targetPage;
// Get tab info
const title = await targetPage.title();
const url = targetPage.url();
console.log(`ā
Switched to tab ${index}: "${title}"`);
return {
content: [{
type: 'text',
text: `ā
Switched to tab ${index}\nš "${title}"\nš ${url}`
}]
};
}
catch (error) {
console.error('ā Error switching tabs:', error);
return {
content: [{ type: 'text', text: `ā Error switching tabs: ${error.message}` }]
};
}
}
// Private helper methods
async checkForBotDetection() {
if (!this.page)
return { hasIssues: false };
try {
const result = await this.page.evaluate(() => {
// Check for visible CAPTCHAs only (not just presence of scripts)
const captchaSelectors = [
'.g-recaptcha:not([style*="display: none"]):not([style*="display:none"])',
'.h-captcha:not([style*="display: none"]):not([style*="display:none"])',
'.cf-turnstile:not([style*="display: none"]):not([style*="display:none"])',
'iframe[src*="recaptcha"]:not([style*="display: none"]):not([style*="display:none"])',
'iframe[src*="hcaptcha"]:not([style*="display: none"]):not([style*="display:none"])',
'iframe[src*="turnstile"]:not([style*="display: none"]):not([style*="display:none"])'
];
let visibleCaptchas = 0;
captchaSelectors.forEach(selector => {
const elements = document.querySelectorAll(selector);
elements.forEach(el => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
if (rect.width > 0 && rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0') {
visibleCaptchas++;
}
});
});
// Check for blocking text patterns
const bodyText = document.body.textContent || '';
const blockingPatterns = [
/please complete.*captcha/i,
/verify.*you.*human/i,
/prove.*you.*human/i,
/security check/i,
/access denied/i,
/blocked.*suspicious/i
];
const hasBlockingText = blockingPatterns.some(pattern => pattern.test(bodyText));
return {
visibleCaptchas,
hasBlockingText,
hasIssues: visibleCaptchas > 0 || hasBlockingText
};
});
return result;
}
catch (error) {
console.error('ā Bot detection check failed:', error);
return { hasIssues: false };
}
}
generateCaptchaResponse(url, botCheckResult) {
const text = `š¤ Whoops! Seems like I am a robot... Any humans around?\n` +
`ā ļø CAPTCHA/verification detected - need human help!\n\n` +
`š Current URL: ${url}\n\n` +
`š Detection details:\n` +
` ⢠Visible CAPTCHAs: ${botCheckResult.visibleCaptchas || 0}\n` +
` ⢠Blocking text: ${botCheckResult.hasBlockingText ? 'Yes' : 'No'}\n\n` +
`š¤ Hey human! Could you please:\n` +
` 1. Go to the browser window\n` +
` 2. Complete the CAPTCHA/verification challenge\n` +
` 3. I'll detect when you're done and continue automatically\n\n` +
`ā³ WAITING: This tool is now polling for CAPTCHA completion.\n` +
`š AGENT: DO NOTHING. DO NOT CALL ANY TOOLS.\n` +
`The tool will automatically return when the user completes the CAPTCHA.`;
return {
content: [{ type: 'text', text }]
};
}
// Removed - DevToolsAgentPageGenerator is now the single source of truth
async generateAgentPage() {
try {
if (!this.page)
throw new Error('Page not initialized');
// Single source of truth - DevToolsAgentPageGenerator
const generator = new DevToolsAgentPageGenerator(this.page);
const result = await generator.generateAgentPage();
// Store current manifest for later use
this.currentManifest = result;
// Check if content needs to be chunked (20k chars is roughly 5k tokens)
const TOKEN_LIMIT = 20000; // Conservative limit for agent pages
if (result.content.length > TOKEN_LIMIT) {
console.log(`š¦ Agent page too large (${result.content.length} chars), creating chunks...`);
// Create chunks based on logical sections
const chunks = this.chunkAgentPage(result.content, result.elements);
// Store chunked data
const chunkId = randomUUID();
this.agentPageChunkData.set(chunkId, {
id: chunkId,
totalChunks: chunks.length,
chunks: chunks.map(c => c.content),
metadata: {
url: result.url,
title: result.title,
totalElements: result.elements.length,
timestamp: new Date(),
elementRanges: chunks.map(c => ({ start: c.startElement, end: c.endElement }))
}
});
// Return first chunk with instructions
const url = await this.page.url();
let response = `ā
Navigation successful\nš URL: ${url}\n\n`;
response += `ā ļø Large page detected (${result.content.length} characters)\n`;
response += `š¦ Content split into ${chunks.length} chunks\n`;
response += `š Chunk ID: ${chunkId}\n`;
response += `š Use get_agent_page_chunk({id: "${chunkId}", chunk: 2}) for next chunk\n\n`;
response += chunks[0].content;
return response;
}
// Return the complete agent page with navigation header
const url = await this.page.url();
return `ā
Navigation successful\nš URL: ${url}\n\n${result.content}`;
}
catch (error) {
console.error('ā Agent page generation failed:', error.message);
throw new Error(`Agent page generation failed: ${error.message}`);
}
}
chunkAgentPage(content, elements) {
const chunks = [];
const CHUNK_SIZE = 18000; // Leave room for headers
// Split content by major sections (forms, navigation, etc.)
const sections = content.split(/\n(?=## )/); // Split on section headers
let currentChunk = '';
let currentStartElement = 0;
let currentEndElement = 0;
let elementIndex = 0;
for (const section of sections) {
// Count elements in this section
const sectionElementCount = (section.match(/\[\w+-[\w-]+\]/g) || []).length;
// If adding this section would exceed limit, save current chunk
if (currentChunk && (currentChunk.length + section.length > CHUNK_SIZE)) {
chunks.push({
content: currentChunk.trim(),
startElement: currentStartElement,
endElement: currentEndElement
});
currentChunk = '';
currentStartElement = elementIndex;
}
currentChunk += (currentChunk ? '\n\n' : '') + section;
currentEndElement = elementIndex + sectionElementCount - 1;
elementIndex += sectionElementCount;
// If current chunk is getting large, save it
if (currentChunk.length > CHUNK_SIZE) {
chunks.push({
content: currentChunk.trim(),
startElement: currentStartElement,
endElement: currentEndElement
});
currentChunk = '';
currentStartElement = elementIndex;
}
}
// Add remaining content
if (currentChunk.trim()) {
chunks.push({
content: currentChunk.trim(),
startElement: currentStartElement,
endElement: Math.max(currentEndElement, elements.length - 1)
});
}
return chunks;
}
// Getter for agent page chunk data (for AgentTools access)
getAgentPageChunkData() {
return this.agentPageChunkData;
}
async navigateAndCaptureLoadingSequence(url, params = {}, screenshotTools) {
try {
// Launch browser if needed
if (!this.browser) {
// Environment variable configuration
const headless = this.forcedHeadless !== null ? this.forcedHeadless : process.env.SUPAPUP_HEADLESS === 'true'; // Default false (visible browser)
const debugPort = process.env.SUPAPUP_DEBUG_PORT || '9222';
const devtools = process.env.SUPAPUP_DEVTOOLS === 'true';
console.log(`š Launching browser for loading sequence capture (headless: ${headless})...`);
// Base arguments for all environments
const baseArgs = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
`--remote-debugging-port=${debugPort}`
];
// Additional arguments for headless environments
const headlessArgs = [
'--no-zygote',
'--single-process',
'--disable-gpu',
'--disable-dev-shm-usage'
];
// Arguments for non-headless (development) environments
const devArgs = [
'--start-maximized'
];
this.browser = await puppeteer.launch({
headless,
defaultViewport: null,
devtools,
args: [
...baseArgs,
...(headless ? headlessArgs : devArgs)
]
});
}
const { captureInterval = 500, // ms between captures (agent configurable)
maxDuration = 10000, // max capture time
quality = 30, // Lower quality for faster processing
stopOnNetworkIdle = true, // stop when network settles
viewport = { width: 1200, height: 800 } // Smaller viewport for faster screenshots
} = params;
// Create page if needed
if (!this.page) {
// Check if there's an existing blank page we can reuse
const pages = await this.browser.pages();
const blankPage = pages.find(page => page.url() === 'about:blank' || page.url() === '');
if (blankPage) {
this.page = blankPage;
}
else {
this.page = await this.browser.newPage();
}
await this.page.setViewport(viewport); // Use configurable viewport
await this.page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
}
else {
// Update viewport for loading sequence capture
await this.page.setViewport(viewport);
}
console.log(`š¬ Starting loading sequence capture for: ${url}`);
console.log(`šø Capturing every ${captureInterval}ms for up to ${maxDuration}ms`);
const frames = [];
const frameMetadata = [];
const startTime = Date.now();
let captureActive = true;
let lastNetworkActivity = Date.now();
let frameIndex = 0;
// Track network activity
const requestHandler = () => { lastNetworkActivity = Date.now(); };
const responseHandler = () => { lastNetworkActivity = Date.now(); };
if (stopOnNetworkIdle) {
this.page.on('request', requestHandler);
this.page.on('response', responseHandler);
}
// Start navigation
const navigationPromise = this.page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Capture initial state
try {
const initialShot = await this.page.screenshot({
type: 'jpeg',
quality,
encoding: 'base64'
});
frames.push(initialShot);
frameMetadata.push({
index: frameIndex++,
timestamp: 0,
description: 'Initial navigation state'
});
console.log('šø Captured initial frame');
}
catch (e) {
console.log('ā ļø Could not capture initial frame');
}
// Capture frames at intervals
const captureIntervalId = setInterval(async () => {
if (!captureActive || !this.page) {
clearInterval(captureIntervalId);
return;
}
const elapsed = Date.now() - startTime;
// Check stop conditions
if (elapsed > maxDuration) {
console.log('ā±ļø Max duration reached');
captureActive = false;
return;
}
if (stopOnNetworkIdle && elapsed > 3000 && Date.now() - lastNetworkActivity > 2000) {
console.log('š Network idle detected');
captureActive = false;
return;
}
try {
// Capture frame
const screenshot = await this.page.screenshot({
type: 'jpeg',
quality,
encoding: 'base64'
});
// Get page state info
const stateInfo = await this.page.evaluate(() => {
const indicators = [];
// Check for loading indicators
const loadingElements = document.querySelectorAll('.loading, .spinner, [class*="load"], [class*="spin"], .skeleton');
if (loadingElements.length > 0) {
indicators.push(`${loadingElements.length} loading indicators`);
}
// Check video states
const videos = document.querySelectorAll('video');
const loadingVideos = Array.from(videos).filter(v => v.readyState < 4);
if (loadingVideos.length > 0) {
indicators.push(`${loadingVideos.length}/${videos.length} videos loading`);
}
// Check images
const images = document.querySelectorAll('img');
const loadingImages = Array.from(images).filter(img => !img.complete);
if (loadingImages.length > 0) {
indicators.push(`${loadingImages.length}/${images.length} images loading`);
}
// Check if document is still parsing
if (document.readyState !== 'complete') {
indicators.push(`document ${document.readyState}`);
}
return indicators.length > 0 ? indicators.join(', ') : 'content visible';
});
frames.push(screenshot);
frameMetadata.push({
index: frameIndex++,
timestamp: elapsed,
description: stateInfo
});
console.log(`šø Frame ${frameIndex} at ${elapsed}ms: ${stateInfo}`);
}
catch (error) {
console.error('Failed to capture frame:', error);
}
}, captureInterval);
// Wait for navigation
try {
await navigationPromise;
console.log('ā
Navigation completed');
}
catch (error) {
console.log('ā ļø Navigation timeout/error:', error);
}
// Wait for capture to finish
while (captureActive) {
await new Promise(resolve => setTimeout(resolve, 100));
}
clearInterval(captureIntervalId);
// Clean up event listeners
if (stopOnNetworkIdle) {
this.page.off('request', requestHandler);
this.page.off('response', responseHandler);
}
// Capture final state
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const finalShot = await this.page.screenshot({
type: 'jpeg',
quality,
encoding: 'base64'
});
frames.push(finalShot);
frameMetadata.push({
index: frameIndex++,
timestamp: Date.now() - startTime,
description: 'Final loaded state'
});
console.log('šø Captured final frame');
}
catch (e) {
console.log('ā ļø Could not capture final frame');
}
// Store as chunk data for retrieval
const sequenceId = `loading-${Date.now()}-${Math.random().toString(36).substring(7)}`;
// Use provided screenshot tools or create new one
let screenshotToolsInstance = screenshotTools;
if (!screenshotToolsInstance) {
const { ScreenshotTools } = await import('./screenshot-tools.js');
screenshotToolsInstance = new ScreenshotTools();
screenshotToolsInstance.initialize(this.page);
}
// Store in chunk format
const chunkData = screenshotToolsInstance.getChunkData();
console.log(`š¾ Storing loading sequence with ID: ${sequenceId}`);
console.log(`š ChunkData size before: ${chunkData.size}`);
chunkData.set(sequenceId, {
id: sequenceId,
totalChunks: frames.length,
chunks: frames,
metadata: {
fullPage: false,
quality,
timestamp: new Date(),
frameMetadata // Store frame descriptions
}
});
console.log(`š ChunkData size after: ${chunkData.size}`);
// Generate loading sequence analysis
const analysis = frameMetadata.map((meta, idx) => ` Frame ${idx + 1}: ${meta.timestamp}ms - ${meta.description}`).join('\n');
return {
content: [{
type: 'text',
text: `š¬ Loading sequence captured successfully!\n\n` +
`š URL: ${url}\n` +
`šļø Captured ${frames.length} frames over ${frameMetadata[frameMetadata.length - 1].timestamp}ms\n` +
`š Sequence ID: ${sequenceId}\n\n` +
`š Loading Timeline:\n${analysis}\n\n` +
`š View frames with:\n` +
` ⢠get_loading_sequence_frame({id: "${sequenceId}", frame: 1}) - Initial state\n` +
` ⢠get_loading_sequence_frame({id: "${sequenceId}", frame: ${Math.ceil(frames.length / 2)}}) - Mid-load\n` +
` ⢠get_loading_sequence_frame({id: "${sequenceId}", frame: ${frames.length}}) - Final state\n\n` +
`š” Navigate between frames to see how the page loaded!`
}]
};
}
catch (error) {
console.error('ā Loading sequence capture failed:', error);
return {
content: [{
type: 'text',
text: `ā Loading sequence capture failed: ${error.message}\nš URL: ${url}`
}]
};
}
}
}