科举小抄

科举小抄 - 阿里云大学“科考”辅助工具

// ==UserScript==
// @name         科举小抄
// @namespace    https://github.com/fuckKeju/fuckKeju
// @version      0.0.5
// @description  科举小抄 - 阿里云大学“科考”辅助工具
// @author       fuckKeju
// @match        *.developer.aliyun.com/*
// @run-at       document-start
// @grant        unsafeWindow
// ==/UserScript==

/* 题库数据 */
var customQuestionsDatabase = []
var useCustomQuestionsDatabase = false

async function getPageWindow () {
  return new Promise(function (resolve, reject) {
    if (window._pageWindow) {
      return resolve(window._pageWindow)
    }

    const listenEventList = ['load', 'mousemove', 'scroll', 'get-page-window-event']

    function getWin (event) {
      window._pageWindow = this
      // debug.log('getPageWindow succeed', event)
      listenEventList.forEach(eventType => {
        window.removeEventListener(eventType, getWin, true)
      })
      resolve(window._pageWindow)
    }

    listenEventList.forEach(eventType => {
      window.addEventListener(eventType, getWin, true)
    })

    /* 自行派发事件以便用最短的时候获得pageWindow对象 */
    window.dispatchEvent(new window.Event('get-page-window-event'))
  })
}
getPageWindow()

/* 修正标题字符串 */
function trimTitle (title, removeSerialNumber) {
  title = title || ''
  title = title.replace(/\s+/gi, ' ').replace(/\?{2,}/gi, ' ')
  if (removeSerialNumber) {
    title = title.replace(/^\d+\./, '')
  }
  return title
}

/* 提取答案字符串 */
function fixAnswer (answer) {
  answer = answer || ''
  return answer.replace(/^[A-Za-z]\.\s/, '').replace(/\s+--checked/, '')
}

/**
 * 判断两条题目是否为同一条题目
 * @param questionA
 * @param questionB
 */
function isTheSameQuestion (questionA, questionB) {
  let isSame = true
  const titleA = trimTitle(questionA.title, true)
  const titleB = trimTitle(questionB.title, true)

  if (titleA === titleB) {
    for (let i = 0; i < questionA.answerList.length; i++) {
      const answerA = fixAnswer(questionA.answerList[i])
      let hasSameAnswer = false
      for (let j = 0; j < questionB.answerList.length; j++) {
        const answerB = fixAnswer(questionB.answerList[j])
        if (answerA === answerB) {
          hasSameAnswer = true
          break
        }
      }

      if (!hasSameAnswer) {
        isSame = false
        break
      }
    }
  } else {
    isSame = false
  }

  // isSame && console.log(titleA, titleB, isSame)
  return isSame
}

/* 因为收集了部分异常数据,为了排查异常数据的干扰,所以需要进行是否异常的判断 */
function isNormalQuestion (question) {
  return /\s+--checked/.test(JSON.stringify(question.answerList))
}

function eachQuestionsDatabase (questionsDatabase, callback) {
  questionsDatabase.forEach((items, index) => {
    if (Array.isArray(items)) {
      items.forEach(curQuestion => {
        callback(curQuestion, index)
      })
    } else {
      callback(items, index)
    }
  })
}

function getQuestionsDatabase () {
  const subjectEl = document.querySelector('.yq-main-examination .top-info h2.title-content')
  let questionsDatabase = []
  try {
    if (subjectEl) {
      questionsDatabase = JSON.parse(localStorage.getItem(subjectEl.innerText) || '[]')
    } else {
      questionsDatabase = customQuestionsDatabase
    }
  } catch (e) {
    questionsDatabase = []
  }
  return questionsDatabase
}

/* 从混乱的题库集里提取整理后的题库 */
function extractQuestionList (questionsDatabase) {
  const questionList = []
  let addCount = 0

  function addToQuestionList (question) {
    addCount++
    // console.log(question, addCount)
    if (!question || !question.title || !Array.isArray(question.answerList)) {
      return false
    }

    let hasSameQuestion = false
    for (let i = 0; i < questionList.length; i++) {
      const questionB = questionList[i]
      if (isTheSameQuestion(question, questionB)) {
        hasSameQuestion = true
        if (isNormalQuestion(question) && question.rightAnswer === '答案正确') {
          questionList[i] = question
        } else {
          questionList[i].relatedQuestions = questionList[i].relatedQuestions || []
          questionList[i].relatedQuestions.push(question)
        }
        break
      }
    }

    if (!hasSameQuestion) {
      questionList.push(question)
    }
  }

  eachQuestionsDatabase(questionsDatabase, (question, index) => {
    addToQuestionList(question, index)
  })

  return questionList
}

// console.log(extractQuestionList(customQuestionsDatabase))

/**
 * 从某个题库数据集里查找是否存在相关的题目
 * @param questionsDatabase
 * @param questions
 */
function searchRelatedQuestions (questionsDatabase, questions) {
  let relatedQuestions = []
  eachQuestionsDatabase(questionsDatabase, (questionsA) => {
    if (isTheSameQuestion(questionsA, questions)) {
      relatedQuestions.push(questionsA)
    }
  })

  /* 查找是否存在答对的历史记录,优先显示答对的数据 */
  if (relatedQuestions.length > 1) {
    const rightAnswerArr = []
    const wrongAnswerArr = []
    relatedQuestions.forEach(question => {
      if (question.rightAnswer === '答案正确' && isNormalQuestion(question)) {
        rightAnswerArr.push(question)
      } else {
        wrongAnswerArr.push(question)
      }
    })
    relatedQuestions = rightAnswerArr.concat(wrongAnswerArr)
  }

  return relatedQuestions
}

/**
 * 判断某条题目的相关问答库里是否包含一样的答案记录
 * @param questions
 * @param relatedQuestions
 */
function hasTheSameQuestionsInRelatedQuestions (questions, relatedQuestions) {
  let hasSame = false
  relatedQuestions = relatedQuestions || []

  for (let i = 0; i < relatedQuestions.length; i++) {
    const relatedQuestion = relatedQuestions[i]
    let isSame = true
    for (let j = 0; j < relatedQuestion.answerList.length; j++) {
      const answer = relatedQuestion.answerList[j]
      const relatedQuestionChecked = /\s+--checked/.test(answer)
      const questionsChecked = /\s+--checked/.test(questions.answerList[j])
      if (relatedQuestionChecked !== questionsChecked) {
        isSame = false
        break
      }
    }

    if (isSame) {
      hasSame = true
      break
    }
  }

  return hasSame
}

/**
 * 遍历页面上的题目并进行回调,该方法必须在试题页面才能运行
 * @param callback
 * @returns {[]}
 */
function eachQuestionItem (callback) {
  const result = []
  const isExamMode = document.querySelector('.yq-main-examination .time-info')
  const items = document.querySelectorAll('.question-panel .question-item')
  if (items) {
    items.forEach(questionItemEl => {
      const type = questionItemEl.querySelector('.q-title .q-tag').innerText.trim()
      const title = trimTitle(questionItemEl.querySelector('.q-title .q-t-text').innerText.trim())
      const answerList = []
      const answerListEl = questionItemEl.querySelectorAll('.q-option .answer-text')
      answerListEl.forEach(answerEl => {
        let answer = answerEl.innerText.trim()
        const checkedEl = answerEl.parentNode.querySelector('input')
        if (checkedEl && checkedEl.checked) {
          answer += '  --checked'
        }
        answerList.push(answer)
      })

      const questionObj = {
        title,
        type,
        answerList
      }

      const pointEl = questionItemEl.querySelector('.e-point .p-detail')
      if (pointEl) {
        questionObj.point = '相关知识点:' + pointEl.innerText.trim()
      } else {
        questionObj.point = '未匹配到任何相关知识点'
      }

      const rightAnswerEl = questionItemEl.querySelector('.right-answer')
      if (rightAnswerEl) {
        questionObj.rightAnswer = rightAnswerEl.innerText.trim() || '答案正确'
      } else {
        if (isExamMode) {
          questionObj.rightAnswer = '答案未知'
        } else {
          questionObj.rightAnswer = '答案正确'
        }
      }
      result.push(questionObj)

      if (callback instanceof Function) {
        try {
          callback(questionObj, questionItemEl)
        } catch (err) {
          console.error('eachQuestionItem error:', err, questionObj, questionItemEl)
        }
      }
    })
  }
  return result
}

/* 添加相关题目内容到题目面板下面,并且添加显示隐藏事件 */
function addRelatedQuestionsDom (questionItemEl, relatedQuestions) {
  const dd = document.createElement('dd')
  dd.setAttribute('class', 'relatedQuestions')
  dd.style.marginTop = '30px'
  dd.style.display = 'none'
  dd.style.border = '1px solid #ccc'
  dd.style.borderRadius = '5px'
  // dd.style.padding = '10px'
  // dd.style.backgroundColor = '#f9f9f9'

  if (questionItemEl.querySelector('.relatedQuestions')) {
    questionItemEl.removeChild(questionItemEl.querySelector('.relatedQuestions'))
  }

  if (relatedQuestions.length) {
    const codeEl = document.createElement('pre')
    codeEl.style.border = 'none'
    codeEl.innerHTML = JSON.stringify(relatedQuestions, null, 2)
    dd.appendChild(codeEl)
    questionItemEl.appendChild(dd)
  } else {
    dd.innerText = '暂无相关题目信息,先考几遍,然后查看考试结果再试试吧'
    questionItemEl.appendChild(dd)
  }

  questionItemEl.ondblclick = function (event) {
    const relatedQuestions = questionItemEl.querySelector('.relatedQuestions')
    if (relatedQuestions) {
      if (relatedQuestions.style.display === 'none') {
        relatedQuestions.style.display = 'block'
        relatedQuestions.style.opacity = 0.4
        relatedQuestions.style.overflow = 'auto'
        relatedQuestions.style.maxHeight = '200px'
      } else {
        relatedQuestions.style.display = 'none'
      }
    }
  }
}

/**
 * 自动匹配题目并尝试自动填充对应答案
 * @param questionsDatabase
 */
function autoMatchQuestionAndCheckedAnswer (questionsDatabase) {
  eachQuestionItem((questions, questionItemEl) => {
    const relatedQuestions = searchRelatedQuestions(questionsDatabase, questions)
    if (relatedQuestions.length) {
      const relatedQuestion = relatedQuestions[0]
      if (isNormalQuestion(relatedQuestion) && relatedQuestion.rightAnswer === '答案正确') {
        relatedQuestion.answerList.forEach((answer, index) => {
          if (/\s+--checked/.test(answer)) {
            const answerLabel = questionItemEl.querySelectorAll('label.option-label')
            if (answerLabel[index]) {
              answerLabel[index].click()
            }
          }
        })
      }
    } else {
      console.log('以下题目无法匹配答案:', questions, questionItemEl, relatedQuestions)
    }
  })
}

/* 隐藏相关题目面板 */
function hideRelatedQuestions () {
  const relatedQuestionsEls = document.querySelectorAll('.relatedQuestions')
  relatedQuestionsEls.forEach(item => {
    item.style.display = 'none'
  })
}

let hasInit = false
async function fuckKeju () {
  if (hasInit) { return false }
  console.log('科举小抄 init suc')
  hasInit = true

  const subjectTitle = document.querySelector('.yq-main-examination .top-info h2.title-content').innerText
  const isExamMode = document.querySelector('.yq-main-examination .time-info')

  let questionsDatabase = getQuestionsDatabase()

  /* 使用预置数据,而非定义的数据 */
  if (useCustomQuestionsDatabase) {
    questionsDatabase = customQuestionsDatabase
  }

  let findNewQuestion = false
  const curQuestionsList = eachQuestionItem((questions, questionItemEl) => {
    const relatedQuestions = searchRelatedQuestions(questionsDatabase, questions)
    addRelatedQuestionsDom(questionItemEl, relatedQuestions)

    /* 收集新题目数据 */
    if (!isExamMode && !hasTheSameQuestionsInRelatedQuestions(questions, relatedQuestions)) {
      findNewQuestion = true
      questionsDatabase.push(questions)

      if (findNewQuestion) {
        console.log('发现新的题目,或新的答案记录:', questions)
      }
    }
  })

  /* 提示到控制面板,用于手动收集题目数据 */
  console.log(JSON.stringify(curQuestionsList, null, 2))

  /* 重新写入收集到的题目数据 */
  if (findNewQuestion) {
    // localStorage.setItem(subjectTitle, JSON.stringify(questionsDatabase))
  }
  localStorage.setItem(subjectTitle, JSON.stringify(questionsDatabase))

  /* 考试模式下双击标题尝试自填充答案 */
  const subjectEl = document.querySelector('.yq-main-examination .top-info h2.title-content')
  subjectEl.ondblclick = function () {
    if (isExamMode) {
      autoMatchQuestionAndCheckedAnswer(questionsDatabase)
    }
  }

  /* 切换题目时候,隐藏小抄 */
  const switchDoms = document.querySelectorAll('.question-num span.item')
  const switchDoms02 = document.querySelectorAll('.e-opt-panel a')

  switchDoms.forEach(el => {
    el.onmouseenter = hideRelatedQuestions
  })
  switchDoms02.forEach(el => {
    el.onclick = hideRelatedQuestions
  })

  /* 通过控制面板提取题库 */
  const pageWindow = await getPageWindow()
  pageWindow.extractQuestionList = function (print) {
    const questionsDatabase = getQuestionsDatabase()
    const questionList = extractQuestionList(questionsDatabase)
    if (print) {
      console.log(JSON.stringify(questionList, null, 2))
    }
    return questionList
  }
}

function ready (selector, fn, shadowRoot) {
  const listeners = []
  const win = window
  const doc = shadowRoot || win.document
  const MutationObserver = win.MutationObserver || win.WebKitMutationObserver
  let observer

  function $ready (selector, fn) {
    // 储存选择器和回调函数
    listeners.push({
      selector: selector,
      fn: fn
    })
    if (!observer) {
      // 监听document变化
      observer = new MutationObserver(check)
      observer.observe(shadowRoot || doc.documentElement, {
        childList: true,
        subtree: true
      })
    }
    // 检查该节点是否已经在DOM中
    check()
  }

  function check () {
    for (let i = 0; i < listeners.length; i++) {
      var listener = listeners[i]
      var elements = doc.querySelectorAll(listener.selector)
      for (let j = 0; j < elements.length; j++) {
        var element = elements[j]
        if (!element._isMutationReady_) {
          element._isMutationReady_ = true
          listener.fn.call(element, element)
        }
      }
    }
  }

  $ready(selector, fn)
}

ready('.question-panel .question-item', () => {
  /**
   * 此处必须延迟执行,题目渲染和选中渲染是异步操作
   * 需要延时等待选中的渲染成功才执行初始化逻辑
   */
  console.log('检查到进入了试题页面,即将为你初始化小抄逻辑')
  setTimeout(function () {
    fuckKeju()
  }, 1000 * 3)
})