NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @namespace https://openuserjs.org/users/yuanoook // @name Monkey Driver // @description Monkey Driver is the best driver // @copyright 2020, yuanoook (https://openuserjs.org/users/yuanoook) // @license MIT // @version 1608206024196 // @include * // @author yuanoook // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @github https://github.com/yuanoook/monkey-driver // ==/UserScript== (function(window) { 'use strict'; function getHighResTime() { return performance.timeOrigin + performance.now() } function formatDateTime(date) { date = date ? new Date(date) : new Date() return `${ date.getFullYear() }-${ date.getMonth() + 1 }-${ date.getDate() } ${ date.getHours() }:${ date.getMinutes() }:${ date.getSeconds() }.${ date.getMilliseconds() }` } const storageCache = {} function keepTheCache() { for (let name in storageCache) { GM_setValue(name, JSON.stringify(storageCache[name])) } } function setValue(name, value) { return GM_setValue(name, JSON.stringify(value)) } function getValue(name) { try { return JSON.parse(GM_getValue(name)) } catch (e) {} } function listValues() { const names = GM_listValues() || [] return names.map(name => getValue(name)) } const TRACK_TYPES = { ACTION: 1, SNAPSHOTS: 2, ANALYSIS: 3 } function getTrackLogs(type) { const logs = storage.getValue('trackLogs') || [] if (!type) return logs return logs.filter(([, , logType]) => logType === type) } function setTrackLogs(logs) { return storage.setValue('trackLogs', logs) } function printTrackLogs(type) { const trackLogs = getTrackLogs(type) console.log(`monkeyDrive\`\n${ trackLogs.map(([,logContent]) => logContent).join('\n') }\``) } function clearTrackLogs() { return setTrackLogs([]) } function addTrackLog(log, index = NaN) { const logs = getTrackLogs() index = Number.isNaN(index) ? logs.length : index logs[index] = log setTrackLogs(logs) } function getLastTrackInfo(type, maxIndex = Infinity) { const allLogs = getTrackLogs() const logs = allLogs.filter( ([, , logType], index) => logType === type && index < maxIndex ) const lastLog = logs[logs.length - 1] const index = lastLog ? allLogs.indexOf(lastLog) : allLogs.length return { lastLog, index, logs, allLogs } } function updateLastTrackLog(log) { const [, , type] = log const { index } = getLastTrackInfo(type) addTrackLog(log, index) } const storage = { setValue, getValue, listValues, TRACK_TYPES, getTrackLogs, setTrackLogs, printTrackLogs, clearTrackLogs, addTrackLog, getLastTrackInfo, updateLastTrackLog } // Any thing less than 8px is not clickable // Char `1` (font-size: 14px) is about 8.9px width const MIN_SIZE = 8 function clickableAt(node, x, y) { const pNode = fromPoint(x, y) if (!pNode) return false return (pNode === node) || ( pNode.nodeType == 3 && pNode.parentElement === node ) } function clickable(node) { let tooSmall = ( node.offsetHeight !== undefined && node.offsetHeight < MIN_SIZE || node.offsetWidth !== undefined && node.offsetWidth < MIN_SIZE ) if (tooSmall) return false const rect = getRect(node) tooSmall = rect.width < MIN_SIZE || rect.height < MIN_SIZE if (tooSmall) return false const inViewPort = !( rect.right <= 0 || rect.bottom <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth ) return inViewPort && ( /** * Check points & orders * 1 4 8 * 6 2 7 * 9 5 3 */ clickableAt(node, rect.x, rect.y) || clickableAt(node, rect.x + rect.width / 2, rect.y + rect.height / 2) || clickableAt(node, rect.x + rect.width - 1, rect.y + rect.height - 1) || clickableAt(node, rect.x + rect.width / 2, rect.y) || clickableAt(node, rect.x + rect.width / 2, rect.y + rect.height - 1) || clickableAt(node, rect.x, rect.y + rect.height / 2) || clickableAt(node, rect.x + rect.width - 1, rect.y + rect.height / 2) || clickableAt(node, rect.x + rect.width - 1, rect.y) || clickableAt(node, rect.x, rect.y + rect.height - 1) ) } function fromPoint(x, y) { return textNodeFromPoint(x, y) || document.elementFromPoint(x, y) } function textNodeFromPoint(x, y) { const range = (document.caretRangeFromPoint || document.caretPositionFromPoint) .call(document, x, y) if (!range) return null const node = range.startContainer || range.offsetNode if (!(node.nodeType == 3)) return null const rect = getRect(node) const underClick = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom return underClick ? node : null } function getRect(node) { return node.nodeType == 3 ? getTextBoundingClientRect(node) : node.getBoundingClientRect() } function getTextBoundingClientRect(text) { const range = document.createRange() range.selectNode(text) const rect = range.getBoundingClientRect() range.detach() return rect } function getKeyElements({ selector, filter, prioritize } = {}) { let nodes = Array.from(document.querySelectorAll(selector || '*')) nodes = nodes.filter( node => clickable(node) && (!filter || filter({ node })) ) return prioritize ? prioritize(nodes) : nodes } function getClickableTextNodes({ filterMap, container = document.body }) { const iterator = document.createNodeIterator(container, NodeFilter.SHOW_TEXT) const results = new Set() let textNode while (textNode = iterator.nextNode()) { if (!textNode.data.trim()) continue if (!clickable(textNode)) continue if (filterMap) { const fmResult = filterMap(textNode) if (!fmResult) continue results.add(fmResult) continue } results.add(textNode) } return Array.from(results) } function getKeyElementsWithText({ selector, filter, prioritize, container = document.body } = {}) { const selected = selector && Array.from(document.querySelectorAll(selector) || []) const elements = getClickableTextNodes({ container, filterMap: textNode => { let element = textNode.parentElement if (!clickable(element)) return if (selector && !selected.includes(element)) return if (filter && !filter({ node: element, textNode })) return return element } }) return prioritize ? prioritize(elements) : elements } function getKeyImages() { return getKeyElements({ selector: 'img, svg' }) } function getKeyButtonsAndLinks(text = '') { text = text.toLowerCase() return getKeyElementsWithText({ selector: 'button, button *, a, a *, li, li *', filter: text && (({ node, textNode }) => textNode.data.trim().toLowerCase() === text || node.innerText.trim().toLowerCase() === text ) }) } function identifiersMatch(identifiers, text) { text = (text || '').trim().toLowerCase() return identifiers.includes(text) || identifiers.includes(text.replace(/\s*[::]\s*$/, '')) } function inputNamePlaceholderMatch({ node, identifiers }) { if (identifiersMatch(identifiers, node.name)) return true if (identifiersMatch(identifiers, node.placeholder)) return true } function inputLabelsMatch({ labels, identifiers }) { return labels.some(label => getKeyElementsWithText({ container: label, filter: ({ node, textNode }) => { if (identifiersMatch(identifiers, textNode.data)) return true if (identifiersMatch(identifiers, node.innerText)) return true } }).length) } function inputDirectLabelMatch({ node, identifiers }) { const labels = Array.from(node.labels) return inputLabelsMatch({ labels, identifiers }) } function getGuessLabels(node) { const rect = node.getBoundingClientRect() const height = Math.max(Math.min(node.offsetHeight, 80), 40) return [ fromPoint(rect.x + height / 2, rect.y - height / 2), fromPoint(rect.x - 2 * height, rect.y + height / 2) ].filter(node => node && (node.nodeType == 3 || ( node.offsetHeight < height * 1.5 && node.offsetWidth < node.offsetWidth * 1.5 ))) } function inputPositionLabelMatch({ node, identifiers }) { const guessLabels = getGuessLabels(node) return guessLabels.some(label => label.nodeType == 3 ? identifiersMatch(identifiers, label.data) : inputLabelsMatch({ labels: [label], identifiers }) ) } function getKeyInputs({ command, label }) { command = command.toLowerCase() label = label.toLowerCase() const identifiers = [command, label] const directInputs = getKeyElements({ selector: 'input, textarea', filter: ({ node }) => { if (inputNamePlaceholderMatch({ node, identifiers })) return true if (inputDirectLabelMatch({ node, identifiers })) return true if (inputPositionLabelMatch({ node, identifiers })) return true } }) return directInputs } function getNodeText(node) { if (node.nodeType == 3) { return node.data.trim() } const texts = [] getKeyElementsWithText({ container: node, filter: ({ textNode }) => { texts.push(textNode.data.trim()) return true } }) return texts[0] } function getRealLabelText(input) { if (!input.labels) return const labels = Array.from(input.labels) for (let label of labels) { const text = getNodeText(label) if (text) return text } } function getGuessLabelText(input) { const labels = getGuessLabels(input) for (let label of labels) { const text = getNodeText(label) if (text) return text } } function getInputLabel(input) { return input.name || input.placeholder || getRealLabelText(input) || getGuessLabelText(input) || '' } const INPUT_ACTION_REG = /[:]\s(.+)/ const SNAPSHOT_SEPARATOR = '\0\0\0\0\0' const pushKarmaSnapshot = shotContent => { const { lastLog: lastShot } = getLastTrackInfo(TRACK_TYPES.SNAPSHOTS) const [, lastContent] = lastShot || [] if (lastContent === shotContent) return addTrackLog([getHighResTime(), shotContent, TRACK_TYPES.SNAPSHOTS]) } function getKarma() { return storage.getValue('karma') || {} } function clearKarma() { return storage.setValue('karma', {}) } function setKarma(causes, results) { causes = Array.from(new Set(causes)) if (!causes || !causes.length) return results = results || getKarmaResults() if (!results || !results.length) return const karma = getKarma() for (let result of results) { for (let cause of causes) { karma[result] = karma[result] || {} karma[result][cause] = (karma[result][cause] | 0) + 1 } } storage.setValue('karma', karma) } const karmaAnalysts = { [TRACK_TYPES.ACTION]: (log, prevAction) => { const [ [, content], [, prevContent] ] = [log, prevAction] const [key] = content.split(INPUT_ACTION_REG) const [prevKey] = prevContent.split(INPUT_ACTION_REG) setKarma([prevKey, prevContent], [key, content]) }, [TRACK_TYPES.SNAPSHOTS]: (log, prevAction) => { const [ [, content], [, prevContent] ] = [log, prevAction] const results = content.split(SNAPSHOT_SEPARATOR) const [prevKey] = prevContent.split(INPUT_ACTION_REG) setKarma([prevKey, prevContent], results) } } function getKarmaResults() { return getClickableTextNodes({ filterMap: textNode => textNode.data.trim().toLowerCase() }).sort() } async function logKarmaResults(immediate = false) { if (!immediate) await relax() pushKarmaSnapshot( getKarmaResults().join(SNAPSHOT_SEPARATOR) ) await relax() pushKarmaSnapshot( getKarmaResults().join(SNAPSHOT_SEPARATOR) ) analysisKarma() } function getPrevActionLog({ log, prevLog, prevLogIndex, lastAnalysisIndex }) { const [logAt] = log if (!prevLog) { const lastAction = getLastTrackInfo(TRACK_TYPES.ACTION, lastAnalysisIndex) prevLog = lastAction.lastLog prevLogIndex = lastAction.index } if (!prevLog) return const [preLogAt, prevLogContent, prevLogType] = prevLog if (prevLogType !== TRACK_TYPES.ACTION) return if (logAt - preLogAt > 150 * 1000) return // 2.5 Minutes, too old return prevLog } function analysisKarma() { let { index: lastAnalysisIndex, allLogs } = getLastTrackInfo(TRACK_TYPES.ANALYSIS) lastAnalysisIndex = lastAnalysisIndex === allLogs.length ? -1 : lastAnalysisIndex const logs = allLogs.slice(lastAnalysisIndex + 1) if (!logs.length) return let hit = false for (let i = 0; i < logs.length; i++) { const log = logs[i] const [logAt, , type] = log if (!karmaAnalysts[type]) continue const prevAction = getPrevActionLog({ log, prevLog: logs[i - 1], prevLogIndex: i - 1, lastAnalysisIndex }) if (!prevAction) continue hit = hit || karmaAnalysts[type](log, prevAction) } if (hit) addTrackLog([getHighResTime(), , TRACK_TYPES.ANALYSIS]) return hit } const karma = { getKarma, clearKarma, setKarma, getKarmaResults, logKarmaResults, analysisKarma } const click = { filter: ({ node, content }) => { return !content || node.innerText.toLowerCase().includes(content.toLowerCase()) }, prioritize: (nodes, content) => { return nodes }, run: (node) => { node.click() return true } } const input = { selector: 'input, textarea', run: (node, content) => { node.value = content // todo: node trigger change event } } const textarea = { } const empty = { } const check = { } const uncheck = { } const handlers = { click, input, textarea, empty, check, uncheck } const nap = ms => new Promise(r => setTimeout(r, ms)) const loadingTests = [ node => /^loading[\.\s]*$/i.test(node.innerText.trim()) ] function isLoading() { const nodes = getKeyElementsWithText() const loadings = nodes.filter( node => loadingTests.some(t => t(node)) ) return loadings.length >= nodes.length / 2 } async function waitLoading() { await nap(100) return isLoading() ? waitLoading() : null } async function relax(ms = 100) { await waitLoading() await nap(ms) } function hasDashboard() { return Boolean(document.body.querySelector('.monkey-driver-dashboard')) } function genDashboard() { const dashboard = document.createElement('div') dashboard.classList.add('monkey-driver-dashboard') dashboard.innerHTML = ` <style> .monkey-driver-dashboard { display: none; position: fixed; margin: auto; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.1); z-index: 10000000000000 } body.monkey-driver-dashboard-open .monkey-driver-dashboard { display: block; } .monkey-driver-dashboard-container { display: flex; flex-direction: column; align-items: center; justify-content: center; position: absolute; margin: auto; padding: 10px; top: 0; left: 0; right: 0; bottom: 0; width: 50vw; height: 50vh; border-radius: 10px; background: white; } .monkey-driver-dashboard-container h1 { font-size: 1rem; } .monkey-driver-dashboard-content { width: 100%; height: 100%; overflow: scroll; background: rgba(250,250,250); padding: 10px; } </style> <div class="monkey-driver-dashboard-container"> <h1 > Hello, I'm Monkey Driver! </h1> <div class="monkey-driver-dashboard-content"> <div class="monkey-driver-dashboard-content-actions"></div> <div class="monkey-driver-dashboard-content-karma"></div> </div> </div> ` const clickHandlers = { 'monkey-driver-dashboard-content-karma-result': function(e) { closeDashboard() window.monkeyDrive(e.target.innerText.trim()) } } dashboard.addEventListener('click', e => { for (let key in clickHandlers) { if (e.target.classList.contains(key)) { clickHandlers[key](e) } } }) document.body.appendChild(dashboard) } function isDashboardOpen() { return document.body.classList.contains('monkey-driver-dashboard-open') } function closeDashboard() { document.body.classList.remove('monkey-driver-dashboard-open') } function openDashboard() { if (!hasDashboard()) genDashboard() document.body.classList.add('monkey-driver-dashboard-open') renderActions() renderKarma() } function renderActions() { const actionsNode = document.querySelector('.monkey-driver-dashboard-content-actions') const logs = getTrackLogs(TRACK_TYPES.ACTION) actionsNode.innerHTML = logs.map( ([logAt, logContent]) => `${formatDateTime(logAt)} ${logContent}` ).join('<br/>') } function renderKarma() { const karmaResultsNode = document.querySelector('.monkey-driver-dashboard-content-karma') const logs = getKarma() karmaResultsNode.innerHTML = Object.keys(logs) .map(log => `<span class="monkey-driver-dashboard-content-karma-result">${log}</span>`) .join('<br/>') } function toggleDashboard() { return isDashboardOpen() ? closeDashboard() : openDashboard() } function refreshDashboard() { return isDashboardOpen() && openDashboard() } const pushActionLog = (log, separator) => { logKarmaResults(true) const [key] = separator ? log.split(separator) : [log] const { lastLog: [, lastLog] = [NaN, NaN], index: lastIndex } = getLastTrackInfo(TRACK_TYPES.ACTION) const [lastKey] = (lastLog && separator) ? lastLog.split(separator): [lastLog] const newLog = [getHighResTime(), log, TRACK_TYPES.ACTION] addTrackLog(newLog, key === lastKey ? lastIndex : undefined) printTrackLogs(TRACK_TYPES.ACTION) } const clickTracker = e => { if (isDashboardOpen()) return if (e.eventPhase === Event.BUBBLING_PHASE) return logKarmaResults() const node = fromPoint(e.clientX, e.clientY) if (node.nodeType == 3) { pushActionLog(node.data.trim()) } else { console.log(node) } } const inputTracker = e => { if (isDashboardOpen()) return if (e.eventPhase === Event.BUBBLING_PHASE) return logKarmaResults() const input = e.target const label = getInputLabel(input) pushActionLog(`${ label.replace(/\s*[::]\s*$/, '').trim() }: ${ (input.value || '').trim() }`, /[:]\s(.+)/) } const shortCuts = { "0": () => (clearTrackLogs(), clearKarma(), refreshDashboard()), "1": toggleDashboard, "Escape": closeDashboard } const keydownTracker = e => { if (e.target && /input|textarea/i.test(e.target.tagName)) return if (e.ctrlKey || e.metaKey || e.shiftKey) return if (!shortCuts[e.key]) return if (e.eventPhase === Event.BUBBLING_PHASE) return shortCuts[e.key]() } const beforeunloadTracker = e => { if (e.eventPhase === Event.BUBBLING_PHASE) return console.log(document.activeElement.href) } const trackers = { clearTrackLogs, getTrackLogs, beforeunload: beforeunloadTracker, click: clickTracker, input: inputTracker, keydown: keydownTracker } const handleCommand = ({ handler, content }) => { const node = getKeyElementsWithText({ selector: handler.selector, filter: handler.filter && (node => handler.filter({ node, content })), prioritize: handler.prioritize && (nodes => handler.prioritize(nodes, content)) })[0] return node ? handler.run(node, content) : null } const guessClick = command => { // if there's a button/link/li with text which is exactly the command, click it const button = getKeyButtonsAndLinks(command)[0] if (!button) return false button.click() return true } const guessInput = async command => { // if there's a label near an input/textarea, focus it let [label, content = ''] = command.split(/[::](.+)/) label = label.trim() content = content.trim() const input = getKeyInputs({ command, label })[0] if (!input) return false input.click() await relax(100) if (content) { input.value = content input.dispatchEvent(new Event('input')) await relax(100) input.dispatchEvent(new Event('change')) } return true } const operateManual = command => { for (let key in handlers) { const reg = new RegExp(`^${key}( |$)`, 'i') if (!reg.test(command)) continue return handleCommand({ handler: handlers[key], content: command.replace(reg, '') }) } } // TODO, fix this logic :D const operateKarma = async command => { const karmaNetwork = karma.getKarma() const karmaNode = karmaNetwork[command] let causes = Object.keys(karmaNode || {}) if (!causes.length) return causes.sort((a, b) => karmaNode[b] - karmaNode[a]) for (let cause of causes) { if (await execute(cause)) { if (await execute(command)) { return true } } } } const execute = async command => { // Monkey Driver Rule No.1 - Click if (await guessClick(command)) return true // Monkey Driver Rule No.2 - Input if (await guessInput(command)) return true // Monkey Driver Rule No.3 - Manual if (await operateManual(command)) return true // Monkey Driver Rule No.4 - Karma if (await operateKarma(command)) return true // Monkey Driver is confused console.log(`Monkey has no idea what to do with ${command}`) } const drive = async scripts => { scripts = Array.isArray(scripts) ? scripts : [scripts] const commands = scripts .join('\n') .split(/\n/) .map(c => c.trim()) .filter(c => c) for (let command of commands) { await relax(100) await execute(command) } } window.monkeyDrive = drive Object.keys(trackers).forEach(key => { window.addEventListener(key, e => e.isTrusted && trackers[key](e), true) window.addEventListener(key, e => e.isTrusted && trackers[key](e), false) }) console.log('Monkey Driver is driving :)') Object.assign(drive, { storage, karma, clickable, getKeyElements, getClickableTextNodes, getKeyElementsWithText, getKeyInputs, getInputLabel, getKeyImages, getKeyButtonsAndLinks, fromPoint, textNodeFromPoint, getRect, getTextBoundingClientRect, relax, trackers, formatDateTime }) })(window.unsafeWindow);