diff --git a/.github/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md similarity index 100% rename from .github/.github/COMMIT_CONVENTION.md rename to .github/COMMIT_CONVENTION.md diff --git a/Dockerfile b/Dockerfile index 719e7814..032dc982 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY vite.config.js ./ COPY tsconfig.json ./ # Expose the frontend port -EXPOSE 5173 +EXPOSE ${FRONTEND_PORT:-5173} # Start the frontend using the client script CMD ["npm", "run", "client", "--", "--host"] \ No newline at end of file diff --git a/README.md b/README.md index ac74d21c..b63cf8a7 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,17 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web -> Note: Maxun is in its early stages of development and currently does not support self-hosting. However, you can run Maxun locally. Self-hosting capabilities are planned for a future release and will be available soon. +# Installation +1. Create a root folder for your project (e.g. 'maxun') +2. Create a file named `.env` in the root folder of the project +3. Example env file can be viewed [here](https://github.com/getmaxun/maxun/blob/master/ENVEXAMPLE). Copy all content of example env to your `.env` file. +4. Choose your installation method below -# Local Installation ### Docker Compose +1. Copy paste the [docker-compose.yml file](https://github.com/getmaxun/maxun/blob/master/docker-compose.yml) into your root folder +2. Ensure you have setup the `.env` file in that same folder +3. Run the command below from a terminal ``` -git clone https://github.com/getmaxun/maxun docker-compose up -d ``` You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/ diff --git a/docker-compose.yml b/docker-compose.yml index 46cc72c4..3c6e3a0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: #build: #context: . #dockerfile: server/Dockerfile - image: getmaxun/maxun-backend:v0.0.5 + image: getmaxun/maxun-backend:v0.0.7 ports: - "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}" env_file: .env @@ -64,28 +64,23 @@ services: - redis - minio volumes: - - ./server:/app/server # Mount server source code for hot reloading - - ./maxun-core:/app/maxun-core # Mount maxun-core for any shared code updates - /var/run/dbus:/var/run/dbus frontend: #build: #context: . #dockerfile: Dockerfile - image: getmaxun/maxun-frontend:v0.0.2 + image: getmaxun/maxun-frontend:v0.0.3 ports: - "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}" env_file: .env environment: PUBLIC_URL: ${PUBLIC_URL} BACKEND_URL: ${BACKEND_URL} - volumes: - - ./:/app # Mount entire frontend app directory for hot reloading - - /app/node_modules # Anonymous volume to prevent overwriting node_modules depends_on: - backend volumes: postgres_data: minio_data: - redis_data: + redis_data: \ No newline at end of file diff --git a/maxun-core/package.json b/maxun-core/package.json index 90ee01b7..36d06aa9 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.5", + "version": "0.0.6", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 09b6578b..a2009d78 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -265,41 +265,72 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, const scrapedData = []; while (scrapedData.length < limit) { - // Get all parent elements matching the listSelector - const parentElements = Array.from(document.querySelectorAll(listSelector)); + let parentElements = Array.from(document.querySelectorAll(listSelector)); + + // If we only got one element or none, try a more generic approach + if (limit > 1 && parentElements.length <= 1) { + const [containerSelector, _] = listSelector.split('>').map(s => s.trim()); + const container = document.querySelector(containerSelector); + + if (container) { + const allChildren = Array.from(container.children); + + const firstMatch = document.querySelector(listSelector); + if (firstMatch) { + // Get classes from the first matching element + const firstMatchClasses = Array.from(firstMatch.classList); + + // Find similar elements by matching most of their classes + parentElements = allChildren.filter(element => { + const elementClasses = Array.from(element.classList); - // Iterate through each parent element - for (const parent of parentElements) { - if (scrapedData.length >= limit) break; - const record = {}; - - // For each field, select the corresponding element within the parent - for (const [label, { selector, attribute }] of Object.entries(fields)) { - const fieldElement = parent.querySelector(selector); - - if (fieldElement) { - if (attribute === 'innerText') { - record[label] = fieldElement.innerText.trim(); - } else if (attribute === 'innerHTML') { - record[label] = fieldElement.innerHTML.trim(); - } else if (attribute === 'src') { - // Handle relative 'src' URLs - const src = fieldElement.getAttribute('src'); - record[label] = src ? new URL(src, window.location.origin).href : null; - } else if (attribute === 'href') { - // Handle relative 'href' URLs - const href = fieldElement.getAttribute('href'); - record[label] = href ? new URL(href, window.location.origin).href : null; - } else { - record[label] = fieldElement.getAttribute(attribute); + // Element should share at least 70% of classes with the first match + const commonClasses = firstMatchClasses.filter(cls => + elementClasses.includes(cls)); + return commonClasses.length >= Math.floor(firstMatchClasses.length * 0.7); + }); + } } - } } - scrapedData.push(record); - } + + // Iterate through each parent element + for (const parent of parentElements) { + if (scrapedData.length >= limit) break; + const record = {}; + + // For each field, select the corresponding element within the parent + for (const [label, { selector, attribute }] of Object.entries(fields)) { + const fieldElement = parent.querySelector(selector); + + if (fieldElement) { + if (attribute === 'innerText') { + record[label] = fieldElement.innerText.trim(); + } else if (attribute === 'innerHTML') { + record[label] = fieldElement.innerHTML.trim(); + } else if (attribute === 'src') { + // Handle relative 'src' URLs + const src = fieldElement.getAttribute('src'); + record[label] = src ? new URL(src, window.location.origin).href : null; + } else if (attribute === 'href') { + // Handle relative 'href' URLs + const href = fieldElement.getAttribute('href'); + record[label] = href ? new URL(href, window.location.origin).href : null; + } else { + record[label] = fieldElement.getAttribute(attribute); + } + } + } + scrapedData.push(record); + } + + // If we've processed all available elements and still haven't reached the limit, + // break to avoid infinite loop + if (parentElements.length === 0 || scrapedData.length >= parentElements.length) { + break; + } } - return scrapedData - }; + return scrapedData; +}; /** diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index afea8e47..14d8f46e 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -102,7 +102,7 @@ export default class Interpreter extends EventEmitter { }; } - PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch).then(blocker => { + PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']).then(blocker => { this.blocker = blocker; }).catch(err => { this.log(`Failed to initialize ad-blocker:`, Level.ERROR); @@ -192,8 +192,8 @@ export default class Interpreter extends EventEmitter { // const actionable = async (selector: string): Promise => { // try { // const proms = [ - // page.isEnabled(selector, { timeout: 5000 }), - // page.isVisible(selector, { timeout: 5000 }), + // page.isEnabled(selector, { timeout: 10000 }), + // page.isVisible(selector, { timeout: 10000 }), // ]; // return await Promise.all(proms).then((bools) => bools.every((x) => x)); @@ -214,6 +214,17 @@ export default class Interpreter extends EventEmitter { // return []; // }), // ).then((x) => x.flat()); + + const presentSelectors: SelectorArray = await Promise.all( + selectors.map(async (selector) => { + try { + await page.waitForSelector(selector, { state: 'attached' }); + return [selector]; + } catch (e) { + return []; + } + }), + ).then((x) => x.flat()); const action = workflowCopy[workflowCopy.length - 1]; @@ -233,7 +244,7 @@ export default class Interpreter extends EventEmitter { ...p, [cookie.name]: cookie.value, }), {}), - selectors, + selectors: presentSelectors, }; } @@ -506,7 +517,11 @@ export default class Interpreter extends EventEmitter { try { await executeAction(invokee, methodName, step.args); } catch (error) { - await executeAction(invokee, methodName, [step.args[0], { force: true }]); + try{ + await executeAction(invokee, methodName, [step.args[0], { force: true }]); + } catch (error) { + continue + } } } else { await executeAction(invokee, methodName, step.args); @@ -767,6 +782,8 @@ export default class Interpreter extends EventEmitter { public async run(page: Page, params?: ParamType): Promise { this.log('Starting the workflow.', Level.LOG); const context = page.context(); + + page.setDefaultNavigationTimeout(100000); // Check proxy settings from context options const contextOptions = (context as any)._options; diff --git a/maxun-core/src/utils/concurrency.ts b/maxun-core/src/utils/concurrency.ts index e1ebb87b..56c15fd9 100644 --- a/maxun-core/src/utils/concurrency.ts +++ b/maxun-core/src/utils/concurrency.ts @@ -3,36 +3,36 @@ */ export default class Concurrency { /** - * Maximum number of workers running in parallel. If set to `null`, there is no limit. - */ + * Maximum number of workers running in parallel. If set to `null`, there is no limit. + */ maxConcurrency: number = 1; /** - * Number of currently active workers. - */ + * Number of currently active workers. + */ activeWorkers: number = 0; /** - * Queue of jobs waiting to be completed. - */ + * Queue of jobs waiting to be completed. + */ private jobQueue: Function[] = []; /** - * "Resolve" callbacks of the waitForCompletion() promises. - */ + * "Resolve" callbacks of the waitForCompletion() promises. + */ private waiting: Function[] = []; /** - * Constructs a new instance of concurrency manager. - * @param {number} maxConcurrency Maximum number of workers running in parallel. - */ + * Constructs a new instance of concurrency manager. + * @param {number} maxConcurrency Maximum number of workers running in parallel. + */ constructor(maxConcurrency: number) { this.maxConcurrency = maxConcurrency; } /** - * Takes a waiting job out of the queue and runs it. - */ + * Takes a waiting job out of the queue and runs it. + */ private runNextJob(): void { const job = this.jobQueue.pop(); @@ -53,12 +53,12 @@ export default class Concurrency { } /** - * Pass a job (a time-demanding async function) to the concurrency manager. \ - * The time of the job's execution depends on the concurrency manager itself - * (given a generous enough `maxConcurrency` value, it might be immediate, - * but this is not guaranteed). - * @param worker Async function to be executed (job to be processed). - */ + * Pass a job (a time-demanding async function) to the concurrency manager. \ + * The time of the job's execution depends on the concurrency manager itself + * (given a generous enough `maxConcurrency` value, it might be immediate, + * but this is not guaranteed). + * @param worker Async function to be executed (job to be processed). + */ addJob(job: () => Promise): void { // console.debug("Adding a worker!"); this.jobQueue.push(job); @@ -72,11 +72,11 @@ export default class Concurrency { } /** - * Waits until there is no running nor waiting job. \ - * If the concurrency manager is idle at the time of calling this function, - * it waits until at least one job is completed (can be "presubscribed"). - * @returns Promise, resolved after there is no running/waiting worker. - */ + * Waits until there is no running nor waiting job. \ + * If the concurrency manager is idle at the time of calling this function, + * it waits until at least one job is completed (can be "presubscribed"). + * @returns Promise, resolved after there is no running/waiting worker. + */ waitForCompletion(): Promise { return new Promise((res) => { this.waiting.push(res); diff --git a/package.json b/package.json index 84ed59ea..866c3dd2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "jwt-decode": "^4.0.0", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.5", + "maxun-core": "^0.0.6", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", diff --git a/server/Dockerfile b/server/Dockerfile index ae26e8eb..e738f252 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.40.0-jammy +FROM mcr.microsoft.com/playwright:v1.46.0-noble # Set working directory WORKDIR /app @@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y \ # RUN chmod +x ./start.sh # Expose the backend port -EXPOSE 8080 +EXPOSE ${BACKEND_PORT:-8080} # Start the backend using the start script CMD ["npm", "run", "server"] \ No newline at end of file diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 417e8964..31aceada 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -104,7 +104,7 @@ export class RemoteBrowser { } catch { return url; } - } + } /** * Determines if a URL change is significant enough to emit @@ -130,11 +130,11 @@ export class RemoteBrowser { }); // Handle page load events with retry mechanism - page.on('load', async () => { + page.on('load', async () => { const injectScript = async (): Promise => { try { await page.waitForLoadState('networkidle', { timeout: 5000 }); - + await page.evaluate(getInjectableScript()); return true; } catch (error: any) { @@ -148,6 +148,19 @@ export class RemoteBrowser { }); } + private getUserAgent() { + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.140 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:117.0) Gecko/20100101 Firefox/117.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.1938.81 Safari/537.36 Edg/116.0.1938.81', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.96 Safari/537.36 OPR/101.0.4843.25', + 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.62 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0', + ]; + + return userAgents[Math.floor(Math.random() * userAgents.length)]; + } + /** * An asynchronous constructor for asynchronously initialized properties. * Must be called right after creating an instance of RemoteBrowser class. @@ -155,37 +168,17 @@ export class RemoteBrowser { * @returns {Promise} */ public initialize = async (userId: string): Promise => { - // const launchOptions = { - // headless: true, - // proxy: options.launchOptions?.proxy, - // chromiumSandbox: false, - // args: [ - // '--no-sandbox', - // '--disable-setuid-sandbox', - // '--headless=new', - // '--disable-gpu', - // '--disable-dev-shm-usage', - // '--disable-software-rasterizer', - // '--in-process-gpu', - // '--disable-infobars', - // '--single-process', - // '--no-zygote', - // '--disable-notifications', - // '--disable-extensions', - // '--disable-background-timer-throttling', - // ...(options.launchOptions?.args || []) - // ], - // env: { - // ...process.env, - // CHROMIUM_FLAGS: '--disable-gpu --no-sandbox --headless=new' - // } - // }; - // console.log('Launch options before:', options.launchOptions); - // this.browser = (await options.browser.launch(launchOptions)); - - // console.log('Launch options after:', options.launchOptions) this.browser = (await chromium.launch({ headless: true, + args: [ + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=IsolateOrigins,site-per-process", + "--disable-site-isolation-trials", + "--disable-extensions", + "--no-sandbox", + "--disable-dev-shm-usage", + ], })); const proxyConfig = await getDecryptedProxyConfig(userId); let proxyOptions: { server: string, username?: string, password?: string } = { server: '' }; @@ -201,7 +194,7 @@ export class RemoteBrowser { const contextOptions: any = { viewport: { height: 400, width: 900 }, // recordVideo: { dir: 'videos/' } - // Force reduced motion to prevent animation issues + // Force reduced motion to prevent animation issues reducedMotion: 'reduce', // Force JavaScript to be enabled javaScriptEnabled: true, @@ -210,7 +203,8 @@ export class RemoteBrowser { // Disable hardware acceleration forcedColors: 'none', isMobile: false, - hasTouch: false + hasTouch: false, + userAgent: this.getUserAgent(), }; if (proxyOptions.server) { @@ -220,19 +214,38 @@ export class RemoteBrowser { password: proxyOptions.password ? proxyOptions.password : undefined, }; } - const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.38 Safari/537.36"; - - contextOptions.userAgent = browserUserAgent; this.context = await this.browser.newContext(contextOptions); + await this.context.addInitScript( + `const defaultGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" + ).get; + defaultGetter.apply(navigator); + defaultGetter.toString(); + Object.defineProperty(Navigator.prototype, "webdriver", { + set: undefined, + enumerable: true, + configurable: true, + get: new Proxy(defaultGetter, { + apply: (target, thisArg, args) => { + Reflect.apply(target, thisArg, args); + return false; + }, + }), + }); + const patchedGetter = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "webdriver" + ).get; + patchedGetter.apply(navigator); + patchedGetter.toString();` + ); this.currentPage = await this.context.newPage(); await this.setupPageEventListeners(this.currentPage); - // await this.currentPage.setExtraHTTPHeaders({ - // 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' - // }); - const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch); + const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']); await blocker.enableBlockingInPage(this.currentPage); this.client = await this.currentPage.context().newCDPSession(this.currentPage); await blocker.disableBlockingInPage(this.currentPage); @@ -456,7 +469,7 @@ export class RemoteBrowser { this.currentPage = newPage; if (this.currentPage) { await this.setupPageEventListeners(this.currentPage); - + this.client = await this.currentPage.context().newCDPSession(this.currentPage); await this.subscribeToScreencast(); } else { diff --git a/server/src/browser-management/inputHandlers.ts b/server/src/browser-management/inputHandlers.ts index d6902b3f..bf365053 100644 --- a/server/src/browser-management/inputHandlers.ts +++ b/server/src/browser-management/inputHandlers.ts @@ -6,7 +6,7 @@ import { Socket } from 'socket.io'; import logger from "../logger"; -import { Coordinates, ScrollDeltas, KeyboardInput } from '../types'; +import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types'; import { browserPool } from "../server"; import { WorkflowGenerator } from "../workflow-management/classes/Generator"; import { Page } from "playwright"; @@ -223,6 +223,53 @@ const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, co logger.log('debug', `Key ${key} pressed`); }; +/** + * Handles the date selection event. + * @param generator - the workflow generator {@link Generator} + * @param page - the active page of the remote browser + * @param data - the data of the date selection event {@link DatePickerEventData} + * @category BrowserManagement + */ +const handleDateSelection = async (generator: WorkflowGenerator, page: Page, data: DatePickerEventData) => { + await generator.onDateSelection(page, data); + logger.log('debug', `Date ${data.value} selected`); +} + +const onDateSelection = async (data: DatePickerEventData) => { + logger.log('debug', 'Handling date selection event emitted from client'); + await handleWrapper(handleDateSelection, data); +} + +const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { + await generator.onDropdownSelection(page, data); + logger.log('debug', `Dropdown value ${data.value} selected`); +} + +const onDropdownSelection = async (data: { selector: string, value: string }) => { + logger.log('debug', 'Handling dropdown selection event emitted from client'); + await handleWrapper(handleDropdownSelection, data); +} + +const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { + await generator.onTimeSelection(page, data); + logger.log('debug', `Time value ${data.value} selected`); +} + +const onTimeSelection = async (data: { selector: string, value: string }) => { + logger.log('debug', 'Handling time selection event emitted from client'); + await handleWrapper(handleTimeSelection, data); +} + +const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { + await generator.onDateTimeLocalSelection(page, data); + logger.log('debug', `DateTime Local value ${data.value} selected`); +} + +const onDateTimeLocalSelection = async (data: { selector: string, value: string }) => { + logger.log('debug', 'Handling datetime-local selection event emitted from client'); + await handleWrapper(handleDateTimeLocalSelection, data); +} + /** * A wrapper function for handling the keyup event. * @param keyboardInput - the keyboard input of the keyup event @@ -378,6 +425,10 @@ const registerInputHandlers = (socket: Socket) => { socket.on("input:refresh", onRefresh); socket.on("input:back", onGoBack); socket.on("input:forward", onGoForward); + socket.on("input:date", onDateSelection); + socket.on("input:dropdown", onDropdownSelection); + socket.on("input:time", onTimeSelection); + socket.on("input:datetime-local", onDateTimeLocalSelection); socket.on("action", onGenerateAction); }; diff --git a/server/src/swagger/config.ts b/server/src/swagger/config.ts index c9c12210..bf115ed1 100644 --- a/server/src/swagger/config.ts +++ b/server/src/swagger/config.ts @@ -1,13 +1,25 @@ import swaggerJSDoc from 'swagger-jsdoc'; import path from 'path'; +import fs from 'fs'; + +// Dynamically determine API file paths +const jsFiles = [path.join(__dirname, '../api/*.js')] +const tsFiles = [path.join(__dirname, '../api/*.ts')] + +let apis = fs.existsSync(jsFiles[0]) ? jsFiles : tsFiles; + +if (!apis) { + throw new Error('No valid API files found! Ensure either .js or .ts files exist in the ../api/ directory.'); +} const options = { definition: { openapi: '3.0.0', info: { - title: 'Maxun API Documentation', + title: 'Website to API', version: '1.0.0', - description: 'API documentation for Maxun (https://github.com/getmaxun/maxun)', + description: + 'Maxun lets you get the data your robot extracted and run robots via API. All you need to do is input the Maxun API key by clicking Authorize below.', }, components: { securitySchemes: { @@ -15,7 +27,8 @@ const options = { type: 'apiKey', in: 'header', name: 'x-api-key', - description: 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.', + description: + 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.', }, }, }, @@ -25,7 +38,7 @@ const options = { }, ], }, - apis: process.env.NODE_ENV === 'production' ? [path.join(__dirname, '../api/*.js')] : [path.join(__dirname, '../api/*.ts')] + apis, }; const swaggerSpec = swaggerJSDoc(options); diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 4fe761f1..f2e327ef 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -20,6 +20,16 @@ export interface Coordinates { y: number; } +/** + * interface to handle date picker events. + * @category Types + */ +export interface DatePickerEventData { + coordinates: Coordinates; + selector: string; + value: string; +} + /** * Holds the deltas of a wheel/scroll event. * @category Types diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index c9dc3385..609541de 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -1,4 +1,4 @@ -import { Action, ActionType, Coordinates, TagName } from "../../types"; +import { Action, ActionType, Coordinates, TagName, DatePickerEventData } from "../../types"; import { WhereWhatPair, WorkflowFile } from 'maxun-core'; import logger from "../../logger"; import { Socket } from "socket.io"; @@ -140,19 +140,22 @@ export class WorkflowGenerator { socket.on('decision', async ({ pair, actionType, decision }) => { const id = browserPool.getActiveBrowserId(); if (id) { - const activeBrowser = browserPool.getRemoteBrowser(id); - const currentPage = activeBrowser?.getCurrentPage(); - if (decision) { + // const activeBrowser = browserPool.getRemoteBrowser(id); + // const currentPage = activeBrowser?.getCurrentPage(); + if (!decision) { switch (actionType) { case 'customAction': - pair.where.selectors = [this.generatedData.lastUsedSelector]; + // pair.where.selectors = [this.generatedData.lastUsedSelector]; + pair.where.selectors = pair.where.selectors.filter( + (selector: string) => selector !== this.generatedData.lastUsedSelector + ); break; default: break; } } - if (currentPage) { - await this.addPairToWorkflowAndNotifyClient(pair, currentPage); - } + // if (currentPage) { + // await this.addPairToWorkflowAndNotifyClient(pair, currentPage); + // } } }) socket.on('updatePair', (data) => { @@ -252,6 +255,85 @@ export class WorkflowGenerator { logger.log('info', `Workflow emitted`); }; + public onDateSelection = async (page: Page, data: DatePickerEventData) => { + const { selector, value } = data; + + try { + await page.fill(selector, value); + } catch (error) { + console.error("Failed to fill date value:", error); + } + + const pair: WhereWhatPair = { + where: { url: this.getBestUrl(page.url()) }, + what: [{ + action: 'fill', + args: [selector, value], + }], + }; + + await this.addPairToWorkflowAndNotifyClient(pair, page); + }; + + public onDropdownSelection = async (page: Page, data: { selector: string, value: string }) => { + const { selector, value } = data; + + try { + await page.selectOption(selector, value); + } catch (error) { + console.error("Failed to fill date value:", error); + } + + const pair: WhereWhatPair = { + where: { url: this.getBestUrl(page.url()) }, + what: [{ + action: 'selectOption', + args: [selector, value], + }], + }; + + await this.addPairToWorkflowAndNotifyClient(pair, page); + }; + + public onTimeSelection = async (page: Page, data: { selector: string, value: string }) => { + const { selector, value } = data; + + try { + await page.fill(selector, value); + } catch (error) { + console.error("Failed to set time value:", error); + } + + const pair: WhereWhatPair = { + where: { url: this.getBestUrl(page.url()) }, + what: [{ + action: 'fill', + args: [selector, value], + }], + }; + + await this.addPairToWorkflowAndNotifyClient(pair, page); + }; + + public onDateTimeLocalSelection = async (page: Page, data: { selector: string, value: string }) => { + const { selector, value } = data; + + try { + await page.fill(selector, value); + } catch (error) { + console.error("Failed to fill datetime-local value:", error); + } + + const pair: WhereWhatPair = { + where: { url: this.getBestUrl(page.url()) }, + what: [{ + action: 'fill', + args: [selector, value], + }], + }; + + await this.addPairToWorkflowAndNotifyClient(pair, page); + }; /** * Generates a pair for the click event. @@ -263,6 +345,81 @@ export class WorkflowGenerator { let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) }; const selector = await this.generateSelector(page, coordinates, ActionType.Click); logger.log('debug', `Element's selector: ${selector}`); + + const elementInfo = await getElementInformation(page, coordinates, '', false); + console.log("Element info: ", elementInfo); + + // Check if clicked element is a select dropdown + const isDropdown = elementInfo?.tagName === 'SELECT'; + + if (isDropdown && elementInfo.innerHTML) { + // Parse options from innerHTML + const options = elementInfo.innerHTML + .split(' { + const valueMatch = optionHtml.match(/value="([^"]*)"/); + const disabledMatch = optionHtml.includes('disabled="disabled"'); + const selectedMatch = optionHtml.includes('selected="selected"'); + + // Extract text content between > and + const textMatch = optionHtml.match(/>([^<]*)} */ private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => { - const elementInfo = await getElementInformation(page, coordinates); - + const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList); const selectorBasedOnCustomAction = (this.getList === true) - ? await getNonUniqueSelectors(page, coordinates) + ? await getNonUniqueSelectors(page, coordinates, this.listSelector) : await getSelectors(page, coordinates); const bestSelector = getBestSelectorForAction( @@ -570,16 +726,14 @@ export class WorkflowGenerator { * @returns {Promise} */ public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => { - const rect = await getRect(page, coordinates); + const rect = await getRect(page, coordinates, this.listSelector, this.getList); const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click); - const elementInfo = await getElementInformation(page, coordinates); + const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList); if (rect) { if (this.getList === true) { if (this.listSelector !== '') { const childSelectors = await getChildSelectors(page, this.listSelector || ''); this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors }) - console.log(`Child Selectors: ${childSelectors}`) - console.log(`Parent Selector: ${this.listSelector}`) } else { this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); } diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index 917ac561..240f8921 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -1,16 +1,161 @@ import { Page } from "playwright"; -import { Action, ActionType, Coordinates, TagName } from "../types"; +import { Coordinates } from "../types"; import { WhereWhatPair, WorkflowFile } from "maxun-core"; import logger from "../logger"; -import { getBestSelectorForAction } from "./utils"; - -/*TODO: -1. Handle TS errors (here we definetly know better) -2. Add pending function descriptions + thought process (esp. selector generation) -*/ type Workflow = WorkflowFile["workflow"]; +/** + * Checks the basic info about an element and returns a {@link BaseActionInfo} object. + * If the element is not found, returns undefined. + * @param page The page instance. + * @param coordinates Coordinates of an element. + * @category WorkflowManagement-Selectors + * @returns {Promise} + */ +export const getElementInformation = async ( + page: Page, + coordinates: Coordinates, + listSelector: string, + getList: boolean +) => { + try { + if (!getList || listSelector !== '') { + const elementInfo = await page.evaluate( + async ({ x, y }) => { + const el = document.elementFromPoint(x, y) as HTMLElement; + if (el) { + const { parentElement } = el; + const element = parentElement?.tagName === 'A' ? parentElement : el; + let info: { + tagName: string; + hasOnlyText?: boolean; + innerText?: string; + url?: string; + imageUrl?: string; + attributes?: Record; + innerHTML?: string; + outerHTML?: string; + } = { + tagName: element?.tagName ?? '', + }; + if (element) { + info.attributes = Array.from(element.attributes).reduce( + (acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, + {} as Record + ); + } + // Gather specific information based on the tag + if (element?.tagName === 'A') { + info.url = (element as HTMLAnchorElement).href; + info.innerText = element.innerText ?? ''; + } else if (element?.tagName === 'IMG') { + info.imageUrl = (element as HTMLImageElement).src; + } else if (element?.tagName === 'SELECT') { + const selectElement = element as HTMLSelectElement; + info.innerText = selectElement.options[selectElement.selectedIndex]?.text ?? ''; + info.attributes = { + ...info.attributes, + selectedValue: selectElement.value, + }; + } else if (element?.tagName === 'INPUT' && (element as HTMLInputElement).type === 'time' || (element as HTMLInputElement).type === 'date') { + info.innerText = (element as HTMLInputElement).value; + } else { + info.hasOnlyText = element?.children?.length === 0 && + element?.innerText?.length > 0; + info.innerText = element?.innerText ?? ''; + } + info.innerHTML = element.innerHTML; + info.outerHTML = element.outerHTML; + return info; + } + return null; + }, + { x: coordinates.x, y: coordinates.y }, + ); + return elementInfo; + } else { + const elementInfo = await page.evaluate( + async ({ x, y }) => { + const originalEl = document.elementFromPoint(x, y) as HTMLElement; + if (originalEl) { + let element = originalEl; + + while (element.parentElement) { + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); + + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; + + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + element = element.parentElement; + } else { + break; + } + } + + let info: { + tagName: string; + hasOnlyText?: boolean; + innerText?: string; + url?: string; + imageUrl?: string; + attributes?: Record; + innerHTML?: string; + outerHTML?: string; + } = { + tagName: element?.tagName ?? '', + }; + + if (element) { + info.attributes = Array.from(element.attributes).reduce( + (acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, + {} as Record + ); + } + + if (element?.tagName === 'A') { + info.url = (element as HTMLAnchorElement).href; + info.innerText = element.innerText ?? ''; + } else if (element?.tagName === 'IMG') { + info.imageUrl = (element as HTMLImageElement).src; + } else { + info.hasOnlyText = element?.children?.length === 0 && + element?.innerText?.length > 0; + info.innerText = element?.innerText ?? ''; + } + + info.innerHTML = element.innerHTML; + info.outerHTML = element.outerHTML; + return info; + } + return null; + }, + { x: coordinates.x, y: coordinates.y }, + ); + return elementInfo; + } + } catch (error) { + const { message, stack } = error as Error; + console.error('Error while retrieving selector:', message); + console.error('Stack:', stack); + } +}; + /** * Returns a {@link Rectangle} object representing * the coordinates, width, height and corner points of the element. @@ -20,182 +165,89 @@ type Workflow = WorkflowFile["workflow"]; * @category WorkflowManagement-Selectors * @returns {Promise} */ -export const getElementInformation = async ( - page: Page, - coordinates: Coordinates -) => { +export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => { try { - const elementInfo = await page.evaluate( - async ({ x, y }) => { - const originalEl = document.elementFromPoint(x, y) as HTMLElement; - if (originalEl) { - let element = originalEl; - - // if (originalEl.tagName === 'A') { - // element = originalEl; - // } else if (originalEl.parentElement?.tagName === 'A') { - // element = originalEl.parentElement; - // } else { - // Generic parent finding logic based on visual containment - const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', - 'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME', - 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET', - 'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT', - 'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT', - 'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET', - 'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A' - ]; - while (element.parentElement) { - const parentRect = element.parentElement.getBoundingClientRect(); - const childRect = element.getBoundingClientRect(); + if (!getList || listSelector !== '') { + const rect = await page.evaluate( + async ({ x, y }) => { + const el = document.elementFromPoint(x, y) as HTMLElement; + if (el) { + const { parentElement } = el; + // Match the logic in recorder.ts for link clicks + const element = parentElement?.tagName === 'A' ? parentElement : el; + const rectangle = element?.getBoundingClientRect(); + if (rectangle) { + return { + x: rectangle.x, + y: rectangle.y, + width: rectangle.width, + height: rectangle.height, + top: rectangle.top, + right: rectangle.right, + bottom: rectangle.bottom, + left: rectangle.left, + }; + } + } + }, + { x: coordinates.x, y: coordinates.y }, + ); + return rect; + } else { + const rect = await page.evaluate( + async ({ x, y }) => { + const originalEl = document.elementFromPoint(x, y) as HTMLElement; + if (originalEl) { + let element = originalEl; - if (!containerTags.includes(element.parentElement.tagName)) { - break; + while (element.parentElement) { + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); + + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; + + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + element = element.parentElement; + } else { + break; + } } - // Check if parent visually contains the child - const fullyContained = - parentRect.left <= childRect.left && - parentRect.right >= childRect.right && - parentRect.top <= childRect.top && - parentRect.bottom >= childRect.bottom; + const rectangle = element?.getBoundingClientRect(); - // Additional checks for more comprehensive containment - const significantOverlap = - (childRect.width * childRect.height) / - (parentRect.width * parentRect.height) > 0.5; - - if (fullyContained && significantOverlap) { - element = element.parentElement; - } else { - break; - // } - } } - - let info: { - tagName: string; - hasOnlyText?: boolean; - innerText?: string; - url?: string; - imageUrl?: string; - attributes?: Record; - innerHTML?: string; - outerHTML?: string; - } = { - tagName: element?.tagName ?? '', - }; - - if (element) { - info.attributes = Array.from(element.attributes).reduce( - (acc, attr) => { - acc[attr.name] = attr.value; - return acc; - }, - {} as Record - ); - } - - // Existing tag-specific logic - if (element?.tagName === 'A') { - info.url = (element as HTMLAnchorElement).href; - info.innerText = element.innerText ?? ''; - } else if (element?.tagName === 'IMG') { - info.imageUrl = (element as HTMLImageElement).src; - } else { - info.hasOnlyText = element?.children?.length === 0 && - element?.innerText?.length > 0; - info.innerText = element?.innerText ?? ''; - } - - info.innerHTML = element.innerHTML; - info.outerHTML = element.outerHTML; - return info; - } - return null; - }, - { x: coordinates.x, y: coordinates.y }, - ); - return elementInfo; - } catch (error) { - const { message, stack } = error as Error; - console.error('Error while retrieving selector:', message); - console.error('Stack:', stack); - } -}; - -export const getRect = async (page: Page, coordinates: Coordinates) => { - try { - const rect = await page.evaluate( - async ({ x, y }) => { - const originalEl = document.elementFromPoint(x, y) as HTMLElement; - if (originalEl) { - let element = originalEl; - - // if (originalEl.tagName === 'A') { - // element = originalEl; - // } else if (originalEl.parentElement?.tagName === 'A') { - // element = originalEl.parentElement; - // } else { - const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', - 'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME', - 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET', - 'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT', - 'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT', - 'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET', - 'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A' - ]; - while (element.parentElement) { - const parentRect = element.parentElement.getBoundingClientRect(); - const childRect = element.getBoundingClientRect(); - - if (!containerTags.includes(element.parentElement.tagName)) { - break; + if (rectangle) { + return { + x: rectangle.x, + y: rectangle.y, + width: rectangle.width, + height: rectangle.height, + top: rectangle.top, + right: rectangle.right, + bottom: rectangle.bottom, + left: rectangle.left, + }; } - - - const fullyContained = - parentRect.left <= childRect.left && - parentRect.right >= childRect.right && - parentRect.top <= childRect.top && - parentRect.bottom >= childRect.bottom; - - const significantOverlap = - (childRect.width * childRect.height) / - (parentRect.width * parentRect.height) > 0.5; - - if (fullyContained && significantOverlap) { - element = element.parentElement; - } else { - break; - // } - }} - - //element = element?.parentElement?.tagName === 'A' ? element?.parentElement : element; - const rectangle = element?.getBoundingClientRect(); - - if (rectangle) { - return { - x: rectangle.x, - y: rectangle.y, - width: rectangle.width, - height: rectangle.height, - top: rectangle.top, - right: rectangle.right, - bottom: rectangle.bottom, - left: rectangle.left, - }; } - } - }, - { x: coordinates.x, y: coordinates.y }, - ); - return rect; + return null; + }, + { x: coordinates.x, y: coordinates.y }, + ); + return rect; + } } catch (error) { const { message, stack } = error as Error; logger.log('error', `Error while retrieving selector: ${message}`); logger.log('error', `Stack: ${stack}`); } -} +}; /** @@ -809,86 +861,123 @@ interface SelectorResult { * @returns {Promise} */ -export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise => { +export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates, listSelector: string): Promise => { try { - const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { - function getNonUniqueSelector(element: HTMLElement): string { - let selector = element.tagName.toLowerCase(); + if (!listSelector) { + console.log(`NON UNIQUE: MODE 1`) + const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + function getNonUniqueSelector(element: HTMLElement): string { + let selector = element.tagName.toLowerCase(); - if (element.className) { - const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); - if (classes.length > 0) { - const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); - if (validClasses.length > 0) { - selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + if (element.className) { + const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); + if (classes.length > 0) { + const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); + if (validClasses.length > 0) { + selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + } } } + + return selector; } - return selector; - } + function getSelectorPath(element: HTMLElement | null): string { + const path: string[] = []; + let depth = 0; + const maxDepth = 2; - function getSelectorPath(element: HTMLElement | null): string { - const path: string[] = []; - let depth = 0; - const maxDepth = 2; + while (element && element !== document.body && depth < maxDepth) { + const selector = getNonUniqueSelector(element); + path.unshift(selector); + element = element.parentElement; + depth++; + } - while (element && element !== document.body && depth < maxDepth) { - const selector = getNonUniqueSelector(element); - path.unshift(selector); - element = element.parentElement; - depth++; + return path.join(' > '); } - return path.join(' > '); - } + const originalEl = document.elementFromPoint(x, y) as HTMLElement; + if (!originalEl) return null; - const originalEl = document.elementFromPoint(x, y) as HTMLElement; - if (!originalEl) return null; + let element = originalEl; - let element = originalEl; + // if (listSelector === '') { + while (element.parentElement) { + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); - const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', - 'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME', - 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET', - 'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT', - 'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT', - 'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET', - 'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A' - ]; - - while (element.parentElement) { - const parentRect = element.parentElement.getBoundingClientRect(); - const childRect = element.getBoundingClientRect(); + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; - if (!containerTags.includes(element.parentElement.tagName)) { - break; + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + element = element.parentElement; + } else { + break; + } + } + // } + + const generalSelector = getSelectorPath(element); + return { + generalSelector, + }; + }, coordinates); + return selectors || { generalSelector: '' }; + } else { + console.log(`NON UNIQUE: MODE 2`) + const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + function getNonUniqueSelector(element: HTMLElement): string { + let selector = element.tagName.toLowerCase(); + + if (element.className) { + const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); + if (classes.length > 0) { + const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); + if (validClasses.length > 0) { + selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + } + + return selector; } - const fullyContained = - parentRect.left <= childRect.left && - parentRect.right >= childRect.right && - parentRect.top <= childRect.top && - parentRect.bottom >= childRect.bottom; + function getSelectorPath(element: HTMLElement | null): string { + const path: string[] = []; + let depth = 0; + const maxDepth = 2; - const significantOverlap = - (childRect.width * childRect.height) / - (parentRect.width * parentRect.height) > 0.5; + while (element && element !== document.body && depth < maxDepth) { + const selector = getNonUniqueSelector(element); + path.unshift(selector); + element = element.parentElement; + depth++; + } - if (fullyContained && significantOverlap) { - element = element.parentElement; - } else { - break; - } + return path.join(' > '); + } + + const originalEl = document.elementFromPoint(x, y) as HTMLElement; + if (!originalEl) return null; + + let element = originalEl; + + const generalSelector = getSelectorPath(element); + return { + generalSelector, + }; + }, coordinates); + return selectors || { generalSelector: '' }; } - const generalSelector = getSelectorPath(element); - return { - generalSelector, - }; - }, coordinates); - - return selectors || { generalSelector: '' }; } catch (error) { console.error('Error in getNonUniqueSelectors:', error); return { generalSelector: '' }; diff --git a/src/components/atoms/DatePicker.tsx b/src/components/atoms/DatePicker.tsx new file mode 100644 index 00000000..30d3b869 --- /dev/null +++ b/src/components/atoms/DatePicker.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { useSocketStore } from '../../context/socket'; +import { Coordinates } from './canvas'; + +interface DatePickerProps { + coordinates: Coordinates; + selector: string; + onClose: () => void; +} + +const DatePicker: React.FC = ({ coordinates, selector, onClose }) => { + const { socket } = useSocketStore(); + const [selectedDate, setSelectedDate] = useState(''); + + const handleDateChange = (e: React.ChangeEvent) => { + setSelectedDate(e.target.value); + }; + + const handleConfirm = () => { + if (socket && selectedDate) { + socket.emit('input:date', { + selector, + value: selectedDate + }); + onClose(); + } + }; + + return ( +
+
+ +
+ + +
+
+
+ ); +}; + +export default DatePicker; \ No newline at end of file diff --git a/src/components/atoms/DateTimeLocalPicker.tsx b/src/components/atoms/DateTimeLocalPicker.tsx new file mode 100644 index 00000000..dc62a79b --- /dev/null +++ b/src/components/atoms/DateTimeLocalPicker.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { useSocketStore } from '../../context/socket'; +import { Coordinates } from './canvas'; + +interface DateTimeLocalPickerProps { + coordinates: Coordinates; + selector: string; + onClose: () => void; +} + +const DateTimeLocalPicker: React.FC = ({ coordinates, selector, onClose }) => { + const { socket } = useSocketStore(); + const [selectedDateTime, setSelectedDateTime] = useState(''); + + const handleDateTimeChange = (e: React.ChangeEvent) => { + setSelectedDateTime(e.target.value); + }; + + const handleConfirm = () => { + if (socket && selectedDateTime) { + socket.emit('input:datetime-local', { + selector, + value: selectedDateTime + }); + onClose(); + } + }; + + return ( +
+
+ +
+ + +
+
+
+ ); +}; + +export default DateTimeLocalPicker; \ No newline at end of file diff --git a/src/components/atoms/Dropdown.tsx b/src/components/atoms/Dropdown.tsx new file mode 100644 index 00000000..c7ead64b --- /dev/null +++ b/src/components/atoms/Dropdown.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { useSocketStore } from '../../context/socket'; +import { Coordinates } from './canvas'; + +interface DropdownProps { + coordinates: Coordinates; + selector: string; + options: Array<{ + value: string; + text: string; + disabled: boolean; + selected: boolean; + }>; + onClose: () => void; +} + +const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) => { + const { socket } = useSocketStore(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + const handleSelect = (value: string) => { + if (socket) { + socket.emit('input:dropdown', { selector, value }); + } + onClose(); + }; + + const containerStyle: React.CSSProperties = { + position: 'absolute', + left: coordinates.x, + top: coordinates.y, + zIndex: 1000, + width: '200px', + backgroundColor: 'white', + border: '1px solid rgb(169, 169, 169)', + boxShadow: '0 2px 4px rgba(0,0,0,0.15)', + }; + + const scrollContainerStyle: React.CSSProperties = { + maxHeight: '180px', + overflowY: 'auto', + overflowX: 'hidden', + }; + + const getOptionStyle = (option: any, index: number): React.CSSProperties => ({ + fontSize: '13.333px', + lineHeight: '18px', + padding: '0 3px', + cursor: option.disabled ? 'default' : 'default', + backgroundColor: hoveredIndex === index ? '#0078D7' : + option.selected ? '#0078D7' : + option.disabled ? '#f8f8f8' : 'white', + color: (hoveredIndex === index || option.selected) ? 'white' : + option.disabled ? '#a0a0a0' : 'black', + userSelect: 'none', + }); + + return ( +
+
e.stopPropagation()} + > +
+ {options.map((option, index) => ( +
!option.disabled && setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => !option.disabled && handleSelect(option.value)} + > + {option.text} +
+ ))} +
+
+
+ ); +}; + +export default Dropdown; \ No newline at end of file diff --git a/src/components/atoms/TimePicker.tsx b/src/components/atoms/TimePicker.tsx new file mode 100644 index 00000000..31353c7a --- /dev/null +++ b/src/components/atoms/TimePicker.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { useSocketStore } from '../../context/socket'; +import { Coordinates } from './canvas'; + +interface TimePickerProps { + coordinates: Coordinates; + selector: string; + onClose: () => void; +} + +const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => { + const { socket } = useSocketStore(); + const [hoveredHour, setHoveredHour] = useState(null); + const [hoveredMinute, setHoveredMinute] = useState(null); + const [selectedHour, setSelectedHour] = useState(null); + const [selectedMinute, setSelectedMinute] = useState(null); + + const handleHourSelect = (hour: number) => { + setSelectedHour(hour); + // If minute is already selected, complete the selection + if (selectedMinute !== null) { + const formattedHour = hour.toString().padStart(2, '0'); + const formattedMinute = selectedMinute.toString().padStart(2, '0'); + if (socket) { + socket.emit('input:time', { + selector, + value: `${formattedHour}:${formattedMinute}` + }); + } + onClose(); + } + }; + + const handleMinuteSelect = (minute: number) => { + setSelectedMinute(minute); + // If hour is already selected, complete the selection + if (selectedHour !== null) { + const formattedHour = selectedHour.toString().padStart(2, '0'); + const formattedMinute = minute.toString().padStart(2, '0'); + if (socket) { + socket.emit('input:time', { + selector, + value: `${formattedHour}:${formattedMinute}` + }); + } + onClose(); + } + }; + + const containerStyle: React.CSSProperties = { + position: 'absolute', + left: coordinates.x, + top: coordinates.y, + zIndex: 1000, + display: 'flex', + backgroundColor: 'white', + border: '1px solid rgb(169, 169, 169)', + boxShadow: '0 2px 4px rgba(0,0,0,0.15)', + }; + + const columnStyle: React.CSSProperties = { + width: '60px', + maxHeight: '180px', + overflowY: 'auto', + overflowX: 'hidden', + borderRight: '1px solid rgb(169, 169, 169)', + }; + + const getOptionStyle = (value: number, isHour: boolean): React.CSSProperties => { + const isHovered = isHour ? hoveredHour === value : hoveredMinute === value; + const isSelected = isHour ? selectedHour === value : selectedMinute === value; + + return { + fontSize: '13.333px', + lineHeight: '18px', + padding: '0 3px', + cursor: 'default', + backgroundColor: isSelected ? '#0078D7' : isHovered ? '#0078D7' : 'white', + color: (isSelected || isHovered) ? 'white' : 'black', + userSelect: 'none', + }; + }; + + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = Array.from({ length: 60 }, (_, i) => i); + + return ( +
+
e.stopPropagation()} + > + {/* Hours column */} +
+ {hours.map((hour) => ( +
setHoveredHour(hour)} + onMouseLeave={() => setHoveredHour(null)} + onClick={() => handleHourSelect(hour)} + > + {hour.toString().padStart(2, '0')} +
+ ))} +
+ + {/* Minutes column */} +
+ {minutes.map((minute) => ( +
setHoveredMinute(minute)} + onMouseLeave={() => setHoveredMinute(null)} + onClick={() => handleMinuteSelect(minute)} + > + {minute.toString().padStart(2, '0')} +
+ ))} +
+
+
+ ); +}; + +export default TimePicker; \ No newline at end of file diff --git a/src/components/atoms/canvas.tsx b/src/components/atoms/canvas.tsx index 1dd88e19..e71a4d93 100644 --- a/src/components/atoms/canvas.tsx +++ b/src/components/atoms/canvas.tsx @@ -3,6 +3,10 @@ import { useSocketStore } from '../../context/socket'; import { getMappedCoordinates } from "../../helpers/inputHelpers"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useActionContext } from '../../context/browserActions'; +import DatePicker from './DatePicker'; +import Dropdown from './Dropdown'; +import TimePicker from './TimePicker'; +import DateTimeLocalPicker from './DateTimeLocalPicker'; interface CreateRefCallback { (ref: React.RefObject): void; @@ -31,6 +35,32 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { const getTextRef = useRef(getText); const getListRef = useRef(getList); + const [datePickerInfo, setDatePickerInfo] = React.useState<{ + coordinates: Coordinates; + selector: string; + } | null>(null); + + const [dropdownInfo, setDropdownInfo] = React.useState<{ + coordinates: Coordinates; + selector: string; + options: Array<{ + value: string; + text: string; + disabled: boolean; + selected: boolean; + }>; + } | null>(null); + + const [timePickerInfo, setTimePickerInfo] = React.useState<{ + coordinates: Coordinates; + selector: string; + } | null>(null); + + const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{ + coordinates: Coordinates; + selector: string; + } | null>(null); + const notifyLastAction = (action: string) => { if (lastAction !== action) { setLastAction(action); @@ -44,6 +74,42 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { getListRef.current = getList; }, [getText, getList]); + useEffect(() => { + if (socket) { + socket.on('showDatePicker', (info: {coordinates: Coordinates, selector: string}) => { + setDatePickerInfo(info); + }); + + socket.on('showDropdown', (info: { + coordinates: Coordinates, + selector: string, + options: Array<{ + value: string; + text: string; + disabled: boolean; + selected: boolean; + }>; + }) => { + setDropdownInfo(info); + }); + + socket.on('showTimePicker', (info: {coordinates: Coordinates, selector: string}) => { + setTimePickerInfo(info); + }); + + socket.on('showDateTimePicker', (info: {coordinates: Coordinates, selector: string}) => { + setDateTimeLocalInfo(info); + }); + + return () => { + socket.off('showDatePicker'); + socket.off('showDropdown'); + socket.off('showTimePicker'); + socket.off('showDateTimePicker'); + }; + } + }, [socket]); + const onMouseEvent = useCallback((event: MouseEvent) => { if (socket && canvasRef.current) { // Get the canvas bounding rectangle @@ -146,6 +212,35 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { width={900} style={{ display: 'block' }} /> + {datePickerInfo && ( + setDatePickerInfo(null)} + /> + )} + {dropdownInfo && ( + setDropdownInfo(null)} + /> + )} + {timePickerInfo && ( + setTimePickerInfo(null)} + /> + )} + {dateTimeLocalInfo && ( + setDateTimeLocalInfo(null)} + /> + )} ); diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index e5b26b96..31355b12 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -1,16 +1,4 @@ - - - - - - - - - - - - -import { useTranslation } from "react-i18next"; // Import useTranslation hook +import { useTranslation } from "react-i18next"; import React, { useState, useContext, useEffect } from 'react'; import axios from 'axios'; @@ -62,7 +50,7 @@ export const NavBar: React.FC = ({ return version; } catch (error) { console.error("Failed to fetch latest version:", error); - return null; // Handle errors gracefully + return null; } }; @@ -112,297 +100,207 @@ export const NavBar: React.FC = ({ }; const changeLanguage = (lang: string) => { - i18n.changeLanguage(lang); // Change language dynamically - localStorage.setItem("language", lang); // Persist language to localStorage + i18n.changeLanguage(lang); + localStorage.setItem("language", lang); }; useEffect(() => { const checkForUpdates = async () => { const latestVersion = await fetchLatestVersion(); - setLatestVersion(latestVersion); // Set the latest version state + setLatestVersion(latestVersion); if (latestVersion && latestVersion !== currentVersion) { - setIsUpdateAvailable(true); // Show a notification or highlight the "Upgrade" button + setIsUpdateAvailable(true); } }; checkForUpdates(); }, []); return ( - - - -
- -
- Maxun + +
+ +
Maxun
+
- - - -
- {user ? ( -
- {!isRecording ? ( - <> - - - - - - - {user.email} - - - { - handleMenuClose(); - logout(); - }} - > - {t("logout")} - - - - - ) : ( - <> - - - {t("discard")} - - - - )} - - - - - - - { - changeLanguage("en"); - handleMenuClose(); - }} - > - English - - { - changeLanguage("es"); - handleMenuClose(); - }} - > - Español - - { - changeLanguage("ja"); - handleMenuClose(); - }} - > - 日本語 - - {/* { - changeLanguage("ar"); - handleMenuClose(); - }} - > - العربية - */} - { - changeLanguage("zh"); - handleMenuClose(); - }} - > - 中文 - - { - changeLanguage("de"); - handleMenuClose(); - }} - > - Deutsch - - -
- ) : ( - <> - {t("language")} - - - { - changeLanguage("en"); - handleMenuClose(); - }} - > - English - - { - changeLanguage("es"); - handleMenuClose(); - }} - > - Español - - { - changeLanguage("ja"); - handleMenuClose(); - }} - > - 日本語 - - {/* { - changeLanguage("ar"); - handleMenuClose(); - }} - > - العربية - */} - { - changeLanguage("zh"); - handleMenuClose(); - }} - > - 中文 - - { - changeLanguage("de"); - handleMenuClose(); - }} - > - Deutsch - - - )} - - - - - + { + user ? ( +
+ {!isRecording ? ( + <> + + + + {latestVersion === null ? ( + Checking for updates... + ) : currentVersion === latestVersion ? ( + + 🎉 You're up to date! + + ) : ( + <> + + A new version is available: {latestVersion}. Upgrade to the latest version for bug fixes, enhancements and new features! +
+ View all the new updates + {' '}here. +
+ + + + + {tab === 0 && ( + + +

Run the commands below

+ # cd to project directory (eg: maxun) +
+ cd maxun +
+
+ # pull latest changes +
+ git pull origin master +
+
+ # install dependencies +
+ npm install +
+
+ # start maxun +
+ npm run start +
+
+ )} + {tab === 1 && ( + + +

Run the commands below

+ # cd to project directory (eg: maxun) +
+ cd maxun +
+
+ # stop the working containers +
+ docker-compose down +
+
+ # pull latest docker images +
+ docker-compose pull +
+
+ # start maxun +
+ docker-compose up -d +
+
+ )} + + )} +
+
+ + + + {user.email} + + + { handleMenuClose(); logout(); }}> + Logout + + { + window.open('https://discord.gg/5GbPjBUkws', '_blank'); + }}> + Discord + + { + window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank'); + }}> + YouTube + + { + window.open('https://x.com/maxun_io?ref=app', '_blank'); + }}> + Twiiter (X) + + + + ) : ( + <> + + + Discard + + + + )} +
+ ) : "" + } + + ); }; diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 5c46e4de..a02711f3 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -1,4 +1,4 @@ -import { useReducer, createContext, useEffect } from 'react'; +import { useReducer, createContext, useEffect, useCallback } from 'react'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { apiUrl } from "../apiConfig"; @@ -14,12 +14,16 @@ interface ActionType { type InitialStateType = { user: any; + lastActivityTime?: number; }; const initialState = { user: null, + lastActivityTime: Date.now(), }; +const AUTO_LOGOUT_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds + const AuthContext = createContext<{ state: InitialStateType; dispatch: React.Dispatch; @@ -34,11 +38,13 @@ const reducer = (state: InitialStateType, action: ActionType) => { return { ...state, user: action.payload, + lastActivityTime: Date.now(), }; case 'LOGOUT': return { ...state, user: null, + lastActivityTime: undefined, }; default: return state; @@ -50,6 +56,39 @@ const AuthProvider = ({ children }: AuthProviderProps) => { const navigate = useNavigate(); axios.defaults.withCredentials = true; + const handleLogout = useCallback(async () => { + try { + await axios.get(`${apiUrl}/auth/logout`); + dispatch({ type: 'LOGOUT' }); + window.localStorage.removeItem('user'); + navigate('/login'); + } catch (err) { + console.error('Logout error:', err); + } + }, [navigate]); + + const checkAutoLogout = useCallback(() => { + if (state.user && state.lastActivityTime) { + const currentTime = Date.now(); + const timeSinceLastActivity = currentTime - state.lastActivityTime; + + if (timeSinceLastActivity >= AUTO_LOGOUT_TIME) { + handleLogout(); + } + } + }, [state.user, state.lastActivityTime, handleLogout]); + + // Update last activity time on user interactions + const updateActivityTime = useCallback(() => { + if (state.user) { + dispatch({ + type: 'LOGIN', + payload: state.user // Reuse existing user data + }); + } + }, [state.user]); + + // Initialize user from localStorage useEffect(() => { const storedUser = window.localStorage.getItem('user'); if (storedUser) { @@ -57,21 +96,54 @@ const AuthProvider = ({ children }: AuthProviderProps) => { } }, []); + // Set up activity listeners + useEffect(() => { + if (state.user) { + // List of events to track for user activity + const events = ['mousedown', 'keydown', 'scroll', 'touchstart']; + + // Throttled event handler + let timeoutId: NodeJS.Timeout; + const handleActivity = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(updateActivityTime, 1000); + }; + + // Add event listeners + events.forEach(event => { + window.addEventListener(event, handleActivity); + }); + + // Set up periodic check for auto logout + const checkInterval = setInterval(checkAutoLogout, 60000); // Check every minute + + // Cleanup + return () => { + events.forEach(event => { + window.removeEventListener(event, handleActivity); + }); + clearInterval(checkInterval); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + } + }, [state.user, updateActivityTime, checkAutoLogout]); + axios.interceptors.response.use( function (response) { return response; }, function (error) { const res = error.response; - if (res.status === 401 && res.config && !res.config.__isRetryRequest) { - return new Promise((resolve, reject) => { - axios - .get(`${apiUrl}/auth/logout`) + if (res?.status === 401 && res.config && !res.config.__isRetryRequest) { + return new Promise((_, reject) => { + handleLogout() .then(() => { console.log('/401 error > logout'); - dispatch({ type: 'LOGOUT' }); - window.localStorage.removeItem('user'); - navigate('/login'); + reject(error); }) .catch((err) => { console.error('AXIOS INTERCEPTORS ERROR:', err); diff --git a/src/context/browserActions.tsx b/src/context/browserActions.tsx index 55ca1b37..fd951f92 100644 --- a/src/context/browserActions.tsx +++ b/src/context/browserActions.tsx @@ -14,8 +14,8 @@ interface ActionContextProps { paginationType: PaginationType; limitType: LimitType; customLimit: string; - captureStage: CaptureStage; // New captureStage property - setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage + captureStage: CaptureStage; + setCaptureStage: (stage: CaptureStage) => void; startPaginationMode: () => void; startGetText: () => void; stopGetText: () => void;