http-on-pages

Initiate an XHR request on the page

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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)
    }
  }
})()