diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 369a08be..09b6578b 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -283,13 +283,13 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, } 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, baseUrl).href : null; + // 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, baseUrl).href : null; + record[label] = href ? new URL(href, window.location.origin).href : null; } else { record[label] = fieldElement.getAttribute(attribute); } @@ -346,5 +346,5 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, return results; }; - + })(window); \ No newline at end of file diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index f0b10936..bd90e40f 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -121,6 +121,53 @@ export default class Interpreter extends EventEmitter { } } + // private getSelectors(workflow: Workflow, actionId: number): string[] { + // const selectors: string[] = []; + + // // Validate actionId + // if (actionId <= 0) { + // console.log("No previous selectors to collect."); + // return selectors; // Empty array as there are no previous steps + // } + + // // Iterate from the start up to (but not including) actionId + // for (let index = 0; index < actionId; index++) { + // const currentSelectors = workflow[index]?.where?.selectors; + // console.log(`Selectors at step ${index}:`, currentSelectors); + + // if (currentSelectors && currentSelectors.length > 0) { + // currentSelectors.forEach((selector) => { + // if (!selectors.includes(selector)) { + // selectors.push(selector); // Avoid duplicates + // } + // }); + // } + // } + + // console.log("Collected Selectors:", selectors); + // return selectors; + // } + + private getSelectors(workflow: Workflow): string[] { + const selectorsSet = new Set(); + + if (workflow.length === 0) { + return []; + } + + for (let index = workflow.length - 1; index >= 0; index--) { + const currentSelectors = workflow[index]?.where?.selectors; + + if (currentSelectors && currentSelectors.length > 0) { + currentSelectors.forEach((selector) => selectorsSet.add(selector)); + return Array.from(selectorsSet); + } + } + + return []; + } + + /** * Returns the context object from given Page and the current workflow.\ * \ @@ -130,52 +177,63 @@ export default class Interpreter extends EventEmitter { * @param workflow Current **initialized** workflow (array of where-what pairs). * @returns {PageState} State of the current page. */ - private async getState(page: Page, workflow: Workflow): Promise { + private async getState(page: Page, workflowCopy: Workflow, selectors: string[]): Promise { /** * All the selectors present in the current Workflow */ - const selectors = Preprocessor.extractSelectors(workflow); + // const selectors = Preprocessor.extractSelectors(workflow); + // console.log("Current selectors:", selectors); /** * Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability). * @param selector Selector to be queried * @returns True if the targetted element is actionable, false otherwise. */ - const actionable = async (selector: string): Promise => { - try { - const proms = [ - page.isEnabled(selector, { timeout: 500 }), - page.isVisible(selector, { timeout: 500 }), - ]; + // const actionable = async (selector: string): Promise => { + // try { + // const proms = [ + // page.isEnabled(selector, { timeout: 5000 }), + // page.isVisible(selector, { timeout: 5000 }), + // ]; - return await Promise.all(proms).then((bools) => bools.every((x) => x)); - } catch (e) { - // log(e, Level.ERROR); - return false; - } - }; + // return await Promise.all(proms).then((bools) => bools.every((x) => x)); + // } catch (e) { + // // log(e, Level.ERROR); + // return false; + // } + // }; /** * Object of selectors present in the current page. */ - const presentSelectors: SelectorArray = await Promise.all( - selectors.map(async (selector) => { - if (await actionable(selector)) { - return [selector]; - } - return []; - }), - ).then((x) => x.flat()); + // const presentSelectors: SelectorArray = await Promise.all( + // selectors.map(async (selector) => { + // if (await actionable(selector)) { + // return [selector]; + // } + // return []; + // }), + // ).then((x) => x.flat()); + + const action = workflowCopy[workflowCopy.length - 1]; + + // console.log("Next action:", action) + + let url: any = page.url(); + + if (action && action.where.url !== url && action.where.url !== "about:blank") { + url = action.where.url; + } return { - url: page.url(), + url, cookies: (await page.context().cookies([page.url()])) .reduce((p, cookie) => ( { ...p, [cookie.name]: cookie.value, }), {}), - selectors: presentSelectors, + selectors, }; } @@ -365,6 +423,7 @@ export default class Interpreter extends EventEmitter { console.log("MERGED results:", mergedResult); await this.options.serializableCallback(mergedResult); + // await this.options.serializableCallback(scrapeResult); }, scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => { @@ -548,11 +607,31 @@ export default class Interpreter extends EventEmitter { return allResults; } + private getMatchingActionId(workflow: Workflow, pageState: PageState, usedActions: string[]) { + for (let actionId = workflow.length - 1; actionId >= 0; actionId--) { + const step = workflow[actionId]; + const isApplicable = this.applicable(step.where, pageState, usedActions); + console.log("-------------------------------------------------------------"); + console.log(`Where:`, step.where); + console.log(`Page state:`, pageState); + console.log(`Match result: ${isApplicable}`); + console.log("-------------------------------------------------------------"); + + if (isApplicable) { + return actionId; + } + } + } + private async runLoop(p: Page, workflow: Workflow) { + const workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow)); + // apply ad-blocker to the current page await this.applyAdBlocker(p); const usedActions: string[] = []; + let selectors: string[] = []; let lastAction = null; + let actionId = -1 let repeatCount = 0; /** @@ -561,7 +640,7 @@ export default class Interpreter extends EventEmitter { * e.g. via `enqueueLinks`. */ p.on('popup', (popup) => { - this.concurrency.addJob(() => this.runLoop(popup, workflow)); + this.concurrency.addJob(() => this.runLoop(popup, workflowCopy)); }); /* eslint no-constant-condition: ["warn", { "checkLoops": false }] */ @@ -580,8 +659,11 @@ export default class Interpreter extends EventEmitter { } let pageState = {}; + let getStateTest = "Hello"; try { - pageState = await this.getState(p, workflow); + pageState = await this.getState(p, workflowCopy, selectors); + selectors = []; + console.log("Empty selectors:", selectors) } catch (e: any) { this.log('The browser has been closed.'); return; @@ -591,32 +673,52 @@ export default class Interpreter extends EventEmitter { this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN); } - const actionId = workflow.findIndex((step) => { - const isApplicable = this.applicable(step.where, pageState, usedActions); - console.log(`Where:`, step.where); - console.log(`Page state:`, pageState); - console.log(`Match result: ${isApplicable}`); - return isApplicable; - }); + // const actionId = workflow.findIndex((step) => { + // const isApplicable = this.applicable(step.where, pageState, usedActions); + // console.log("-------------------------------------------------------------"); + // console.log(`Where:`, step.where); + // console.log(`Page state:`, pageState); + // console.log(`Match result: ${isApplicable}`); + // console.log("-------------------------------------------------------------"); + // return isApplicable; + // }); - const action = workflow[actionId]; + actionId = this.getMatchingActionId(workflowCopy, pageState, usedActions); + const action = workflowCopy[actionId]; + + console.log("MATCHED ACTION:", action); + console.log("MATCHED ACTION ID:", actionId); this.log(`Matched ${JSON.stringify(action?.where)}`, Level.LOG); if (action) { // action is matched if (this.options.debugChannel?.activeId) { this.options.debugChannel.activeId(actionId); } - + repeatCount = action === lastAction ? repeatCount + 1 : 0; - if (this.options.maxRepeats && repeatCount >= this.options.maxRepeats) { + + console.log("REPEAT COUNT", repeatCount); + if (this.options.maxRepeats && repeatCount > this.options.maxRepeats) { return; } lastAction = action; - + try { + console.log("Carrying out:", action.what); await this.carryOutSteps(p, action.what); usedActions.push(action.id ?? 'undefined'); + + workflowCopy.splice(actionId, 1); + console.log(`Action with ID ${action.id} removed from the workflow copy.`); + + // const newSelectors = this.getPreviousSelectors(workflow, actionId); + const newSelectors = this.getSelectors(workflowCopy); + newSelectors.forEach(selector => { + if (!selectors.includes(selector)) { + selectors.push(selector); + } + }); } catch (e) { this.log(e, Level.ERROR); } diff --git a/server/src/api/record.ts b/server/src/api/record.ts index 5b33b12f..05560487 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -15,6 +15,8 @@ import { io, Socket } from "socket.io-client"; import { BinaryOutputService } from "../storage/mino"; import { AuthenticatedRequest } from "../routes/record" import {capture} from "../utils/analytics"; +import { Page } from "playwright"; +import { WorkflowFile } from "maxun-core"; chromium.use(stealthPlugin()); const formatRecording = (recordingData: any) => { @@ -533,6 +535,17 @@ function resetRecordingState(browserId: string, id: string) { id = ''; } +function AddGeneratedFlags(workflow: WorkflowFile) { + const copy = JSON.parse(JSON.stringify(workflow)); + for (let i = 0; i < workflow.workflow.length; i++) { + copy.workflow[i].what.unshift({ + action: 'flag', + args: ['generated'], + }); + } + return copy; +}; + async function executeRun(id: string) { try { const run = await Run.findOne({ where: { runId: id } }); @@ -560,13 +573,14 @@ async function executeRun(id: string) { throw new Error('Could not access browser'); } - const currentPage = await browser.getCurrentPage(); + let currentPage = await browser.getCurrentPage(); if (!currentPage) { throw new Error('Could not create a new page'); } + const workflow = AddGeneratedFlags(recording.recording); const interpretationInfo = await browser.interpreter.InterpretRecording( - recording.recording, currentPage, plainRun.interpreterSettings + workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings ); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index d1f648f8..ddadf240 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -18,6 +18,8 @@ import { AuthenticatedRequest } from './record'; import { computeNextRun } from '../utils/schedule'; import { capture } from "../utils/analytics"; import { tryCatch } from 'bullmq'; +import { WorkflowFile } from 'maxun-core'; +import { Page } from 'playwright'; chromium.use(stealthPlugin()); export const router = Router(); @@ -422,6 +424,17 @@ router.get('/runs/run/:id', requireSignIn, async (req, res) => { } }); +function AddGeneratedFlags(workflow: WorkflowFile) { + const copy = JSON.parse(JSON.stringify(workflow)); + for (let i = 0; i < workflow.workflow.length; i++) { + copy.workflow[i].what.unshift({ + action: 'flag', + args: ['generated'], + }); + } + return copy; +}; + /** * PUT endpoint for finishing a run and saving it to the storage. */ @@ -443,10 +456,11 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re // interpret the run in active browser const browser = browserPool.getRemoteBrowser(plainRun.browserId); - const currentPage = browser?.getCurrentPage(); + let currentPage = browser?.getCurrentPage(); if (browser && currentPage) { + const workflow = AddGeneratedFlags(recording.recording); const interpretationInfo = await browser.interpreter.InterpretRecording( - recording.recording, currentPage, plainRun.interpreterSettings); + workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); await destroyRemoteBrowser(plainRun.browserId); diff --git a/server/src/workflow-management/classes/Interpreter.ts b/server/src/workflow-management/classes/Interpreter.ts index d53259b7..b982b172 100644 --- a/server/src/workflow-management/classes/Interpreter.ts +++ b/server/src/workflow-management/classes/Interpreter.ts @@ -244,7 +244,12 @@ export class WorkflowInterpreter { * @param page The page instance used to interact with the browser. * @param settings The settings to use for the interpretation. */ - public InterpretRecording = async (workflow: WorkflowFile, page: Page, settings: InterpreterSettings) => { + public InterpretRecording = async ( + workflow: WorkflowFile, + page: Page, + updatePageOnPause: (page: Page) => void, + settings: InterpreterSettings + ) => { const params = settings.params ? settings.params : null; delete settings.params; @@ -262,7 +267,7 @@ export class WorkflowInterpreter { this.socket.emit('debugMessage', msg) }, }, - serializableCallback: (data: string) => { + serializableCallback: (data: any) => { this.serializableData.push(data); this.socket.emit('serializableCallback', data); }, @@ -275,6 +280,23 @@ export class WorkflowInterpreter { const interpreter = new Interpreter(decryptedWorkflow, options); this.interpreter = interpreter; + interpreter.on('flag', async (page, resume) => { + if (this.activeId !== null && this.breakpoints[this.activeId]) { + logger.log('debug', `breakpoint hit id: ${this.activeId}`); + this.socket.emit('breakpointHit'); + this.interpretationIsPaused = true; + } + + if (this.interpretationIsPaused) { + this.interpretationResume = resume; + logger.log('debug', `Paused inside of flag: ${page.url()}`); + updatePageOnPause(page); + this.socket.emit('log', '----- The interpretation has been paused -----', false); + } else { + resume(); + } + }); + const status = await interpreter.run(page, params); const lastArray = this.serializableData.length > 1 diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index 02ca905f..169b0061 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -11,6 +11,8 @@ import Run from "../../models/Run"; import { getDecryptedProxyConfig } from "../../routes/proxy"; import { BinaryOutputService } from "../../storage/mino"; import { capture } from "../../utils/analytics"; +import { WorkflowFile } from "maxun-core"; +import { Page } from "playwright"; chromium.use(stealthPlugin()); async function createWorkflowAndStoreMetadata(id: string, userId: string) { @@ -79,6 +81,17 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { } } +function AddGeneratedFlags(workflow: WorkflowFile) { + const copy = JSON.parse(JSON.stringify(workflow)); + for (let i = 0; i < workflow.workflow.length; i++) { + copy.workflow[i].what.unshift({ + action: 'flag', + args: ['generated'], + }); + } + return copy; +}; + async function executeRun(id: string) { try { const run = await Run.findOne({ where: { runId: id } }); @@ -106,13 +119,15 @@ async function executeRun(id: string) { throw new Error('Could not access browser'); } - const currentPage = await browser.getCurrentPage(); + let currentPage = await browser.getCurrentPage(); if (!currentPage) { throw new Error('Could not create a new page'); } + const workflow = AddGeneratedFlags(recording.recording); const interpretationInfo = await browser.interpreter.InterpretRecording( - recording.recording, currentPage, plainRun.interpreterSettings); + workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings + ); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); diff --git a/src/components/molecules/IntegrationSettings.tsx b/src/components/molecules/IntegrationSettings.tsx index c31605de..b34bc0e9 100644 --- a/src/components/molecules/IntegrationSettings.tsx +++ b/src/components/molecules/IntegrationSettings.tsx @@ -15,11 +15,13 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRecording } from "../../api/storage"; import { apiUrl } from "../../apiConfig.js"; import Cookies from 'js-cookie'; + interface IntegrationProps { isOpen: boolean; handleStart: (data: IntegrationSettings) => void; handleClose: () => void; } + export interface IntegrationSettings { spreadsheetId: string; spreadsheetName: string; @@ -75,8 +77,7 @@ export const IntegrationSettingsModal = ({ ); notify( "error", - `Error fetching spreadsheet files: ${ - error.response?.data?.message || error.message + `Error fetching spreadsheet files: ${error.response?.data?.message || error.message }` ); } diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index ee8c80e8..c2f271cf 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -1,16 +1,17 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useEffect } from 'react'; import axios from 'axios'; import styled from "styled-components"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { IconButton, Menu, MenuItem, Typography, Avatar, Chip, } from "@mui/material"; -import { AccountCircle, Logout, Clear } from "@mui/icons-material"; +import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material"; +import { AccountCircle, Logout, Clear, YouTube, X, Update, Close } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../../context/auth'; import { SaveRecording } from '../molecules/SaveRecording'; import DiscordIcon from '../atoms/DiscordIcon'; import { apiUrl } from '../../apiConfig'; import MaxunLogo from "../../assets/maxunlogo.png"; +import packageJson from "../../../package.json" interface NavBarProps { recordingName: string; @@ -24,6 +25,38 @@ export const NavBar: React.FC = ({ recordingName, isRecording }) => const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); + const currentVersion = packageJson.version; + + const [open, setOpen] = useState(false); + const [latestVersion, setLatestVersion] = useState(null); + const [tab, setTab] = useState(0); + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + + const fetchLatestVersion = async (): Promise => { + try { + const response = await fetch("https://api.github.com/repos/getmaxun/maxun/releases/latest"); + const data = await response.json(); + const version = data.tag_name.replace(/^v/, ""); // Remove 'v' prefix + return version; + } catch (error) { + console.error("Failed to fetch latest version:", error); + return null; // Handle errors gracefully + } + }; + + const handleUpdateOpen = () => { + setOpen(true); + fetchLatestVersion(); + }; + + const handleUpdateClose = () => { + setOpen(false); + setTab(0); // Reset tab to the first tab + }; + + const handleUpdateTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTab(newValue); + }; const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -50,85 +83,233 @@ export const NavBar: React.FC = ({ recordingName, isRecording }) => navigate('/'); }; + useEffect(() => { + const checkForUpdates = async () => { + const latestVersion = await fetchLatestVersion(); + setLatestVersion(latestVersion); // Set the latest version state + if (latestVersion && latestVersion !== currentVersion) { + setIsUpdateAvailable(true); // Show a notification or highlight the "Upgrade" button + } + }; + checkForUpdates(); + }, []); + return ( - -
- -
Maxun
-
- { - user ? ( -
- {!isRecording ? ( - <> - + {isUpdateAvailable && ( + setIsUpdateAvailable(false)} + message={ + `New version ${latestVersion} available! Click "Upgrade" to update.` + } + action={ + <> + + setIsUpdateAvailable(false)} + style={{ color: 'black' }} + > + + + + } + ContentProps={{ + sx: { + background: "white", + color: "black", + } + }} + /> + + )} + +
+ +
Maxun
+ +
+ { + 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

+ # pull latest changes +
+ git pull origin master +
+
+ # install dependencies +
+ npm install +
+
+ # start maxun +
+ npm run start +
+
+ )} + {tab === 1 && ( + + +

Run the commands below

+ # pull latest docker images +
+ docker-compose pull +
+
+ # start maxun +
+ docker-compose up -d +
+
+ )} + + )} +
+
+ + - - - - - - {user.email} - - - { handleMenuClose(); logout(); }}> - Logout - - - - ) : ( - <> - - - Discard - - - - )} -
- ) : "" - } -
+ marginRight: '10px', + '&:hover': { backgroundColor: 'white', color: '#ff00c3' } + }}> + + {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/components/molecules/Pair.tsx b/src/components/molecules/Pair.tsx index b05b912d..3c332600 100644 --- a/src/components/molecules/Pair.tsx +++ b/src/components/molecules/Pair.tsx @@ -1,5 +1,5 @@ import React, { FC, useState } from 'react'; -import { Stack, Button, IconButton, Tooltip, Chip, Badge } from "@mui/material"; +import { Stack, Button, IconButton, Tooltip, Badge } from "@mui/material"; import { AddPair, deletePair, UpdatePair } from "../../api/workflow"; import { WorkflowFile } from "maxun-core"; import { ClearButton } from "../atoms/buttons/ClearButton"; diff --git a/src/components/molecules/RobotDuplicate.tsx b/src/components/molecules/RobotDuplicate.tsx index 850614b0..38b7b422 100644 --- a/src/components/molecules/RobotDuplicate.tsx +++ b/src/components/molecules/RobotDuplicate.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { GenericModal } from "../atoms/GenericModal"; -import { TextField, Typography, Box, Button, Chip } from "@mui/material"; +import { TextField, Typography, Box, Button } from "@mui/material"; import { modalStyle } from "./AddWhereCondModal"; import { useGlobalInfoStore } from '../../context/globalInfo'; import { duplicateRecording, getStoredRecording } from '../../api/storage';