// ==UserScript==
// @name N站视频信息查询
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 获取 B 站视频简介中 N 站视频的实时信息,包括播放量、弹幕数、简介等,并显示在视频简介中。
// @author ctrn43062
// @match *://*.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @grant none
// @license MIT
// @downloadURL none
// ==/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 `${text}`
}
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, '$1');
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出错了:${info['status']}`;
return infoEle;
}
infoEle.innerHTML = `
\n${toLink('video', info['id'], info['id'])} 的详细信息:
标题:
${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('span.desc-info-text').textContent.match(/sm\d+/g));
// 如果简介长度无需折叠,则不会显示展开按钮。但是加上视频详情后可能需要折叠,所以强制开启折叠按钮
const toggleBtn = document.querySelector('.toggle-btn');
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
});
})();