UNPKG

@knowcode/screenshotfetch

Version:

Web application spider with screenshot capture and customer journey documentation. Automate user flow documentation with authentication support.

305 lines (253 loc) 8.91 kB
const { v4: uuidv4 } = require('uuid'); const URLCapture = require('../utils/URLCapture'); class FlowTracker { constructor(options = {}) { this.options = { maxFlowDepth: 10, maxFlows: 5, waitTime: 2000, screenshotDelay: 1000, ...options }; this.urlCapture = new URLCapture(options); this.flows = new Map(); this.currentFlow = null; this.visitedElements = new Set(); } startNewFlow(startUrl, flowType = 'general') { const flowId = uuidv4(); const timestamp = new Date().toISOString(); const flow = { id: flowId, type: flowType, startUrl: startUrl, startTime: timestamp, steps: [], completed: false, metadata: { totalSteps: 0, totalScreenshots: 0, duration: 0 } }; this.flows.set(flowId, flow); this.currentFlow = flow; console.log(`🚀 Started new flow: ${flowType} (${flowId.substring(0, 8)})`); return flowId; } async addFlowStep(page, action, metadata = {}) { if (!this.currentFlow) { throw new Error('No active flow. Call startNewFlow() first.'); } const stepNumber = this.currentFlow.steps.length + 1; const timestamp = new Date().toISOString(); const urlData = this.urlCapture.captureURL(page, { step: stepNumber }); // Wait a moment for any animations or loading to complete await new Promise(resolve => setTimeout(resolve, this.options.screenshotDelay)); const step = { stepNumber, timestamp, url: urlData, action: action, pageTitle: await this.getPageTitle(page), ...metadata }; this.currentFlow.steps.push(step); this.currentFlow.metadata.totalSteps = stepNumber; console.log(`📝 Added step ${stepNumber}: ${action} at ${urlData.display}`); return step; } async getPageTitle(page) { try { return await page.title(); } catch (error) { return 'Unknown Page'; } } async discoverClickableElements(page) { console.log('🔍 Discovering clickable elements...'); try { const elements = await page.evaluate(() => { const clickables = []; // Find all potentially clickable elements const selectors = [ 'a[href]', 'button:not([disabled])', '[onclick]', '[role="button"]', '[tabindex="0"]', 'input[type="submit"]', 'input[type="button"]', '.btn', '.button', '[class*="clickable"]' ]; const elements = document.querySelectorAll(selectors.join(', ')); for (const element of elements) { // Skip if element is not visible const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); if (rect.width === 0 || rect.height === 0 || style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { continue; } let href = ''; let text = ''; let type = 'unknown'; if (element.tagName === 'A') { href = element.href; text = element.textContent.trim(); type = 'link'; } else if (element.tagName === 'BUTTON' || element.type === 'button' || element.type === 'submit') { text = element.textContent.trim(); type = 'button'; } else { text = element.textContent.trim(); type = 'interactive'; } // Skip elements without meaningful text or href if (!text && !href) continue; if (text.length > 100) text = text.substring(0, 100) + '...'; clickables.push({ tagName: element.tagName, type, text, href, id: element.id, className: element.className, x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) }); } return clickables; }); // Filter out dangerous or unwanted elements const filtered = elements.filter(el => this.shouldIncludeElement(el)); console.log(`✅ Found ${filtered.length} clickable elements (${elements.length} total, ${elements.length - filtered.length} filtered out)`); return filtered; } catch (error) { console.error('❌ Error discovering clickable elements:', error.message); return []; } } shouldIncludeElement(element) { const text = element.text.toLowerCase(); const href = element.href ? element.href.toLowerCase() : ''; // Skip dangerous or unwanted actions const skipPatterns = [ 'logout', 'log out', 'sign out', 'signout', 'delete', 'remove', 'cancel', 'download', 'export', 'print', 'admin', 'settings', 'preferences', 'help', 'support', 'contact', 'privacy', 'terms', 'legal' ]; for (const pattern of skipPatterns) { if (text.includes(pattern) || href.includes(pattern)) { return false; } } // Skip external links unless specifically included if (element.href && element.type === 'link') { try { const url = new URL(element.href); const currentUrl = new URL(window.location.href); if (url.hostname !== currentUrl.hostname) { return false; } } catch (e) { // Invalid URL, skip return false; } } // Skip if we've already interacted with this element const elementKey = this.getElementKey(element); if (this.visitedElements.has(elementKey)) { return false; } return true; } getElementKey(element) { // Create a unique key for the element return `${element.tagName}-${element.text}-${element.href || element.id || element.className}`; } async clickElement(page, element) { const elementKey = this.getElementKey(element); this.visitedElements.add(elementKey); try { console.log(`🖱️ Clicking: ${element.text || element.href}`); if (element.href && element.type === 'link') { // For links, navigate directly await page.goto(element.href, { waitUntil: 'networkidle2' }); } else { // For buttons and other interactive elements, try to click by coordinates await page.mouse.click(element.x + element.width/2, element.y + element.height/2); // Wait for potential navigation or page changes await new Promise(resolve => setTimeout(resolve, this.options.waitTime)); } return true; } catch (error) { console.error(`❌ Failed to click element: ${error.message}`); return false; } } completeCurrentFlow() { if (!this.currentFlow) { return null; } this.currentFlow.completed = true; this.currentFlow.endTime = new Date().toISOString(); this.currentFlow.metadata.duration = new Date(this.currentFlow.endTime) - new Date(this.currentFlow.startTime); console.log(`✅ Completed flow: ${this.currentFlow.type} with ${this.currentFlow.steps.length} steps`); const completedFlow = this.currentFlow; this.currentFlow = null; return completedFlow; } getAllFlows() { return Array.from(this.flows.values()); } getCompletedFlows() { return this.getAllFlows().filter(flow => flow.completed); } getCurrentFlow() { return this.currentFlow; } hasReachedMaxDepth() { return this.currentFlow && this.currentFlow.steps.length >= this.options.maxFlowDepth; } hasReachedMaxFlows() { return this.getCompletedFlows().length >= this.options.maxFlows; } generateFlowSummary() { const flows = this.getAllFlows(); const completed = this.getCompletedFlows(); return { totalFlows: flows.length, completedFlows: completed.length, totalSteps: flows.reduce((sum, flow) => sum + flow.steps.length, 0), totalUrls: this.urlCapture.visitedUrls.size, averageStepsPerFlow: completed.length > 0 ? Math.round(completed.reduce((sum, flow) => sum + flow.steps.length, 0) / completed.length) : 0, flows: flows.map(flow => ({ id: flow.id, type: flow.type, steps: flow.steps.length, completed: flow.completed, startUrl: flow.startUrl, duration: flow.metadata.duration })) }; } reset() { this.flows.clear(); this.currentFlow = null; this.visitedElements.clear(); this.urlCapture = new URLCapture(this.options); console.log('🔄 Flow tracker reset'); } } module.exports = FlowTracker;