识别并自动填写MIS验证码

识别并自动填写北京交通大学MIS入口的验证码,使用前需自行申请讯飞印刷文字识别API

// ==UserScript==
// @name         识别并自动填写MIS验证码
// @namespace    https://github.com/ZiuChen/NO-FLASH-Upload
// @version      1.1.0
// @description  识别并自动填写北京交通大学MIS入口的验证码,使用前需自行申请讯飞印刷文字识别API
// @author       Ziu
// @match        https://cas.bjtu.edu.cn/*
// @connect      webapi.xfyun.cn
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @require      https://cdn.bootcss.com/crypto-js/3.1.9-1/crypto-js.min.js
// @icon         https://fastly.jsdelivr.net/gh/ZiuChen/ZiuChen@main/avatar.jpg
// @license      MIT
// ==/UserScript==

/**
 * 使用前请先申请: 讯飞开放平台 印刷文字识别接口 申请链接 www.xfyun.cn 每天可免费识别 500 次
 * 控制台/我的应用/文字识别/印刷文字识别 获取到 APPID 和 APIKey
 * apikey存储在本地不会上传到服务器
 */
const global = {
  /**
   * 讯飞印刷文字识别接口地址
   */
  hostUrl: 'https://webapi.xfyun.cn/v1/service/v1/ocr/general',
  /**
   * APPID
   */
  appid: null,
  /**
   * APIKey
   */
  apiKey: null,
  /**
   * 验证码图片选择器
   */
  imgSelector: '.captcha',
  /**
   * 验证码输入框选择器
   */
  inputSelector: '#id_captcha_1'
}

const console = {
  log: window.console.log.bind(window.console, '[识别并自动填写MIS验证码]'),
  error: window.console.error.bind(window.console, '[识别并自动填写MIS验证码]'),
  warn: window.console.warn.bind(window.console, '[识别并自动填写MIS验证码]'),
  info: window.console.info.bind(window.console, '[识别并自动填写MIS验证码]')
}

/**
 * 获取验证码图片的base64编码
 */
async function getCaptchaImage() {
  const img = document.querySelector(global.imgSelector)
  if (!img) {
    alert('未找到验证码图片')
    return
  }

  const canvas = document.createElement('canvas')
  canvas.width = img.width
  canvas.height = img.height
  const ctx = canvas.getContext('2d')
  ctx.drawImage(img, 0, 0)
  const base64 = canvas.toDataURL()
  return base64
}

/**
 * blob转base64
 */
async function blobToBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => {
      resolve(reader.result)
    }
    reader.onerror = reject
    reader.readAsDataURL(blob)
  })
}

/**
 * 组装请求头
 */
function getReqHeader() {
  const xParamStr = CryptoJS.enc.Base64.stringify(
    CryptoJS.enc.Utf8.parse(
      JSON.stringify({
        language: 'cn|en'
      })
    )
  )
  const timeStamp = parseInt(new Date().getTime() / 1000) // 获取当前时间戳
  const xCheckSum = CryptoJS.MD5(global.apiKey + timeStamp + xParamStr).toString()
  return {
    'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
    'X-Appid': global.appid,
    'X-CurTime': timeStamp + '',
    'X-Param': xParamStr,
    'X-CheckSum': xCheckSum
  }
}

/**
 * 修正识别字符串
 */
function reviseString(ocrResult) {
  const rules = {
    '*': ['x', 'X', '×'],
    '/': ['.'],
    ' ': ['='] // remove
  }
  let res = ocrResult
  for (const symbol of Object.keys(rules)) {
    const rule = rules[symbol]
    rule.forEach((r) => {
      if (ocrResult.indexOf(r) !== -1) {
        res = res.replace(r, symbol)
      }
    })
  }

  // 修正后的字符串中仍然存在非数字字符
  if (res.match(/[^0-9\+\-\*\/\.]/g)) {
    res = res.replace(/[^0-9\+\-\*\/\.]/g, '')
  }

  console.log('originString: ' + ocrResult)
  console.log('rtnString: ' + res)
  return res
}

/**
 * 处理ocr识别传回的字符串
 * 执行计算并返回结果
 */
function calcResult(string) {
  try {
    return eval(reviseString(string))
  } catch (error) {
    confirm('计算失败,点击确定重新识别') && location.reload()
  }
}

/**
 * GM_xmlhttpRequest 封装
 */
function fetchWithGM(url, options) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: options.method || 'GET',
      url,
      headers: options.headers,
      data: options.data,
      responseType: options.responseType || 'json',
      timeout: options.timeout || 10 * 1000,
      onload: resolve,
      onerror: reject,
      ontimeout: () => reject('请求超时')
    })
  })
}

/**
 * 参数预检查并填充到全局变量
 */
function precheck() {
  // 优先从 GM_getValue 中获取
  global.appid = GM_getValue('xf_appid')
  global.apiKey = GM_getValue('xf_apiKey')

  if (!global.appid || !global.apiKey) {
    // 尝试从 localStorage 中获取
    global.appid = localStorage.getItem('xf_appid')
    global.apiKey = localStorage.getItem('xf_apiKey')
  }

  if (!global.appid || !global.apiKey) {
    const appid = prompt('[讯飞印刷文字识别] 请输入appid: ')
    if (appid) {
      global.appid = appid
      GM_setValue('xf_appid', appid)
    }

    const apiKey = prompt('[讯飞印刷文字识别] 请输入apiKey: ')
    if (apiKey) {
      global.apiKey = apiKey
      GM_setValue('xf_apiKey', apiKey)
    }
  }

  if (!global.appid || !global.apiKey) {
    return false
  }

  return true
}

/**
 * 识别并填充验证码
 * @param {*} image base64编码的验证码图片
 */
async function captchAndFill(image) {
  // 将 base64 图片转换为讯飞识别接口所需的格式
  image = 'image=' + image.split('base64,')[1]

  if (!precheck()) {
    alert('初始化错误,请检查 Key 是否正确输入')
  }

  const input = document.querySelector(global.inputSelector)
  let inputPlaceholder = input?.placeholder
  inputPlaceholder && (input.placeholder = '正在识别验证码...')

  try {
    const res = await fetchWithGM(global.hostUrl, {
      method: 'POST',
      headers: getReqHeader(),
      data: image,
      responseType: 'json'
    })
    console.log('res', res)

    const ocrResult = res?.response?.data?.block[0]?.line[0]?.word[0]?.content
    if (!ocrResult) {
      throw new Error('ocrResult is invalid', ocrResult)
    }

    const numberResult = calcResult(ocrResult)
    if (numberResult === undefined) {
      throw new Error('numberResult is invalid', numberResult)
    }

    const target = document.querySelector(global.inputSelector)
    if (!target) {
      alert('未找到验证码输入框')
      return
    }
    target.value = numberResult // 填入输入框内
  } catch (error) {
    console.error(error)
    confirm('识别失败,点击确定重新识别') && location.reload()
  }

  input.placeholder = inputPlaceholder
}

;(async () => {
  const base64 = await getCaptchaImage()
  captchAndFill(base64)

  // 点击验证码图片时重新识别
  document.querySelector(global.imgSelector).addEventListener('click', async () => {
    const base64 = await getCaptchaImage()
    captchAndFill(base64)
  })
})()