yuanoook / Monkey Driver

// ==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);