@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
JavaScript
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;