Spaces:
Sleeping
Sleeping
charSLee013
feat: complete Hugging Face Spaces deployment with production-ready CognitiveKernel-Launchpad
1ea26af
| const express = require('express'); | |
| const { chromium } = require('playwright-extra') | |
| const StealthPlugin = require('puppeteer-extra-plugin-stealth') | |
| const { v4: uuidv4 } = require('uuid'); | |
| const yaml = require('js-yaml'); | |
| const fs = require('fs').promises; | |
| const path = require('path'); | |
| function sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| const app = express(); | |
| const port = parseInt(process.env.LISTEN_PORT) || 3000; | |
| app.use(express.json()); | |
| let browserPool = {}; | |
| const maxBrowsers = parseInt(process.env.MAX_BROWSERS) || 16; | |
| let waitingQueue = []; | |
| const initializeBrowserPool = (size) => { | |
| for (let i = 0; i < size; i++) { | |
| browserPool[String(i)] = { | |
| browserId: null, | |
| status: 'empty', | |
| browser: null, // actually context | |
| browser0: null, // browser | |
| pages: {}, | |
| lastActivity: Date.now() | |
| }; | |
| } | |
| }; | |
| const v8 = require('v8'); | |
| const processNextInQueue = async () => { | |
| const availableBrowserslot = Object.keys(browserPool).find( | |
| id => browserPool[id].status === 'empty' | |
| ); | |
| if (waitingQueue.length > 0 && availableBrowserslot) { | |
| const nextRequest = waitingQueue.shift(); | |
| try { | |
| const browserEntry = browserPool[availableBrowserslot]; | |
| let browserId = uuidv4() | |
| browserEntry.browserId = browserId | |
| browserEntry.status = 'not'; | |
| nextRequest.res.send({ availableBrowserslot: availableBrowserslot }); | |
| } catch (error) { | |
| nextRequest.res.status(500).send({ error: 'Failed to allocate browser.' }); | |
| } | |
| } else if (waitingQueue.length > 0) { | |
| } | |
| }; | |
| const releaseBrowser = async (browserslot) => { | |
| const browserEntry = browserPool[browserslot]; | |
| if (browserEntry && browserEntry.browser) { | |
| await browserEntry.browser.close(); | |
| await browserEntry.browser0.close(); | |
| browserEntry.browserId = null; | |
| browserEntry.status = 'empty'; | |
| browserEntry.browser = null; | |
| browserEntry.browser0 = null; | |
| browserEntry.pages = {}; | |
| browserEntry.lastActivity = Date.now(); | |
| processNextInQueue(); | |
| } | |
| }; | |
| setInterval(async () => { | |
| const now = Date.now(); | |
| for (const [browserslot, browserEntry] of Object.entries(browserPool)) { | |
| if (browserEntry.status === 'not' && now - browserEntry.lastActivity > 600000) { | |
| await releaseBrowser(browserslot); | |
| } | |
| } | |
| }, 60000); | |
| function findPageByPageId(browserId, pageId) { | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| if (browserEntry && browserEntry.pages[pageId]) { | |
| return browserEntry.pages[pageId]; | |
| } | |
| return null; | |
| } | |
| function findPagePrefixesWithCurrentMark(browserId, currentPageId) { | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| let pagePrefixes = []; | |
| if (browserEntry) { | |
| console.log(`current page id:${currentPageId}`, typeof currentPageId) | |
| for (const pageId in browserEntry.pages) { | |
| const page = browserEntry.pages[pageId]; | |
| const pageTitle = page.pageTitle; | |
| console.log(`iter page id:${pageId}`, typeof pageId) | |
| const isCurrentPage = pageId === currentPageId; | |
| const pagePrefix = `Tab ${pageId}${isCurrentPage ? ' (current)' : ''}: ${pageTitle}`; | |
| pagePrefixes.push(pagePrefix); | |
| } | |
| } | |
| return pagePrefixes.length > 0 ? pagePrefixes.join('\n') : null; | |
| } | |
| const { Mutex } = require("async-mutex"); | |
| const mutex = new Mutex(); | |
| app.post('/getBrowser', async (req, res) => { | |
| const { storageState, geoLocation } = req.body; | |
| const tryAllocateBrowser = () => { | |
| const availableBrowserslot = Object.keys(browserPool).find( | |
| id => browserPool[id].status === 'empty' | |
| ); | |
| let browserId = null; | |
| if (availableBrowserslot) { | |
| browserId = uuidv4() | |
| browserPool[availableBrowserslot].browserId = browserId | |
| } | |
| return {availableBrowserslot, browserId}; | |
| }; | |
| const waitForAvailableBrowser = () => { | |
| return new Promise(resolve => { | |
| waitingQueue.push(request => resolve(request)); | |
| }); | |
| }; | |
| // Acquire the mutex lock | |
| const release = await mutex.acquire(); | |
| try { | |
| let {availableBrowserslot, browserId} = tryAllocateBrowser(); | |
| if (!availableBrowserslot) { | |
| await waitForAvailableBrowser().then((id) => { | |
| availableBrowserslot = id; | |
| }); | |
| } | |
| console.log(storageState); | |
| let browserEntry = browserPool[availableBrowserslot]; | |
| if (!browserEntry.browser) { | |
| chromium.use(StealthPlugin()) | |
| // Configure browser launch options based on environment | |
| const isContainer = process.env.DOCKER_CONTAINER === 'true'; | |
| const launchOptions = { | |
| headless: true, | |
| chromiumSandbox: !isContainer, // Disable sandbox only in container | |
| }; | |
| // Add container-specific arguments if running in Docker | |
| if (isContainer) { | |
| launchOptions.args = [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', // Overcome limited resource problems | |
| '--disable-gpu' // Applicable to docker containers | |
| ]; | |
| console.log('[INFO] Running in container mode - sandbox disabled for compatibility'); | |
| } else { | |
| console.log('[INFO] Running in host mode - sandbox enabled for security'); | |
| } | |
| const new_browser = await chromium.launch(launchOptions); | |
| browserEntry.browser = await new_browser.newContext({ | |
| viewport: {width: 1024, height: 768}, | |
| locale: 'en-US', // Set the locale to English (US) | |
| geolocation: { latitude: 40.4415, longitude: -80.0125 }, // Coordinates for Pittsburgh, PA, USA | |
| permissions: ['geolocation'], // Grant geolocation permissions | |
| userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' // Example user agent | |
| }); | |
| browserEntry.browser0 = new_browser; | |
| } | |
| browserEntry.status = 'not'; | |
| browserEntry.lastActivity = Date.now(); | |
| console.log(`browserId: ${browserId}`) | |
| res.send({browserId: browserId}); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to get browser.' }); | |
| } finally { | |
| // Release the mutex lock | |
| release(); | |
| } | |
| }); | |
| app.post('/closeBrowser', async (req, res) => { | |
| const { browserId } = req.body; | |
| if (!browserId) { | |
| return res.status(400).send({ error: 'Missing required field: browserId.' }); | |
| } | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| if (!browserEntry || !browserEntry.browser) { | |
| return res.status(404).send({ error: 'Browser not found.' }); | |
| } | |
| try { | |
| await browserEntry.browser.close(); | |
| await browserEntry.browser0.close(); | |
| browserEntry.browserId = null; | |
| browserEntry.pages = {}; | |
| browserEntry.browser = null; | |
| browserEntry.browser0 = null; | |
| browserEntry.status = 'empty'; | |
| browserEntry.lastActivity = null; | |
| if (waitingQueue.length > 0) { | |
| const nextRequest = waitingQueue.shift(); | |
| const nextAvailableBrowserId = Object.keys(browserPool).find( | |
| id => browserPool[id].status === 'empty' | |
| ); | |
| if (nextRequest && nextAvailableBrowserId) { | |
| browserPool[nextAvailableBrowserId].status = 'not'; | |
| nextRequest(nextAvailableBrowserId); | |
| } | |
| } | |
| res.send({ message: 'Browser closed successfully.' }); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to close browser.' }); | |
| } | |
| }); | |
| app.post('/openPage', async (req, res) => { | |
| const { browserId, url } = req.body; | |
| if (!browserId || !url) { | |
| return res.status(400).send({ error: 'Missing browserId or url.' }); | |
| } | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| // const browserEntry = browserPool[browserId]; | |
| if (!browserEntry || !browserEntry.browser) { | |
| return res.status(404).send({ error: 'Browser not found.' }); | |
| } | |
| console.log(await browserEntry.browser.storageState()); | |
| const setCustomUserAgent = async (page) => { | |
| await page.addInitScript(() => { | |
| Object.defineProperty(navigator, 'userAgent', { | |
| get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', | |
| }); | |
| }); | |
| }; | |
| try { | |
| console.log(`[WEB_SERVER] OpenPage: Creating new page for browser ${browserId}`); | |
| const page = await browserEntry.browser.newPage(); | |
| await setCustomUserAgent(page); | |
| console.log(`[WEB_SERVER] OpenPage: Navigating to URL: ${url}`); | |
| const startTime = Date.now(); | |
| await page.goto(url); | |
| const endTime = Date.now(); | |
| console.log(`[WEB_SERVER] OpenPage: Navigation completed in ${endTime - startTime}ms`); | |
| const currentUrl = page.url(); | |
| console.log(`[WEB_SERVER] OpenPage: Actual URL after navigation: ${currentUrl}`); | |
| if (currentUrl !== url) { | |
| console.log(`[WEB_SERVER] OpenPage: URL_MISMATCH - Expected: ${url} | Actual: ${currentUrl}`); | |
| } | |
| const pageIdint = Object.keys(browserEntry.pages).length; | |
| console.log(`current page id:${pageIdint}`) | |
| const pageTitle = await page.title(); | |
| console.log(`[WEB_SERVER] OpenPage: Page title: ${pageTitle}`); | |
| const pageId = String(pageIdint); | |
| browserEntry.pages[pageId] = {'pageId': pageId, 'pageTitle': pageTitle, 'page': page, 'downloadedFiles': [], 'downloadSources': []}; | |
| browserEntry.lastActivity = Date.now(); | |
| // Define your download path | |
| const downloadPath = `./DownloadedFiles/${browserId}`; | |
| path.resolve(downloadPath); | |
| console.log(`Download path: ${downloadPath}`); | |
| // Ensure the download directory exists | |
| // try { | |
| // await fs.access(downloadPath); | |
| // } catch (error) { | |
| // if (error.code === 'ENOENT') { | |
| // await fs.mkdir(downloadPath, { recursive: true }); | |
| // } else { | |
| // console.error(`Failed to access download directory: ${error}`); | |
| // return; | |
| // } | |
| // } | |
| // Listen for the download event | |
| page.on('download', async (download) => { | |
| try { | |
| console.log('Download object properties:', download.url(), download.suggestedFilename(), download.failure()); | |
| const tmp_downloadPath = await download.path(); | |
| console.log(`Download path: ${tmp_downloadPath}`); | |
| // Get the original filename | |
| const filename = download.suggestedFilename(); | |
| console.log(`Suggested filename: ${filename}`); | |
| // Create the full path to save the file | |
| try { | |
| await fs.access(downloadPath); | |
| } catch (error) { | |
| if (error.code === 'ENOENT') { | |
| await fs.mkdir(downloadPath, { recursive: true }); | |
| } else { | |
| console.error(`Failed to access download directory: ${error}`); | |
| return; | |
| } | |
| } | |
| const filePath = path.join(downloadPath, filename); | |
| console.log(`Saving to path: ${filePath}`); | |
| // Save the file to the specified path | |
| await download.saveAs(filePath); | |
| console.log(`Download completed: ${filePath}`); | |
| browserEntry.pages[pageId].downloadedFiles.push(filePath); | |
| } catch (error) { | |
| console.error(`Failed to save download: ${error}`); | |
| } | |
| }); | |
| const userAgent = await page.evaluate(() => navigator.userAgent); | |
| console.log('USER AGENT: ', userAgent); | |
| res.send({ browserId, pageId }); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to open new page.' }); | |
| } | |
| }); | |
| function parseAccessibilityTree(nodes) { | |
| const IGNORED_ACTREE_PROPERTIES = [ | |
| "focusable", | |
| "editable", | |
| "readonly", | |
| "level", | |
| "settable", | |
| "multiline", | |
| "invalid", | |
| "hiddenRoot", | |
| "hidden", | |
| "controls", | |
| "labelledby", | |
| "describedby", | |
| "url" | |
| ]; | |
| const IGNORED_ACTREE_ROLES = [ | |
| "gridcell", | |
| ]; | |
| let nodeIdToIdx = {}; | |
| nodes.forEach((node, idx) => { | |
| if (!(node.nodeId in nodeIdToIdx)) { | |
| nodeIdToIdx[node.nodeId] = idx; | |
| } | |
| }); | |
| let treeIdxtoElement = {}; | |
| function dfs(idx, depth, parent_name) { | |
| let treeStr = ""; | |
| let node = nodes[idx]; | |
| let indent = "\t".repeat(depth); | |
| let validNode = true; | |
| try { | |
| let role = node.role.value; | |
| let name = node.name.value; | |
| let nodeStr = `${role} '${name}'`; | |
| if (!name.trim() || IGNORED_ACTREE_ROLES.includes(role) || (parent_name.trim().includes(name.trim()) && ["StaticText", "heading", "image", "generic"].includes(role))){ | |
| validNode = false; | |
| } else{ | |
| let properties = []; | |
| (node.properties || []).forEach(property => { | |
| if (!IGNORED_ACTREE_PROPERTIES.includes(property.name)) { | |
| properties.push(`${property.name}: ${property.value.value}`); | |
| } | |
| }); | |
| if (properties.length) { | |
| nodeStr += " " + properties.join(" "); | |
| } | |
| } | |
| if (validNode) { | |
| treeIdxtoElement[Object.keys(treeIdxtoElement).length + 1] = node; | |
| treeStr += `${indent}[${Object.keys(treeIdxtoElement).length}] ${nodeStr}`; | |
| } | |
| } catch (e) { | |
| validNode = false; | |
| } | |
| for (let childNodeId of node.childIds) { | |
| if (Object.keys(treeIdxtoElement).length >= 300) { | |
| break; | |
| } | |
| if (!(childNodeId in nodeIdToIdx)) { | |
| continue; | |
| } | |
| let childDepth = validNode ? depth + 1 : depth; | |
| let curr_name = validNode ? node.name.value : parent_name; | |
| let childStr = dfs(nodeIdToIdx[childNodeId], childDepth, curr_name); | |
| if (childStr.trim()) { | |
| if (treeStr.trim()) { | |
| treeStr += "\n"; | |
| } | |
| treeStr += childStr; | |
| } | |
| } | |
| return treeStr; | |
| } | |
| let treeStr = dfs(0, 0, 'root'); | |
| return {treeStr, treeIdxtoElement}; | |
| } | |
| async function getBoundingClientRect(client, backendNodeId) { | |
| try { | |
| // Resolve the node to get the RemoteObject | |
| const remoteObject = await client.send("DOM.resolveNode", {backendNodeId: parseInt(backendNodeId)}); | |
| const remoteObjectId = remoteObject.object.objectId; | |
| // Call a function on the resolved node to get its bounding client rect | |
| const response = await client.send("Runtime.callFunctionOn", { | |
| objectId: remoteObjectId, | |
| functionDeclaration: ` | |
| function() { | |
| if (this.nodeType === 3) { // Node.TEXT_NODE | |
| var range = document.createRange(); | |
| range.selectNode(this); | |
| var rect = range.getBoundingClientRect().toJSON(); | |
| range.detach(); | |
| return rect; | |
| } else { | |
| return this.getBoundingClientRect().toJSON(); | |
| } | |
| } | |
| `, | |
| returnByValue: true | |
| }); | |
| return response; | |
| } catch (e) { | |
| return {result: {subtype: "error"}}; | |
| } | |
| } | |
| async function fetchPageAccessibilityTree(accessibilityTree) { | |
| let seenIds = new Set(); | |
| let filteredAccessibilityTree = []; | |
| let backendDOMids = []; | |
| for (let i = 0; i < accessibilityTree.length; i++) { | |
| if (filteredAccessibilityTree.length >= 20000) { | |
| break; | |
| } | |
| let node = accessibilityTree[i]; | |
| if (!seenIds.has(node.nodeId) && 'backendDOMNodeId' in node) { | |
| filteredAccessibilityTree.push(node); | |
| seenIds.add(node.nodeId); | |
| backendDOMids.push(node.backendDOMNodeId); | |
| } | |
| } | |
| accessibilityTree = filteredAccessibilityTree; | |
| return [accessibilityTree, backendDOMids]; | |
| } | |
| async function fetchAllBoundingClientRects(client, backendNodeIds) { | |
| const fetchRectPromises = backendNodeIds.map(async (backendNodeId) => { | |
| return getBoundingClientRect(client, backendNodeId); | |
| }); | |
| try { | |
| const results = await Promise.all(fetchRectPromises); | |
| return results; | |
| } catch (error) { | |
| console.error("An error occurred:", error); | |
| } | |
| } | |
| function removeNodeInGraph(node, nodeidToCursor, accessibilityTree) { | |
| const nodeid = node.nodeId; | |
| const nodeCursor = nodeidToCursor[nodeid]; | |
| const parentNodeid = node.parentId; | |
| const childrenNodeids = node.childIds; | |
| const parentCursor = nodeidToCursor[parentNodeid]; | |
| // Update the children of the parent node | |
| if (accessibilityTree[parentCursor] !== undefined) { | |
| // Remove the nodeid from parent's childIds | |
| const index = accessibilityTree[parentCursor].childIds.indexOf(nodeid); | |
| //console.log('index:', index); | |
| accessibilityTree[parentCursor].childIds.splice(index, 1); | |
| // Insert childrenNodeids in the same location | |
| childrenNodeids.forEach((childNodeid, idx) => { | |
| if (childNodeid in nodeidToCursor) { | |
| accessibilityTree[parentCursor].childIds.splice(index + idx, 0, childNodeid); | |
| } | |
| }); | |
| // Update children node's parent | |
| childrenNodeids.forEach(childNodeid => { | |
| if (childNodeid in nodeidToCursor) { | |
| const childCursor = nodeidToCursor[childNodeid]; | |
| accessibilityTree[childCursor].parentId = parentNodeid; | |
| } | |
| }); | |
| } | |
| accessibilityTree[nodeCursor].parentId = "[REMOVED]"; | |
| } | |
| function processAccessibilityTree(accessibilityTree, minRatio) { | |
| const nodeidToCursor = {}; | |
| accessibilityTree.forEach((node, index) => { | |
| nodeidToCursor[node.nodeId] = index; | |
| }); | |
| let count = 0; | |
| accessibilityTree.forEach(node => { | |
| if (node.union_bound === undefined) { | |
| removeNodeInGraph(node, nodeidToCursor, accessibilityTree); | |
| return; | |
| } | |
| const x = node.union_bound.x; | |
| const y = node.union_bound.y; | |
| const width = node.union_bound.width; | |
| const height = node.union_bound.height; | |
| // Invisible node | |
| if (width === 0 || height === 0) { | |
| removeNodeInGraph(node, nodeidToCursor, accessibilityTree); | |
| return; | |
| } | |
| const inViewportRatio = getInViewportRatio( | |
| parseFloat(x), | |
| parseFloat(y), | |
| parseFloat(width), | |
| parseFloat(height), | |
| ); | |
| // if (inViewportRatio < 0.5) { | |
| if (inViewportRatio < minRatio) { | |
| count += 1; | |
| removeNodeInGraph(node, nodeidToCursor, accessibilityTree); | |
| } | |
| }); | |
| console.log('number of nodes marked:', count); | |
| accessibilityTree = accessibilityTree.filter(node => node.parentId !== "[REMOVED]"); | |
| return accessibilityTree; | |
| } | |
| function getInViewportRatio(elemLeftBound, elemTopBound, width, height, config) { | |
| const elemRightBound = elemLeftBound + width; | |
| const elemLowerBound = elemTopBound + height; | |
| const winLeftBound = 0; | |
| const winRightBound = 1024; | |
| const winTopBound = 0; | |
| const winLowerBound = 768; | |
| const overlapWidth = Math.max( | |
| 0, | |
| Math.min(elemRightBound, winRightBound) - Math.max(elemLeftBound, winLeftBound), | |
| ); | |
| const overlapHeight = Math.max( | |
| 0, | |
| Math.min(elemLowerBound, winLowerBound) - Math.max(elemTopBound, winTopBound), | |
| ); | |
| const ratio = (overlapWidth * overlapHeight) / (width * height); | |
| return ratio; | |
| } | |
| app.post('/getAccessibilityTree', async (req, res) => { | |
| const { browserId, pageId, currentRound } = req.body; | |
| if (!browserId || !pageId) { | |
| return res.status(400).send({ error: 'Missing browserId or pageId.' }); | |
| } | |
| const pageEntry = findPageByPageId(browserId, pageId); | |
| if (!pageEntry) { | |
| return res.status(404).send({ error: 'pageEntry not found.' }); | |
| } | |
| const page = pageEntry.page; | |
| if (!page) { | |
| return res.status(404).send({ error: 'Page not found.' }); | |
| } | |
| try { | |
| console.time('FullAXTTime'); | |
| const client = await page.context().newCDPSession(page); | |
| const response = await client.send('Accessibility.getFullAXTree'); | |
| const [axtree, backendDOMids] = await fetchPageAccessibilityTree(response.nodes); | |
| console.log('finished fetching page accessibility tree') | |
| const boundingClientRects = await fetchAllBoundingClientRects(client, backendDOMids);; | |
| console.log('finished fetching bounding client rects') | |
| console.log('boundingClientRects:', boundingClientRects.length, 'axtree:', axtree.length); | |
| for (let i = 0; i < boundingClientRects.length; i++) { | |
| if (axtree[i].role.value === 'RootWebArea') { | |
| axtree[i].union_bound = [0.0, 0.0, 10.0, 10.0]; | |
| } else { | |
| axtree[i].union_bound = boundingClientRects[i].result.value; | |
| } | |
| } | |
| const clone_axtree = processAccessibilityTree(JSON.parse(JSON.stringify(axtree)), -1.0); // no space pruning | |
| const pruned_axtree = processAccessibilityTree(axtree, 0.5); | |
| const fullTreeRes = parseAccessibilityTree(clone_axtree); // full tree | |
| const {treeStr, treeIdxtoElement} = parseAccessibilityTree(pruned_axtree); // pruned tree | |
| console.timeEnd('FullAXTTime'); | |
| console.log(treeStr); | |
| pageEntry['treeIdxtoElement'] = treeIdxtoElement; | |
| const accessibilitySnapshot = await page.accessibility.snapshot(); | |
| const prefix = findPagePrefixesWithCurrentMark(browserId, pageId) || ''; | |
| let yamlWithPrefix = `${prefix}\n${treeStr}`; | |
| // if (pageEntry['downloadedFiles'].length > 0) { | |
| // if (pageEntry['downloadSources'].length < pageEntry['downloadedFiles'].length) { | |
| // const source_name = pruned_axtree[0].name.value; | |
| // while (pageEntry['downloadSources'].length < pageEntry['downloadedFiles'].length) { | |
| // pageEntry['downloadSources'].push(source_name); | |
| // } | |
| // } | |
| // const downloadedFiles = pageEntry['downloadedFiles']; | |
| // yamlWithPrefix += `\n\nYou have successfully downloaded the following files:\n`; | |
| // downloadedFiles.forEach((file, idx) => { | |
| // yamlWithPrefix += `File ${idx + 1} (from ${pageEntry['downloadSources'][idx]}): ${file}\n`; | |
| // } | |
| // ); | |
| // } | |
| const screenshotBuffer = await page.screenshot(); | |
| const fileName = `${browserId}@@${pageId}@@${currentRound}.png`; | |
| const screenshotPath = './screenshots'; | |
| const filePath = path.join(screenshotPath, fileName); | |
| // Ensure the download directory exists | |
| try { | |
| await fs.access(screenshotPath); | |
| } catch (error) { | |
| if (error.code === 'ENOENT') { | |
| await fs.mkdir(screenshotPath, { recursive: true }); | |
| } else { | |
| console.error(`Failed to access download directory: ${error}`); | |
| return; | |
| } | |
| } | |
| // | |
| await fs.writeFile(filePath, screenshotBuffer); | |
| const boxed_screenshotBuffer = await getboxedScreenshot( | |
| page, | |
| browserId, | |
| pageId, | |
| currentRound, | |
| treeIdxtoElement | |
| ); | |
| const currentUrl = page.url(); | |
| const html = await page.content(); | |
| res.send({ yaml: yamlWithPrefix, fulltree: fullTreeRes.treeStr, url: currentUrl, html: html, snapshot: accessibilitySnapshot, nonboxed_screenshot: screenshotBuffer.toString("base64"), boxed_screenshot: boxed_screenshotBuffer.toString("base64"), downloaded_file_path: pageEntry['downloadedFiles']}); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to get accessibility tree.' }); | |
| } | |
| }); | |
| async function getboxedScreenshot( | |
| page, | |
| browserId, | |
| pageId, | |
| currentRound, | |
| treeIdxtoElement | |
| ) { | |
| // filter treeIdxtoElement to only include elements that are interactive | |
| // (e.g., buttons, links, form elements, etc.) | |
| const interactiveElements = {}; | |
| Object.keys(treeIdxtoElement).forEach(function (index) { | |
| var elementData = treeIdxtoElement[index]; | |
| var role = elementData.role.value; | |
| if ( | |
| role === "button" || | |
| role === "link" || | |
| role === "tab" || | |
| role.includes("box") | |
| ) { | |
| interactiveElements[index] = elementData; | |
| } | |
| }); | |
| await page.evaluate((interactiveElements) => { | |
| Object.keys(interactiveElements).forEach(function (index) { | |
| var elementData = interactiveElements[index]; | |
| var unionBound = elementData.union_bound; // Access the union_bound object | |
| // Create a new div element to represent the bounding box | |
| var newElement = document.createElement("div"); | |
| var borderColor = "#000000"; // Use your color function to get the color | |
| newElement.style.outline = `2px dashed ${borderColor}`; | |
| newElement.style.position = "fixed"; | |
| // Use union_bound's x, y, width, and height | |
| newElement.style.left = unionBound.x + "px"; | |
| newElement.style.top = unionBound.y + "px"; | |
| newElement.style.width = unionBound.width + "px"; | |
| newElement.style.height = unionBound.height + "px"; | |
| newElement.style.pointerEvents = "none"; | |
| newElement.style.boxSizing = "border-box"; | |
| newElement.style.zIndex = 2147483647; | |
| newElement.classList.add("bounding-box"); | |
| // Create a floating label to show the index | |
| var label = document.createElement("span"); | |
| label.textContent = index; | |
| label.style.position = "absolute"; | |
| // Adjust label position with respect to union_bound | |
| label.style.top = Math.max(-19, -unionBound.y) + "px"; | |
| label.style.left = Math.min(Math.floor(unionBound.width / 5), 2) + "px"; | |
| label.style.background = borderColor; | |
| label.style.color = "white"; | |
| label.style.padding = "2px 4px"; | |
| label.style.fontSize = "12px"; | |
| label.style.borderRadius = "2px"; | |
| newElement.appendChild(label); | |
| // Append the element to the document body | |
| document.body.appendChild(newElement); | |
| }); | |
| }, interactiveElements); // Pass treeIdxtoElement here as a second argument | |
| // Optionally wait a bit to ensure the boxes are drawn | |
| await page.waitForTimeout(1000); | |
| // Take the screenshot | |
| const screenshotBuffer = await page.screenshot(); | |
| // Define the file name and path | |
| const fileName = `${browserId}@@${pageId}@@${currentRound}_with_box.png`; | |
| const filePath = path.join("./screenshots", fileName); | |
| // Write the screenshot to a file | |
| await fs.writeFile(filePath, screenshotBuffer); | |
| await page.evaluate(() => { | |
| document.querySelectorAll(".bounding-box").forEach((box) => box.remove()); | |
| }); | |
| return screenshotBuffer; | |
| } | |
| async function adjustAriaHiddenForSubmenu(menuitemElement) { | |
| try { | |
| const submenu = await menuitemElement.$('div.submenu'); | |
| if (submenu) { | |
| await submenu.evaluate(node => { | |
| node.setAttribute('aria-hidden', 'false'); | |
| }); | |
| } | |
| } catch (e) { | |
| console.log('Failed to adjust aria-hidden for submenu:', e); | |
| } | |
| } | |
| async function clickElement(click_locator, adjust_aria_label, x1, x2, y1, y2) { | |
| const elements = adjust_aria_label ? await click_locator.elementHandles() : await click_locator.all(); | |
| if (elements.length > 1) { | |
| for (const element of elements) { | |
| await element.evaluate(el => { | |
| if (el.tagName.toLowerCase() === 'a' && el.hasAttribute('target')) { | |
| el.setAttribute('target', '_self'); | |
| } | |
| }); | |
| } | |
| const targetX = (x1 + x2) / 2; | |
| const targetY = (y1 + y2) / 2; | |
| let closestElement = null; | |
| let closestDistance = Infinity; | |
| for (const element of elements) { | |
| const boundingBox = await element.boundingBox(); | |
| if (boundingBox) { | |
| const elementCenterX = boundingBox.x + boundingBox.width / 2; | |
| const elementCenterY = boundingBox.y + boundingBox.height / 2; | |
| const distance = Math.sqrt( | |
| Math.pow(elementCenterX - targetX, 2) + Math.pow(elementCenterY - targetY, 2) | |
| ); | |
| if (distance < closestDistance) { | |
| closestDistance = distance; | |
| closestElement = element; | |
| } | |
| } | |
| } | |
| await closestElement.click({ timeout: 5000, force: true}); | |
| if (adjust_aria_label) { | |
| await adjustAriaHiddenForSubmenu(closestElement); | |
| } | |
| } else if (elements.length === 1) { | |
| await elements[0].evaluate(el => { | |
| if (el.tagName.toLowerCase() === 'a' && el.hasAttribute('target')) { | |
| el.setAttribute('target', '_self'); | |
| } | |
| }); | |
| await elements[0].click({ timeout: 5000, force: true}); | |
| if (adjust_aria_label) { | |
| await adjustAriaHiddenForSubmenu(elements[0]); | |
| } | |
| } else { | |
| return false; | |
| } | |
| return true; | |
| } | |
| app.post('/performAction', async (req, res) => { | |
| const { browserId, pageId, actionName, targetId, targetElementType, targetElementName, actionValue, needEnter } = req.body; | |
| console.log(`[WEB_SERVER] PerformAction: Received action request`); | |
| console.log(`[WEB_SERVER] PerformAction: Browser: ${browserId} | Page: ${pageId} | Action: ${actionName}`); | |
| console.log(`[WEB_SERVER] PerformAction: Target: ${targetElementType} | Name: ${targetElementName} | Value: ${actionValue}`); | |
| if (['click', 'type'].includes(actionName) && (!browserId || !actionName || !targetElementType || !pageId)) { | |
| console.log(`[WEB_SERVER] PerformAction: ERROR - Missing required fields for ${actionName}`); | |
| return res.status(400).send({ error: 'Missing required fields.' }); | |
| } else if (!browserId || !actionName || !pageId) { | |
| console.log(`[WEB_SERVER] PerformAction: ERROR - Missing basic required fields`); | |
| return res.status(400).send({ error: 'Missing required fields.' }); | |
| } | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| console.log(`[WEB_SERVER] PerformAction: Found browser slot: ${slot}`); | |
| const browserEntry = browserPool[slot] | |
| if (!browserEntry || !browserEntry.browser) { | |
| console.log(`[WEB_SERVER] PerformAction: ERROR - Browser not found for ID: ${browserId}`); | |
| return res.status(404).send({ error: 'Browser not found.' }); | |
| } | |
| const pageEntry = browserEntry.pages[pageId]; | |
| console.log(`[WEB_SERVER] PerformAction: Page entry found: ${pageEntry ? 'YES' : 'NO'}`); | |
| if (!pageEntry || !pageEntry.page) { | |
| console.log(`[WEB_SERVER] PerformAction: ERROR - Page not found for ID: ${pageId}`); | |
| console.log(`[WEB_SERVER] PerformAction: Available pages: ${Object.keys(browserEntry.pages)}`); | |
| return res.status(404).send({ error: 'Page not found.' }); | |
| } | |
| try { | |
| const page = pageEntry.page; | |
| const treeIdxtoElement = pageEntry.treeIdxtoElement; | |
| let adjust_aria_label = false; | |
| if (targetElementType === 'menuitem' || targetElementType === 'combobox') { | |
| adjust_aria_label = true; | |
| } | |
| switch (actionName) { | |
| case 'click': | |
| let element = treeIdxtoElement[targetId]; | |
| let clicked = false; | |
| let click_locator; | |
| try{ | |
| click_locator = await page.getByRole(targetElementType, { name: targetElementName, exact:true, timeout: 5000}); | |
| clicked = await clickElement(click_locator, adjust_aria_label, element.union_bound.x, element.union_bound.x + element.union_bound.width, element.union_bound.y, element.union_bound.y + element.union_bound.height); | |
| } catch (e) { | |
| console.log(e); | |
| clicked = false; | |
| } | |
| if (!clicked) { | |
| const click_locator = await page.getByRole(targetElementType, { name: targetElementName}); | |
| clicked = await clickElement(click_locator, adjust_aria_label, element.union_bound.x, element.union_bound.x + element.union_bound.width, element.union_bound.y, element.union_bound.y + element.union_bound.height); | |
| if (!clicked) { | |
| const targetElementNameStartWords = targetElementName.split(' ').slice(0, 3).join(' '); | |
| const click_locator = await page.getByText(targetElementNameStartWords); | |
| clicked = await clickElement(click_locator, adjust_aria_label, element.union_bound.x, element.union_bound.x + element.union_bound.width, element.union_bound.y, element.union_bound.y + element.union_bound.height); | |
| if (!clicked) { | |
| return res.status(400).send({ error: 'No clickable element found.' }); | |
| } | |
| } | |
| } | |
| await page.waitForTimeout(5000); | |
| break; | |
| case 'type': | |
| let type_clicked = false; | |
| let locator; | |
| let node = treeIdxtoElement[targetId]; | |
| try{ | |
| locator = await page.getByRole(targetElementType, { name: targetElementName, exact:true, timeout: 5000}).first() | |
| type_clicked = await clickElement(locator, adjust_aria_label, node.union_bound.x, node.union_bound.x + node.union_bound.width, node.union_bound.y, node.union_bound.y + node.union_bound.height); | |
| } catch (e) { | |
| console.log(e); | |
| type_clicked = false; | |
| } | |
| if (!type_clicked) { | |
| locator = await page.getByRole(targetElementType, { name: targetElementName}).first() | |
| type_clicked = await clickElement(locator, adjust_aria_label, node.union_bound.x, node.union_bound.x + node.union_bound.width, node.union_bound.y, node.union_bound.y + node.union_bound.height); | |
| if (!type_clicked) { | |
| locator = await page.getByPlaceholder(targetElementName).first(); | |
| type_clicked = await clickElement(locator, adjust_aria_label, node.union_bound.x, node.union_bound.x + node.union_bound.width, node.union_bound.y, node.union_bound.y + node.union_bound.height); | |
| if (!type_clicked) { | |
| return res.status(400).send({ error: 'No clickable element found.' }); | |
| } | |
| } | |
| } | |
| await page.keyboard.press('Control+A'); | |
| await page.keyboard.press('Backspace'); | |
| if (needEnter) { | |
| const newactionValue = actionValue + '\n'; | |
| await page.keyboard.type(newactionValue); | |
| } else { | |
| await page.keyboard.type(actionValue); | |
| } | |
| break; | |
| case 'select': | |
| let menu_locator = await page.getByRole(targetElementType, { name: targetElementName, exact:true, timeout: 5000}); | |
| await menu_locator.selectOption({ label: actionValue }) | |
| await menu_locator.click(); | |
| break; | |
| case 'scroll': | |
| if (actionValue === 'down') { | |
| await page.evaluate(() => window.scrollBy(0, window.innerHeight)); | |
| } else if (actionValue === 'up') { | |
| await page.evaluate(() => window.scrollBy(0, -window.innerHeight)); | |
| } else { | |
| return res.status(400).send({ error: 'Unsupported scroll direction.' }); | |
| } | |
| break; | |
| case 'goback': | |
| await page.goBack(); | |
| break; | |
| case 'goto': | |
| console.log(`[WEB_SERVER] PerformAction: GOTO - Navigating to: ${actionValue}`); | |
| const gotoStartTime = Date.now(); | |
| try { | |
| await page.goto(actionValue, { timeout: 60000 }); | |
| const gotoEndTime = Date.now(); | |
| const finalUrl = page.url(); | |
| console.log(`[WEB_SERVER] PerformAction: GOTO - Navigation completed in ${gotoEndTime - gotoStartTime}ms`); | |
| console.log(`[WEB_SERVER] PerformAction: GOTO - Final URL: ${finalUrl}`); | |
| if (finalUrl !== actionValue) { | |
| console.log(`[WEB_SERVER] PerformAction: GOTO - URL_MISMATCH - Expected: ${actionValue} | Actual: ${finalUrl}`); | |
| } | |
| } catch (error) { | |
| console.log(`[WEB_SERVER] PerformAction: GOTO - Navigation FAILED: ${error.message}`); | |
| throw error; | |
| } | |
| break; | |
| case 'restart': | |
| await page.goto("https://www.bing.com"); | |
| // await page.goto(actionValue); | |
| break; | |
| case 'wait': | |
| await sleep(3000); | |
| break; | |
| default: | |
| return res.status(400).send({ error: 'Unsupported action.' }); | |
| } | |
| browserEntry.lastActivity = Date.now(); | |
| await sleep(3000); | |
| const currentUrl = page.url(); | |
| console.log(`current url: ${currentUrl}`); | |
| res.send({ message: 'Action performed successfully.' }); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to perform action.' }); | |
| } | |
| }); | |
| app.post('/gotoUrl', async (req, res) => { | |
| const { browserId, pageId, targetUrl } = req.body; | |
| if (!targetUrl) { | |
| return res.status(400).send({ error: 'Missing required fields.' }); | |
| } | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| if (!browserEntry || !browserEntry.browser) { | |
| return res.status(404).send({ error: 'Browser not found.' }); | |
| } | |
| const pageEntry = browserEntry.pages[pageId]; | |
| if (!pageEntry || !pageEntry.page) { | |
| return res.status(404).send({ error: 'Page not found.' }); | |
| } | |
| try { | |
| const page = pageEntry.page; | |
| console.log(`target url: ${targetUrl}`); | |
| await page.goto(targetUrl, { timeout: 60000 }); | |
| browserEntry.lastActivity = Date.now(); | |
| await sleep(3000); | |
| const currentUrl = page.url(); | |
| console.log(`current url: ${currentUrl}`); | |
| res.send({ message: 'Action performed successfully.' }); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to perform action.' }); | |
| } | |
| }); | |
| app.post('/takeScreenshot', async (req, res) => { | |
| const { browserId, pageId } = req.body; | |
| if (!browserId || !pageId) { | |
| return res.status(400).send({ error: 'Missing required fields: browserId, pageId.' }); | |
| } | |
| const slot = Object.keys(browserPool).find(slot => browserPool[slot].browserId === browserId); | |
| const browserEntry = browserPool[slot] | |
| if (!browserEntry || !browserEntry.browser) { | |
| return res.status(404).send({ error: 'Browser not found.' }); | |
| } | |
| const pageEntry = browserEntry.pages[pageId]; | |
| if (!pageEntry || !pageEntry.page) { | |
| return res.status(404).send({ error: 'Page not found.' }); | |
| } | |
| try { | |
| const page = pageEntry.page; | |
| const screenshotBuffer = await page.screenshot({ fullPage: true }); | |
| res.setHeader('Content-Type', 'image/png'); | |
| res.send(screenshotBuffer); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: 'Failed to take screenshot.' }); | |
| } | |
| }); | |
| app.post('/loadScreenshot', (req, res) => { | |
| const { browserId, pageId, currentRound } = req.body; | |
| const fileName = `${browserId}@@${pageId}@@${currentRound}.png`; | |
| const filePath = path.join('./screenshots', fileName); | |
| res.sendFile(filePath, (err) => { | |
| if (err) { | |
| console.error(err); | |
| if (err.code === 'ENOENT') { | |
| res.status(404).send({ error: 'Screenshot not found.' }); | |
| } else { | |
| res.status(500).send({ error: 'Error sending screenshot file.' }); | |
| } | |
| } | |
| }); | |
| }); | |
| app.post("/gethtmlcontent", async (req, res) => { | |
| const { browserId, pageId, currentRound } = req.body; | |
| // if (!browserId || !pageId) { | |
| // return res.status(400).send({ error: 'Missing browserId or pageId.' }); | |
| // } | |
| const pageEntry = findPageByPageId(browserId, pageId); | |
| const page = pageEntry.page; | |
| // if (!page) { | |
| // return res.status(404).send({ error: 'Page not found.' }); | |
| // } | |
| try { | |
| const html = await page.content(); | |
| const currentUrl = page.url(); | |
| res.send({ html: html, url: currentUrl }); | |
| } catch (error) { | |
| console.error(error); | |
| res.status(500).send({ error: "Failed to get html content." }); | |
| } | |
| }); | |
| app.post('/getFile', async (req, res) => { | |
| try { | |
| const { filename } = req.body; | |
| if (!filename) { | |
| return res.status(400).send({ error: 'Filename is required.' }); | |
| } | |
| const data = await fs.readFile(filename); // simply directly read it! | |
| const base64String = data.toString('base64'); | |
| res.send({ file: base64String }); | |
| } catch (err) { | |
| console.error(err); | |
| res.status(500).send({ error: 'File not found or cannot be read.' }); | |
| } | |
| }); | |
| // 健康检查端点 | |
| app.get('/health', (req, res) => { | |
| const healthStatus = { | |
| status: 'healthy', | |
| timestamp: new Date().toISOString(), | |
| uptime: process.uptime(), | |
| memory: process.memoryUsage(), | |
| browserPool: { | |
| total: maxBrowsers, | |
| active: Object.values(browserPool).filter(b => b.status !== 'empty').length, | |
| empty: Object.values(browserPool).filter(b => b.status === 'empty').length | |
| } | |
| }; | |
| res.json(healthStatus); | |
| }); | |
| app.listen(port, () => { | |
| initializeBrowserPool(maxBrowsers); | |
| console.log(`Server listening at http://localhost:${port}`); | |
| console.log(`Health check available at http://localhost:${port}/health`); | |
| }); | |
| process.on('exit', async () => { | |
| for (const browserEntry of browserPool) { | |
| await browserEntry.browser.close(); | |
| await browserEntry.browser0.close(); | |
| } | |
| }); |