bilibili 枝网查重 API 版

bilibili 枝网(asoulcnki.asia)查重 API 版

// ==UserScript==
// @name         bilibili 枝网查重 API 版
// @namespace    https://github.com/sparanoid/userscript
// @supportURL   https://github.com/sparanoid/userscript/issues
// @version      0.1.12
// @description  bilibili 枝网(asoulcnki.asia)查重 API 版
// @author       Sparanoid
// @license      AGPL
// @compatible   chrome 80 or later
// @compatible   edge 80 or later
// @compatible   firefox 74 or later
// @compatible   safari 13.1 or later
// @match        https://*.bilibili.com/*
// @icon         https://experiments.sparanoid.net/favicons/v2/www.bilibili.com.ico
// @grant        none
// @run-at       document-start
// ==/UserScript==

window.addEventListener('load', () => {
  const DEBUG = true;
  const NAMESPACE = 'bilibili-asoulcnki';
  const apiBase = 'https://asoulcnki.asia';
  const refTag = '?utm_source=bilibili-asoulcnki-plugin&utm_campaign=tampermonkey'
  const feedbackUrl = 'https://t.bilibili.com/545085157213602473';

  console.log(`${NAMESPACE} loaded`);

  async function fetchResult(url = '', data = {}) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });
    return response.json();
  }

  function debug(description = '', msg = '', force = false) {
    if (DEBUG || force) {
      console.log(`${NAMESPACE}: ${description}`, msg)
    }
  }

  function formatDate(timestamp) {
    let date = timestamp.toString().length === 10 ? new Date(+timestamp * 1000) : new Date(+timestamp);
    return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
  }

  function rateColor(percent) {
    return `hsl(${100 - percent}, 70%, 45%)`;
  }

  function percentDisplay(num) {
    return num.toFixed(2).replace('.00', '');
  }

  function sanitize(string) {
    const map = {
      '&': '&',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#x27;',
      "/": '&#x2F;',
    };
    const reg = /[&<>"'/]/ig;
    return string.replace(reg, match => map[match]);
  }

  function attachEl(item) {
    let injectWrap = item.querySelector('.con .info');

    // .text - comment content
    // .text-con - reply content
    let content = item.querySelector('.con .text') || item.querySelector('.reply-con .text-con');
    let id = item.dataset.id;

    // Simple way to attach element on replies initially loaded with comment
    // which wouldn't trigger mutation inside observeComments
    let replies = item.querySelectorAll('.con .reply-box .reply-item');
    if (replies.length > 0) {
      [...replies].map(reply => {
        attachEl(reply);
      });
    }

    if (injectWrap.querySelector('.asoulcnki')) {
      debug('already loaded for this comment');
    } else {
      // Insert asoulcnki check button
      let asoulcnkiEl = document.createElement('span');

      asoulcnkiEl.classList.add('asoulcnki', 'btn-hover', 'btn-highlight');
      asoulcnkiEl.innerHTML = '狠狠地查';
      asoulcnkiEl.addEventListener('click', e => {
        let contentPrepared = '';

        // Copy meme icons alt text
        for (let node of content.childNodes.values()) {
          if (node.nodeType === 3) {
            contentPrepared += node.textContent;
          } else if (node.nodeName === 'IMG' && node.nodeType === 1) {
            contentPrepared += node.alt;
          } else if (node.nodeName === 'BR' && node.nodeType === 1) {
            contentPrepared += '\n';
          } else if (node.nodeName === 'A' && node.nodeType === 1 && node.classList.contains('comment-jump-url')) {
            contentPrepared += node.href.replace(/https?:\/\/www\.bilibili\.com\/video\//, '');
          } else {
            contentPrepared += node.innerText;
          }
        }

        // Need regex to stripe `回复 @username  :`
        let contentProcessed = contentPrepared.replace(/回复 @.*:/, '');
        debug('content processed', contentProcessed);

        // ask to confirm if words count not enough
        if (contentProcessed.length < 10 && !confirm('内容过短(少于 10 字),可能无法得到正确结果,是否继续查询?')) return;

        fetchResult(`${apiBase}/v1/api/check`, {
          text: contentProcessed
        })
        .then(data => {
          debug('data returned', data);

          let resultContent = '';

          if (data.code !== 0) {
            resultContent = `返回结果错误,可能是文本内容过短,或请访问 <a href="${apiBase}/${refTag}" target="_blank">枝网</a> 查看服务是否正常\n枝网返回结果参考:${data?.code || ''} ${data?.message || ''}`;
          } else {
            let result = data.data;
            let startTime = result.start_time;
            let endTime = result.end_time;
            let rate = result.rate * 100;
            let relatedItems = result.related;

            resultContent = `<a href="${apiBase}/${refTag}" target="_blank">枝网</a>文本复制检测报告(油猴一键版 ${feedbackUrl})
查重时间:${formatDate(Date.now())}
总文字复制比:<b style="color: ${rateColor(rate)}">${percentDisplay(rate)}%</b>\n`;

            if (relatedItems.length === 0) {
              resultContent += `一眼原创,再偷必究(查重结果仅作娱乐参考)`;
            } else {
              let selfOriginal = +relatedItems[0].reply.rpid === +id ? `(<span style="color: blue;">本文原创/原偷,已收录</span>)` : '';
              let relatedCountAlert = relatedItems.length === 5 ? `(最多只显示最近 5 次)` : '';

              resultContent += `重复次数:${relatedItems.length}${selfOriginal}${relatedCountAlert}\n`;

              relatedItems.map((item, idx) => {
                let rate = item.rate * 100;

                resultContent += `#${idx + 1} <span style="color: ${rateColor(rate)}">${percentDisplay(rate)}%</span> <a href="${item.reply_url.trim()}" title="${sanitize(item.reply.content)}" target="_blank">${item.reply_url.trim()}</a>
发布于:${formatDate(item.reply.ctime)}
作者:${item.reply.m_name} (UID <a href="https://space.bilibili.com/${item.reply.mid}" target="_blank">${item.reply.mid}</a>)\n\n`;
              });

              resultContent += `查重结果仅作娱乐参考,请注意辨别是否为原创`;
            }
          }

          // Insert result
          let resultWrap = document.createElement('div');

          resultWrap.style.position = 'relative';
          resultWrap.style.padding = '.5rem';
          resultWrap.style.margin = '.5rem 0';
          resultWrap.style.background = 'hsla(0, 0%, 50%, .1)';
          resultWrap.style.borderRadius = '4px';
          resultWrap.style.whiteSpace = 'pre';
          resultWrap.classList.add('asoulcnki-result');
          resultWrap.innerHTML = resultContent;

          // Create close button
          let asoulcnkiCloseBtn = document.createElement('span');
          asoulcnkiCloseBtn.classList.add('asoulcnki-close');
          asoulcnkiCloseBtn.innerHTML = '+';
          asoulcnkiCloseBtn.style.position = 'absolute';
          asoulcnkiCloseBtn.style.top = '.5rem';
          asoulcnkiCloseBtn.style.right = '.5rem';
          asoulcnkiCloseBtn.style.width = '16px';
          asoulcnkiCloseBtn.style.height = '16px';
          asoulcnkiCloseBtn.style.fontSize = '16px';
          asoulcnkiCloseBtn.style.lineHeight = '1';
          asoulcnkiCloseBtn.style.textAlign = 'center';
          asoulcnkiCloseBtn.style.transform = 'rotate(45deg)';
          asoulcnkiCloseBtn.style.cursor = 'pointer';
          asoulcnkiCloseBtn.addEventListener('click', e => {
            injectWrap.querySelector('.asoulcnki-result').remove();
          });

          resultWrap.append(asoulcnkiCloseBtn);

          // Remove previous result if exists
          if (injectWrap.querySelector('.asoulcnki-result')) {
            injectWrap.querySelector('.asoulcnki-result').remove();
          }
          injectWrap.append(resultWrap);
        })
        .catch(error => {
          alert(`枝网后端出错,请检查网络,报错信息:${error}`);
          debug('fetch error', error);
        });
      }, false);

      injectWrap.append(asoulcnkiEl);

      // Insert comment ID link
      let idLink = document.createElement('a');

      idLink.innerHTML = '#';
      idLink.setAttribute('title', '当前评论 ID: ' + id);
      idLink.setAttribute('href', '#reply' + id);
      idLink.style.marginRight = '.25em';

      injectWrap.prepend(idLink);
    }
  }

  function observeComments(wrapper) {
    // .comment-list - general list for video, zhuanlan, and dongtai
    // .reply-box - replies attached to specific comment
    let commentLists = wrapper ? wrapper.querySelectorAll('.comment-list, .reply-box') : document.querySelectorAll('.comment-list, .reply-box');

    if (commentLists) {

      [...commentLists].map(commentList => {

        // Directly attach elements for pure static server side rendered comments
        // and replies list. Used by zhuanlan posts with reply hash in URL.
        // TODO: need a better solution
        [...commentList.querySelectorAll('.list-item, .reply-item')].map(item => {
          attachEl(item);
        });

        const observer = new MutationObserver((mutationsList, observer) => {

          for (const mutation of mutationsList) {

            if (mutation.type === 'childList') {

              debug('observed mutations', [...mutation.addedNodes].length);

              [...mutation.addedNodes].map(item => {
                attachEl(item);

                // Check if the comment has replies
                // I check replies here to make sure I can disable subtree option for
                // MutationObserver to get better performance.
                let replies = item.querySelectorAll('.con .reply-box .reply-item');

                if (replies.length > 0) {
                  observeComments(item)
                  debug(item.dataset.id + ' has rendered reply(ies)', replies.length);
                }
              })
            }
          }
        });
        observer.observe(commentList, { attributes: false, childList: true, subtree: false });
      });
    }
  }

  // .bb-comment loads directly for zhuanlan post. So load it directly
  observeComments();

  // .bb-comment loads dynamcially for dontai and videos. So observe it first
  const wrapperObserver = new MutationObserver((mutationsList, observer) => {

    for (const mutation of mutationsList) {

      if (mutation.type === 'childList') {

        [...mutation.addedNodes].map(item => {
          debug('mutation wrapper added', item);

          if (item.classList?.contains('bb-comment')) {
            debug('mutation wrapper added (found target)', item);

            observeComments(item);

            // Stop observing
            // TODO: when observer stops it won't work for dynamic homepage ie. https://space.bilibili.com/703007996/dynamic
            // so disable it here. This may have some performance impact on low-end machines.
            // wrapperObserver.disconnect();
          }
        })
      }
    }
  });
  wrapperObserver.observe(document.body, { attributes: false, childList: true, subtree: true });

}, false);