Greasy Fork

Greasy Fork is available in English.

N站视频信息查询

获取 B 站视频简介中 N 站视频的实时信息,包括播放量、弹幕数、简介等,并显示在视频简介中。

当前为 2022-02-20 提交的版本,查看 最新版本

// ==UserScript==
// @name         N站视频信息查询
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  获取 B 站视频简介中 N 站视频的实时信息,包括播放量、弹幕数、简介等,并显示在视频简介中。
// @author       ctrn43062
// @match        *://*.bilibili.com/video/*
// @icon         https://www.bilibili.com/favicon.ico
// @grant        none
// @license      MIT
// @note         v0.2 适配旧版播放页
// ==/UserScript==

const REVERSE_PROXY_API = 'https://api.nicovideo.workers.dev/'


function toLink(type, target, text) {
  let href = ''

  switch (type) {
    case 'video':
      href = `https://acg.tv/${target}`;
      break;
    case 'user':
      href = `https://www.nicovideo.jp/user/${target}`;
      break;
    case 'tag':
      href = `https://www.nicovideo.jp/tag/${target}`;
      break;
  }

  return `<a href="${href}" target="_blank">${text}</a>`
}


async function getVideoInfoData(sm) {
  return await fetch(`${REVERSE_PROXY_API}/${sm}`).then(resp => resp.json())
}


function parseVideoInfo(sm, data) {
  if (data['code'] !== 200) {
    // throw new Error(`Request API Response Error:${sm}\n${data}`)
    return {
      status: '请求 cf 接口失败。'
    }
  }

  const xml = (new DOMParser()).parseFromString(data['data'], 'text/xml');
  const response = xml.firstChild;

  if (response.getAttribute('status') !== 'ok') {
    // throw new Error(`Request Video Info Error:${sm}\n${response}`)
    return {
      status: `获取 ${toLink('video', sm, sm)} 数据失败,视频可能已被删除。`
    }
  }

  function _parse() {
    const user_id = response.querySelector('user_id').textContent;
    const username = response.querySelector('user_nickname').textContent;
    const title = response.querySelector('title').textContent;
    const description = response.querySelector('description').textContent.replaceAll(/(sm\d+)/g, '<a href="https://acg.tv/$1" target="_blank">$1</a>');
    const post_at = response.querySelector('first_retrieve').textContent;
    let view = +response.querySelector('view_counter').textContent;
    let comment = +response.querySelector('comment_num').textContent;
    let favorite = +response.querySelector('mylist_counter').textContent;
    const tagsEle = response.querySelectorAll('tags > tag');

    const tags = [];
    tagsEle.forEach(tagEle => {
      tags.push(tagEle.textContent);
    });

    const tags_link = tags.map(tag => toLink('tag', tag, tag)).join(' | ')

    const base = 10000;

    if (view >= base) {
        view = (view / base).toFixed(1) + '万';
    }

    if (comment >= base) {
        comment = (comment / base).toFixed(1) + '万';
    }

    if (favorite >= base) {
        favorite = (favorite / base).toFixed(1) + '万';
    }

    return {
      status: 'ok',
      title,
      description,
      post_at,
      view,
      comment,
      favorite,
      tags: tags_link,
      user_id,
      username,
      id: sm
    }
  }

  return _parse();
}


function createVideoInfoElement(info) {
  const infoEle = document.createElement('span');
  infoEle.className = 'desc-info-text nico-video-detail';

  if (info['status'] !== 'ok') {
    infoEle.innerHTML = `\n\n<strong>出错了:${info['status']}</strong>`;
    return infoEle;
  }

  infoEle.innerHTML = `
        \n<strong>${toLink('video', info['id'], info['id'])} 的详细信息:</strong>
        标题:
        ${info['title']}

        简介:
        ${info['description'] || '(无简介)'}

        投稿时间: ${(new Date(info['post_at'])).toLocaleString()}

        播放量: ${info['view']}
        评论数(弹幕数): ${info['comment']}
        收藏量: ${info['favorite']}

        投稿者: ${toLink('user', info['user_id'], info['username'])}

        ${info['tags']}
    `

  return infoEle;
}


function insertVideoInfoToDesc(data) {
  const element = createVideoInfoElement(data);
  const container = document.querySelector('.desc-info.desc-v2');
  container.appendChild(element);
}


(async function () {
  const observer = new MutationObserver((muls) => {
    for (const mul of muls) {
      // v0.1 B站视频简介不知道为什么会被重新加载一次,具体行为是删除原先的简介 DOM 然后插入新 DOM,因此这里通过判断是否有 DOM 被删除来决定是否插入数据
      if (mul.removedNodes.length) {

        let oldDetailRemoved = false;

        for (const removedNode of mul.removedNodes) {
          // 移除旧元素时同样会触发 observer,需要判断当前移除的是否为旧元素,如果是则不发送请求
          if (removedNode.nodeType === 1 && removedNode.classList.contains('nico-video-detail')) {
            oldDetailRemoved = true;
            break;
          }
        }

        document.querySelectorAll('.nico-video-detail').forEach(item => {
          item.remove();
        })

        if (oldDetailRemoved) {
          continue;
        }

        const video_ids = new Set(document.querySelector('.desc-info.desc-v2 > span').textContent.match(/sm\d+/g));
        // 如果简介长度无需折叠,则不会显示展开按钮。但是加上视频详情后可能需要折叠,所以强制开启折叠按钮
        const toggleBtn = document.querySelector('.toggle-btn');

        if (toggleBtn) {
          toggleBtn.style.display = 'block';
        }

        video_ids.forEach(async id => {
          console.log('[DEBUG]', 'requesting', id);
          // TODO:
          // 在这里插入 cache 相关代码
          const data = parseVideoInfo(id, await getVideoInfoData(id));
          insertVideoInfoToDesc(data);
        });
      }
    }
  })

  const container = document.querySelector('.desc-info.desc-v2');

  observer.observe(container, {
    subtree: true,
    childList: true
  });
})();