http-on-pages

Initiate an XHR request on the page

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name            http-on-pages
// @namespace       https://github.com/pansong291/
// @version         0.1.11
// @description     Initiate an XHR request on the page
// @description:zh  在页面上发起 XHR 请求
// @author          paso
// @license         Apache-2.0
// @match           *://*/*
// @icon            data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201024%201024%22%20fill%3D%22black%22%3E%3Cpath%20d%3D%22M474.937%20387.054c0-20.275%2016.638-36.403%2036.913-36.403h53.035L362.632%20148.402%20160.377%20350.65h53.297c20.275%200%2036.652%2016.128%2036.652%2036.403v259.959c0%2020.276-16.377%2036.914-36.652%2036.914-20.021%200-36.919-16.638-36.919-36.914V423.967H71.732c-20.015%200-36.137-17.159-36.137-36.914%200-9.883%203.644-19.244%2010.138-25.999l291.42-291.16c13.781-13.521%2037.435-13.521%2051.217%200l291.421%20291.161c13.776%2014.552%2013.776%2037.956%200%2051.987-7.282%206.766-16.898%2010.925-25.999%2010.925H548.247v223.045c0%2020.276-16.377%2036.914-36.398%2036.914h-53.291l202.509%20202.26%20201.994-202.26H809.77c-20.276%200-36.398-16.638-36.398-36.914V387.054c0-20.275%2016.122-36.403%2036.398-36.403s36.914%2016.128%2036.914%2036.403v223.567h104.768c9.617%200%2018.717%203.116%2026.254%2010.393a37.359%2037.359%200%200%201%200%2051.999L687.328%20962.609l-0.261%201.043c-14.558%2014.563-37.957%2014.563-51.993%200l-291.16-290.639c-6.239-6.755-10.659-16.117-10.659-26%200-20.275%2016.898-36.392%2036.397-36.392h105.285V387.054z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E
// @grant           none
// @noframes
// @run-at          context-menu
// @require         https://update.greasyfork.org/scripts/473443/1690999/popup-inject.js
// ==/UserScript==

/**
 * @typedef {object} ReqObj
 * @property {string} method
 * @property {string} url
 * @property {string} code
 * @property {number} timestamp
 */
/**
 * @typedef {object} ProxiedReqExtension
 * @property {(i: number, v: ReqObj) => void} insert
 * @property {(i: number) => ReqObj} remove
 * @property {ReqObj} selected
 */
/**
 * @typedef {ReqObj[] & ProxiedReqExtension} ProxiedReqArray
 */
;(function () {
  'use strict'
  const namespace = 'paso-http-on-pages'
  const injectHint = 'const data = { headers: {}, params: {}, body: void 0, withCredentials: true }'
  const injectHtml = `
    <div class="tip-box info">${injectHint}</div>
    <div class="flex gap-4">
      <select id="ipt-req-sel" class="input"></select>
      <button id="btn-req-rem" type="button" class="button square">
        <svg width="16" height="16" fill="currentcolor">
          <path d="M2 7h12v2H2Z"></path>
        </svg>
      </button>
      <button id="btn-req-add" type="button" class="button square">
        <svg width="16" height="16" fill="currentcolor">
          <path d="M2 7H7V2H9V7H14V9H9V14H7V9H2Z"></path>
        </svg>
      </button>
    </div>
    <div class="flex gap-4">
      <select id="ipt-method" class="input"></select>
      <input type="text" id="ipt-url" class="input" autocomplete="off">
      <button type="button" id="btn-submit" class="button">Submit</button>
    </div>
    <textarea id="ipt-code" class="input" spellcheck="false"></textarea>
    <div id="error-tip-box"></div>`
  const injectStyle = `
<style>
  button, input, select, textarea {
    font-family: inherit;
    font-size: inherit;
  }
  .popup {
    gap: 4px;
  }
  .gap-4 {
    gap: 4px;
  }
  .tip-box.info {
    background: #d3dff7;
    border-left: 6px solid #3d7fff;
    border-radius: 4px;
    padding: 16px;
  }
  .button.square {
    width: 32px;
    padding: 0;
  }
  #ipt-method {
    width: 90px;
  }
  #ipt-url {
    width: 300px;
    flex-grow: 1;
  }
  #btn-submit {
    width: 100px;
  }
  #ipt-code {
    height: 400px;
  }
  #error-tip-box {
    background: #fdd;
    border-left: 6px solid #f66;
    border-radius: 4px;
    padding: 16px;
  }
  #error-tip-box:empty {
    display: none;
  }
</style>`
  window.paso.injectPopup({
    namespace,
    actionName: 'Http Request',
    collapse: '70%',
    content: injectHtml,
    style: injectStyle
  }).then((result) => {
    const { popup } = result.elem
    const { createElement } = result.func
    popup.classList.add('monospace')
    const element = {
      ipt_req_sel: popup.querySelector('#ipt-req-sel'),
      btn_req_rem: popup.querySelector('#btn-req-rem'),
      btn_req_add: popup.querySelector('#btn-req-add'),
      ipt_method: popup.querySelector('#ipt-method'),
      ipt_url: popup.querySelector('#ipt-url'),
      ipt_code: popup.querySelector('#ipt-code'),
      btn_submit: popup.querySelector('#btn-submit'),
      error_tip: popup.querySelector('#error-tip-box')
    }
    const method_options = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']
    element.ipt_method.innerHTML = method_options.map(op => `<option value="${op}">${op}</option>`).join('')
    /**
     * @type {ProxiedReqArray}
     */
    const reactiveRequests = new Proxy([], {
      get(target, prop, receiver) {
        if (prop === 'insert') {
          return (index, value) => {
            checkIndex(index, target.length + 1)
            const opt = createElement('option', { value: value.timestamp }, [formatDate(value.timestamp)])
            if (target.length === 0 || index === target.length)
              element.ipt_req_sel.append(opt)
            else
              element.ipt_req_sel.children[index].before(opt)
            target.splice(index, 0, value)
          }
        } else if (prop === 'remove') {
          return (index) => {
            checkIndex(index, target.length)
            if (receiver.selected === target[index])
              receiver.selected = target[index > 0 ? index - 1 : 1]
            element.ipt_req_sel.children[index].remove()
            return target.splice(index, 1)[0]
          }
        } else if (prop === 'push') {
          return (value) => receiver.insert(target.length, value)
        } else if (prop === 'selected') {
          if (!target.selected) {
            const v = String(element.ipt_req_sel.value)
            target.selected = target.find((r) => String(r.timestamp) === v)
          }
        }
        return target[prop]
      },
      set(target, prop, newValue, receiver) {
        if (prop === 'selected') {
          target.selected = newValue
          element.ipt_req_sel.value = newValue?.timestamp || ''
          element.ipt_method.value = newValue?.method || ''
          element.ipt_url.value = newValue?.url || ''
          element.ipt_code.value = newValue?.code || ''
        }
        return true
      }
    })
    /**
     * @param {string|number} ts
     * @returns {ReqObj}
     */
    const getReqByTimestamp = (ts) => {
      ts = String(ts)
      return reactiveRequests.find((r) => String(r.timestamp) === ts)
    }

    const cache = getCache()
    if (cache?.requests && Array.isArray(cache.requests)) {
      for (const req of cache.requests) {
        reactiveRequests.push(createRequestObj(req))
      }
    }
    if (!reactiveRequests.length) reactiveRequests.push(createRequestObj())
    reactiveRequests.selected = getReqByTimestamp(cache?.selected) || reactiveRequests[0]

    element.ipt_req_sel.addEventListener('change', (e) => reactiveRequests.selected = getReqByTimestamp(e.currentTarget.value))
    element.btn_req_rem.addEventListener('click', () => {
      if (reactiveRequests.length <= 1) return
      reactiveRequests.remove(reactiveRequests.indexOf(reactiveRequests.selected))
    })
    element.btn_req_add.addEventListener('click', () => {
      const obj = createRequestObj()
      reactiveRequests.push(obj)
      reactiveRequests.selected = obj
    })
    element.ipt_method.addEventListener('change', (e) => reactiveRequests.selected.method = e.currentTarget.value)
    element.ipt_url.addEventListener('change', (e) => reactiveRequests.selected.url = e.currentTarget.value)
    element.ipt_code.addEventListener('change', (e) => reactiveRequests.selected.code = e.currentTarget.value)
    element.btn_submit.addEventListener('click', tryTo(() => {
      const selReq = reactiveRequests.selected
      if (!selReq.url) throw 'Url is required'
      const isGet = selReq.method === 'GET'
      // 预备数据
      const data = {
        headers: { 'Content-Type': isGet ? 'application/x-www-form-urlencoded' : 'application/json' },
        params: {},
        body: void 0,
        withCredentials: true
      }
      // 处理数据
      const handleData = new Function('data', selReq.code)
      handleData.call(data, data)
      const isForm = data.body instanceof FormData
      if (isForm) {
        // 使用表单时不填充 Content-Type
        delete data.headers['Content-Type']
      }

      const xhr = new XMLHttpRequest()
      // 链接
      xhr.open(selReq.method, selReq.url + serializeQueryParam(data.params))
      // 使用凭证
      xhr.withCredentials = !!data.withCredentials
      // 请求头
      Object.entries(data.headers).forEach(([n, v]) => {
        if (v !== null && v !== undefined) xhr.setRequestHeader(n, v)
      })
      // 请求体
      xhr.send(isGet ? void 0 : isForm ? data.body : serializeHttpBody(data.body))
      saveCache({ requests: reactiveRequests, selected: selReq.timestamp })
      element.error_tip.innerText = ''
    }, e => element.error_tip.innerText = String(e)))
  })

  /**
   * @param {function} fn
   * @param {function} [errorCallback]
   * @returns {function}
   */
  function tryTo(fn, errorCallback) {
    return function (...args) {
      try {
        fn.apply(this, args)
      } catch (e) {
        console.error(e)
        errorCallback?.(e)
      }
    }
  }

  /**
   * @param {string | Record<string, string>} [param]
   * @param {string} [prefix='?']
   * @returns {string}
   */
  function serializeQueryParam(param, prefix = '?') {
    if (!param) return ''
    if (typeof param === 'string') return prefix + param
    const str = Object.entries(flatten(param)).flatMap(([k, v]) => {
      if (v === null || v === void 0) return []
      return [k + '=' + encodeURIComponent(String(v))]
    }).join('&')
    if (str) return prefix + str
    return str
  }

  /**
   * @param {*} obj
   * @param {string} [name='']
   * @returns {Record<string, *>}
   */
  function flatten(obj, name = '') {
    const result = {}
    if (!obj || typeof obj !== 'object') {
      if (!name) return [obj]
      result[name] = obj
    } else {
      const isArr = Array.isArray(obj)
      Object.entries(obj).forEach(([k, v]) => {
        Object.entries(flatten(v, !name ? k : isArr ? `${name}[${k}]` : `${name}.${k}`)).forEach(([k2, v2]) => {
          result[k2] = v2
        })
      })
    }
    return result
  }

  /**
   * @param {?ReqObj} [base]
   * @returns {ReqObj}
   */
  function createRequestObj(base) {
    return {
      method: base?.method || 'GET',
      url: base?.url || '',
      code: base?.code || '',
      timestamp: base?.timestamp || Date.now()
    }
  }

  /**
   * @param {number} index
   * @param {number} length
   */
  function checkIndex(index, length) {
    if (index < 0 || index >= length) throw new RangeError(`Index out of bounds error.\nindex: ${index}\nlength: ${length}`)
  }

  /**
   * @param {*} [date]
   * @returns {string}
   */
  function formatDate(date) {
    date = new Date(date || null)
    const year = formatNumber(date.getFullYear(), 4)
    const month = formatNumber(date.getMonth())
    const day = formatNumber(date.getDate())
    const hour = formatNumber(date.getHours())
    const minute = formatNumber(date.getMinutes())
    const second = formatNumber(date.getSeconds())
    const mill = formatNumber(date.getMilliseconds(), 3)
    return `${year}-${month}-${day} ${hour}:${minute}:${second}.${mill}`
  }

  /**
   * @param {number} num
   * @param {number} [count=2]
   * @returns {string}
   */
  function formatNumber(num, count = 2) {
    return String(num).padStart(count, '0')
  }

  /**
   * @param {*} obj
   * @returns {string}
   */
  function serializeHttpBody(obj) {
    if (typeof obj === 'string') return obj
    return JSON.stringify(obj)
  }

  /**
   * @param {*} obj
   */
  function saveCache(obj) {
    localStorage.setItem(namespace, JSON.stringify(obj))
  }

  /**
   * @returns {{requests: ReqObj[], selected: string} | undefined}
   */
  function getCache() {
    const str = localStorage.getItem(namespace)
    try {
      if (str) return JSON.parse(str)
    } catch (e) {
      console.error(e)
    }
  }
})()