MaraQuestHelpers

Questing helpers

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/573215/1794669/MaraQuestHelpers.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

;(function (global) {
  'use strict'

  /** Returns the first matching element for a selector within the given root. */
  function query(selector, root = document) {
    return root?.querySelector(selector) ?? null
  }

  /** Returns all matching elements for a selector as a plain array. */
  function queryAll(selector, root = document) {
    return [...(root?.querySelectorAll(selector) ?? [])]
  }

  /** Returns the first element found from a list of selectors. */
  function queryFirst(selectors, root = document) {
    for (const selector of selectors) {
      const element = query(selector, root)
      if (element) {
        return element
      }
    }

    return null
  }

  /** Clicks the first element matching a selector and returns it. */
  function clickElement(selector, root = document) {
    const element = query(selector, root)
    element?.click()
    return element
  }

  /** Extracts the item id from a price-check link. */
  function getItemIdFromPriceCheck(priceCheck = query('.dopricecheck')) {
    return priceCheck?.getAttribute('data-id') ?? ''
  }

  /** Clicks a price-check link immediately or after an optional delay. */
  function clickPriceCheck({
    selector = '.dopricecheck',
    itemId = '',
    delay = 0,
    root = document,
  } = {}) {
    const priceCheck = itemId
      ? query(`a[data-id='${itemId}']`, root)
      : query(selector, root)

    if (!priceCheck) {
      return null
    }

    setTimeout(() => {
      priceCheck.click()
    }, delay)

    return priceCheck
  }

  /** Reads the price-check stock row and returns the element, text, quantity, and URL. */
  function getStockInfo(
    selectors = ['.pricechecktable .sitedate', '.pricechecktable .sitedate.same.italic']
  ) {
    const stockElement = Array.isArray(selectors)
      ? queryFirst(selectors)
      : query(selectors)
    const stockText = stockElement?.innerText ?? ''
    const stockMatch = stockText.match(/(\d+)\s+in\s+stock/)

    return {
      element: stockElement,
      text: stockText,
      quantity: stockMatch ? parseInt(stockMatch[1]) : null,
      url: stockElement?.parentElement?.href ?? null,
    }
  }

  /** Returns the shop URL when the price check reports stock is available. */
  function getInStockShopUrl(
    selectors = ['.pricechecktable .sitedate', '.pricechecktable .sitedate.same.italic']
  ) {
    const stockInfo = getStockInfo(selectors)
    return stockInfo.quantity > 0 ? stockInfo.url : null
  }

  /** Returns the linked user shop URL from the price-check panel. */
  function getUserShopUrl(selector = '.pricechecktable .alsotry.same.strong') {
    const userShopLink = query(selector)
    return userShopLink?.parentElement?.href ?? null
  }

  /** Returns true when the price-check panel marks the item as retired. */
  function isPriceCheckRetired({
    selectors = [
      '.pricechecktable .banned.same.italic',
      '.pricechecktable .offline.same.italic',
    ],
  } = {}) {
    return selectors.some((selector) => Boolean(query(selector)))
  }

  /** Returns the number of copies shown in attic results. */
  function getAtticItemCount(
    selectors = ['.pricecheckcontent .offline.same', '.offline.same']
  ) {
    const atticElement = Array.isArray(selectors)
      ? queryFirst(selectors)
      : query(selectors)
    const countText = atticElement?.textContent?.split(' ')[0] ?? '0'
    const quantity = parseInt(countText, 10)

    return Number.isNaN(quantity) ? 0 : quantity
  }

  /** Returns the attic URL when at least one copy of the item is available there. */
  function getAtticUrl(
    selectors = ['.pricecheckcontent .offline.same', '.offline.same']
  ) {
    const atticElement = Array.isArray(selectors)
      ? queryFirst(selectors)
      : query(selectors)
    return getAtticItemCount(selectors) > 0
      ? atticElement?.parentElement?.href ?? null
      : null
  }

  /** Compares displayed prices and returns true when the user shop is cheaper. */
  function isUserShopCheaper({
    userSelector = '.alsotry.same.strong',
    shopSelector = 'span.sitedate.same.italic',
  } = {}) {
    const userText = query(userSelector)?.innerText ?? ''
    const shopText = query(shopSelector)?.innerText ?? ''

    const userPrice = parseInt(
      userText.split(' ')[2]?.split('MP')[0]?.replace(/,/g, '') ?? '',
      10
    )
    const shopPrice = parseInt(
      shopText.split(' ').pop()?.split('MP')[0]?.replace(/,/g, '') ?? '',
      10
    )

    if (Number.isNaN(userPrice) || Number.isNaN(shopPrice)) {
      return false
    }

    return shopPrice > userPrice
  }

  /** Chooses the best destination URL from attic, main shop, or user shop results. */
  function choosePriceCheckUrl() {
    const atticUrl = getAtticUrl()
    if (atticUrl) {
      return atticUrl
    }

    const inStockUrl = getInStockShopUrl()
    const userShopUrl = getUserShopUrl()

    if (isPriceCheckRetired() || !inStockUrl) {
      return userShopUrl
    }

    return isUserShopCheaper() ? userShopUrl : inStockUrl
  }

  /** Navigates to the chosen price-check result after an optional delay. */
  function goToPriceCheckResult(delay = 0, getUrl = choosePriceCheckUrl) {
    setTimeout(() => {
      const url = getUrl()
      if (url) {
        location.href = url
      }
    }, delay)
  }

  /** Clicks a shop stock item using the site's `eachitemdiv{id}` wrapper. */
  function clickShopItemById(itemId, root = document) {
    const itemToClick = query(`#eachitemdiv${itemId} a`, root)
    itemToClick?.click()
    return itemToClick
  }

  /** Clicks an inventory item using the site's `eachitemdiv{id}` wrapper. */
  function clickInventoryItemById(itemId, root = document) {
    const itemToClick = query(`#eachitemdiv${itemId} a img`, root)?.parentElement
    itemToClick?.click()
    return itemToClick
  }

  /** Returns true after buying an item, on the generic shop confirmation page. */
  function isBoughtItemPage(url = document.URL) {
    return url.includes('/shop.php') && !url.includes('id=')
  }

  /** Returns true on the shop purchase confirmation form page. */
  function isBuyItemPage(url = document.URL) {
    return url.includes('/shop.php?do=buy&id=')
  }

  /** Returns true after the attic has already removed the item. */
  function isAtticRemovePage(url = document.URL) {
    return url.includes('remove=1')
  }

  /** Moves one or more copies of an item from attic to inventory. */
  function moveItemFromAttic({
    amount = 1,
    amountSelector = "[name='amountmove']",
    submitSelector = "[value='Inventory']",
  } = {}) {
    const amountInput = query(amountSelector)
    const submitButton = query(submitSelector)

    if (!amountInput || !submitButton) {
      return false
    }

    amountInput.value = amount
    submitButton.click()
    return true
  }

  /** Clicks the button immediately or waits for a 6-digit captcha entry first. */
  function handleCaptcha(captchaElement, buttonElement) {
    if (!buttonElement) {
      return false
    }

    if (!captchaElement) {
      buttonElement.click()
      return true
    }

    captchaElement.focus()
    captchaElement.oninput = () => {
      if (captchaElement.value.length === 6) {
        buttonElement.click()
      }
    }

    return false
  }

  /** Runs a callback after a random delay and returns the chosen timeout value. */
  function withRandomDelay(callback, min = 300, max = 500) {
    const timeout = Math.random() * (max - min) + min
    setTimeout(callback, timeout)
    return timeout
  }

  global.MaraQuestHelpers = {
    choosePriceCheckUrl,
    clickElement,
    clickInventoryItemById,
    clickPriceCheck,
    clickShopItemById,
    getAtticItemCount,
    getAtticUrl,
    getInStockShopUrl,
    getItemIdFromPriceCheck,
    getStockInfo,
    getUserShopUrl,
    goToPriceCheckResult,
    handleCaptcha,
    isAtticRemovePage,
    isBoughtItemPage,
    isBuyItemPage,
    isPriceCheckRetired,
    isUserShopCheaper,
    moveItemFromAttic,
    query,
    queryAll,
    queryFirst,
    withRandomDelay,
  }
})(globalThis)