DLSite RJ code preview

Make RJ code great again!

23.09.2022 itibariyledir. En son verisyonu görün.

// ==UserScript==
// @name        DLSite RJ code preview
// @namespace   SettingDust
// @description Make RJ code great again!
// @include     *://*/*
// @version     2.1.4
// @license     MIT
// @grant       GM.xmlHttpRequest
// @grant       GM_xmlhttpRequest
// @run-at      document-start
// ==/UserScript==

const RJ_REGEX = new RegExp('R[JE][0-9]{6}', 'gi')
const VOICELINK_CLASS = 'voicelink'
const RJCODE_ATTRIBUTE = 'rjcode'
const css = `
.voicepopup {
  z-index: 50000;
  max-width: 80%;
  max-height: 80%;
  position: fixed;
  box-shadow: 0 0 0 2.5px rgba(0, 0, 0, 0.12);
  border-radius: 16px;
  background-color: white;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.voicepopup img {
  width: 100%;
  height: auto;
  max-width: 360px;
}

.voicelink {
  text-shadow: 1px 1px 1px #333;
  color: #333;
}

.voicelink: hover {
  text-decoration: none;
}

.voicepopup > .voice-info {
  padding: 12px 16px;
  font-size: 0.88rem;
  line-height: 1.2;
  max-width: 360px;
  display: grid;
  grid-gap: 6px;
  box-sizing: border-box;
}

.voicepopup > .voice-info > p {
  margin: 0;
  font-weight: bold;
}

.voicepopup > .voice-info > p > span {
  font-weight: normal;
}

.voicepopup .voice-title {
  margin: 0;
  font-size: 1.1rem;
  font-weight: bold;
  line-height: 1;
}

.voicepopup .error {
  height: 210px;
  line-height: 210px;
  text-align: center;
}

.voicepopup.discord-dark {
  background-color: #36393f;
  color: #dcddde;
  font-size: 0.9375rem;
}`

function getAdditionalPopupClasses() {
  const hostname = document.location.hostname
  switch (hostname) {
    case 'boards.4chan.org':
      return 'post reply'
    case 'discordapp.com':
      return 'discord-dark'
    default:
      return null
  }
}

function getXmlHttpRequest() {
  return typeof GM !== 'undefined' && GM !== null
    ? GM.xmlHttpRequest
    : GM_xmlhttpRequest
}

const Parser = {
  walkNodes: function (elem) {
    const rjNodeTreeWalker = document.createTreeWalker(
      elem,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode: function (node) {
          if (node.parentElement.classList.contains(VOICELINK_CLASS))
            return NodeFilter.FILTER_ACCEPT
          if (node.nodeValue.match(RJ_REGEX)) return NodeFilter.FILTER_ACCEPT
        },
      },
      false,
    )
    while (rjNodeTreeWalker.nextNode()) {
      const node = rjNodeTreeWalker.currentNode
      if (node.parentElement.classList.contains(VOICELINK_CLASS))
        Parser.rebindEvents(node.parentElement)
      else {
        Parser.linkify(node)
      }
    }
  },

  wrapRJCode: function (rjCode) {
    var e
    e = document.createElement('a')
    e.classList = VOICELINK_CLASS
    e.href = `https://www.dlsite.com/maniax/work/=/product_id/${rjCode}.html`
    e.innerHTML = rjCode
    e.target = '_blank'
    e.rel = 'noreferrer'
    e.classList.add(rjCode)
    e.setAttribute(RJCODE_ATTRIBUTE, rjCode.toUpperCase())
    e.addEventListener('mouseover', Popup.over)
    e.addEventListener('mouseout', Popup.out)
    e.addEventListener('mousemove', Popup.move)
    return e
  },

  linkify: function (textNode) {
    const nodeOriginalText = textNode.nodeValue
    const matches = []

    let match
    while ((match = RJ_REGEX.exec(nodeOriginalText))) {
      matches.push({
        index: match.index,
        value: match[0],
      })
    }

    // Keep text in text node until first RJ code
    textNode.nodeValue = nodeOriginalText.substring(0, matches[0].index)

    // Insert rest of text while linkifying RJ codes
    let prevNode = null
    for (let i = 0; i < matches.length; ++i) {
      // Insert linkified RJ code
      const rjLinkNode = Parser.wrapRJCode(matches[i].value)
      textNode.parentNode.insertBefore(
        rjLinkNode,
        prevNode ? prevNode.nextSibling : textNode.nextSibling,
      )

      // Insert text after if there is any
      let upper
      if (i === matches.length - 1) upper = undefined
      else upper = matches[i + 1].index
      let substring
      if (
        (substring = nodeOriginalText.substring(matches[i].index + 8, upper))
      ) {
        const subtextNode = document.createTextNode(substring)
        textNode.parentNode.insertBefore(
          subtextNode,
          rjLinkNode.nextElementSibling,
        )
        prevNode = subtextNode
      } else {
        prevNode = rjLinkNode
      }
    }
  },

  rebindEvents: function (elem) {
    if (elem.nodeName === 'A') {
      elem.addEventListener('mouseover', Popup.over)
      elem.addEventListener('mouseout', Popup.out)
      elem.addEventListener('mousemove', Popup.move)
    } else {
      const voicelinks = elem.querySelectorAll('.' + VOICELINK_CLASS)
      for (var i = 0, ii = voicelinks.length; i < ii; i++) {
        const voicelink = voicelinks[i]
        voicelink.addEventListener('mouseover', Popup.over)
        voicelink.addEventListener('mouseout', Popup.out)
        voicelink.addEventListener('mousemove', Popup.move)
      }
    }
  },
}

var globalCodes = []

const Popup = {
  makePopup: function (e, rjCode) {
    const popup = document.createElement('div')
    popup.className = 'voicepopup ' + (getAdditionalPopupClasses() || '')
    popup.id = 'voice-' + rjCode
    popup.style = 'display: flex'
    document.body.appendChild(popup)
    DLsite.request(rjCode, function (workInfo) {
      if (workInfo === null)
        popup.innerHTML = "<div class='error'>Work not found.</span>"
      else {
        const img = document.createElement('img')
        img.src = workInfo.img
        img.onload = () => Popup.move(e)

        let html = `<div class='voice-info'>
                       <h4 class='voice-title'>${workInfo.title.trim()}</h4>
                       <p>社团名:<span>${workInfo.circle.trim()}</span></p>`
        if (workInfo.date)
          html += `<p>贩卖日:<span>${workInfo.date}</span></p>`
        else if (workInfo.dateAnnounce)
          html += `<p>发布日期:<span>${workInfo.dateAnnounce}</span></p>`

        html += `<p>年龄指定:<span>${workInfo.rating.trim()}</span></p>`

        if (workInfo.cv) html += `<p>声优:<span>${workInfo.cv}</span></p>`
        if (workInfo.tags) {
          html += `<p>分类:<span>`
          workInfo.tags.forEach((tag) => { html += tag + '\u3000' })
          html += '</span></p>'
        }

        if (workInfo.filesize) html += `<p>文件容量:<span>${workInfo.filesize}</span></p>`

        html += '</div>'
        popup.innerHTML = html
        popup.insertBefore(img, popup.childNodes[0])
      }

      Popup.move(e)
    })
  },
  humanFileSize: function (size) {
    if (!size) return ''
    var i = Math.floor(Math.log(size) / Math.log(1024))
    return (
      (size / Math.pow(1024, i)).toFixed(2) * 1 +
      ' ' +
      ['B', 'kB', 'MB', 'GB', 'TB'][i]
    )
  },

  over: function (e) {
    const rjCode = e.target.getAttribute(RJCODE_ATTRIBUTE)
    const popup = document.querySelector('div#voice-' + rjCode)
    if (popup) {
      const style = popup.getAttribute('style').replace('none', 'flex')
      popup.setAttribute('style', style)
    } else {
      Popup.makePopup(e, rjCode)
    }
  },

  out: function (e) {
    const rjCode = e.target.getAttribute('rjcode')
    const popup = document.querySelector('div#voice-' + rjCode)
    if (popup) {
      const style = popup.getAttribute('style').replace('flex', 'none')
      popup.setAttribute('style', style)
    }
  },

  move: function (e) {
    const rjCode = e.target.getAttribute('rjcode')
    const popup = document.querySelector('div#voice-' + rjCode)
    if (popup) {
      // 如果右侧没有超出屏幕范围
      if (popup.offsetWidth + e.clientX + 24 < window.innerWidth) {
        popup.style.left = e.clientX + 8 + 'px'
      } else {
        // 显示在左侧
        popup.style.left = e.clientX - popup.offsetWidth - 8 + 'px'
      }

      // 如果下方超出屏幕范围
      if (popup.offsetHeight + e.clientY + 16 > window.innerHeight) {
        // 尽可能靠下
        popup.style.top = window.innerHeight - popup.offsetHeight - 16 + 'px'
      } else {
        popup.style.top = e.clientY + 'px'
      }
    }
  },
}

const DLsite = {
  parseWorkDOM: function (dom, rj) {
    // workInfo: {
    //     rj: any;
    //     img: string;
    //     title: any;
    //     circle: any;
    //     date: any;
    //     rating: any;
    //     tags: any[];
    //     cv: any;
    //     filesize: any;
    //     dateAnnounce: any;
    // }
    const workInfo = {}
    workInfo.rj = rj

    let rj_group
    if (rj.slice(5) == '000') rj_group = rj
    else {
      rj_group = (parseInt(rj.slice(2, 5)) + 1).toString() + '000'
      rj_group = 'RJ' + ('000000' + rj_group).substring(rj_group.length)
    }

    workInfo.img =
      'https://img.dlsite.jp/modpub/images2/work/doujin/' +
      rj_group +
      '/' +
      rj +
      '_img_main.jpg'
    workInfo.title = dom.getElementById('work_name').textContent
    workInfo.circle = dom.querySelector('span.maker_name').textContent

    const table_outline = dom.querySelector('table#work_outline')
    for (var i = 0, ii = table_outline.rows.length; i < ii; i++) {
      const row = table_outline.rows[i]
      const row_header = row.cells[0].textContent
      const row_data = row.cells[1]
      switch (true) {
        case row_header.includes('贩卖日'):
          workInfo.date = row_data.textContent
          break
        case row_header.includes('年龄指定'):
          workInfo.rating = row_data.textContent
          break
        case row_header.includes('分类'):
          const tag_nodes = row_data.querySelectorAll('a')
          workInfo.tags = [...tag_nodes].map((a) => {
            return a.textContent
          })
          break
        case row_header.includes('声优'):
          workInfo.cv = row_data.textContent
          break
        case row_header.includes('文件容量'):
          workInfo.filesize = row_data.textContent.replace('合计', '').trim()
          break
        default:
          break
      }
    }

    const work_date_ana = dom.querySelector('strong.work_date_ana')
    if (work_date_ana) {
      workInfo.dateAnnounce = work_date_ana.innerText
      workInfo.img =
        'https://img.dlsite.jp/modpub/images2/ana/doujin/' +
        rj_group +
        '/' +
        rj +
        '_ana_img_main.jpg'
    }
    console.log(workInfo)

    return workInfo
  },

  request: function (rjCode, callback) {
    const url = `https://www.dlsite.com/maniax/work/=/product_id/${rjCode}.html/?locale=zh_CN`
    getXmlHttpRequest()({
      method: 'GET',
      url,
      headers: {
        Accept: 'text/xml',
        'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:67.0)',
      },
      onload: function (resp) {
        if (resp.readyState === 4 && resp.status === 200) {
          const dom = new DOMParser().parseFromString(
            resp.responseText,
            'text/html',
          )
          const workInfo = DLsite.parseWorkDOM(dom, rjCode)
          callback(workInfo)
        } else if (resp.readyState === 4 && resp.status === 404)
          DLsite.requestAnnounce(rjCode, callback)
      },
    })
  },

  requestAnnounce: function (rjCode, callback) {
    const url = `https://www.dlsite.com/maniax/announce/=/product_id/${rjCode}.html/?locale=ja_JP`
    getXmlHttpRequest()({
      method: 'GET',
      url,
      headers: {
        Accept: 'text/xml',
        'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:67.0)',
      },
      onload: function (resp) {
        if (resp.readyState === 4 && resp.status === 200) {
          const dom = new DOMParser().parseFromString(
            resp.responseText,
            'text/html',
          )
          const workInfo = DLsite.parseWorkDOM(dom, rjCode)
          callback(workInfo)
        } else if (resp.readyState === 4 && resp.status === 404) callback(null)
      },
    })
  },
}

document.addEventListener('DOMContentLoaded', function () {
  const style = document.createElement('style')
  style.innerHTML = css
  document.head.appendChild(style)

  Parser.walkNodes(document.body)

  const observer = new MutationObserver(function (m) {
    for (let i = 0; i < m.length; ++i) {
      let addedNodes = m[i].addedNodes

      for (let j = 0; j < addedNodes.length; ++j) {
        Parser.walkNodes(addedNodes[j])
      }
    }
  })

  document.addEventListener('securitypolicyviolation', function (e) {
    if (e.blockedURI.includes('img.dlsite.jp')) {
      const img = document.querySelector(`img[src="${e.blockedURI}"]`)
      img.remove()
    }
  })

  observer.observe(document.body, { childList: true, subtree: true })
})