// ==UserScript== // @name N站视频信息查询 // @namespace http://tampermonkey.net/ // @version 0.4 // @description 获取 B 站视频简介中 N 站视频的实时信息,包括播放量、弹幕数、简介等,并显示在视频简介中。 // @author ctrn43062 // @match *://*.bilibili.com/video/* // @icon https://www.bilibili.com/favicon.ico // @grant none // @license MIT // @note v0.4 信息格式调整为播放等数据在视频标题下;支持自定义视频数据位置 // @note v0.3 替换跳转链接 acg.tv 为 N 视频链接 // @note v0.2 适配旧版播放页 // @downloadURL none // ==/UserScript== // 修复请求失败替换原简介的bug const REVERSE_PROXY_API = 'https://f7z1to.deta.dev' // 视频信息是否在原视频简介前插入 // true 是 // false 否 const INSERT_INFO_BEFORE = false function getURL() { return location.origin + location.pathname } function toLink(type, target, text) { const BASE_URL = 'https://www.nicovideo.jp' const PATHS = { 'video': 'watch', 'user': 'user', 'tag': 'tag' } let href = `${BASE_URL}/${PATHS[type]}/${target}` return `${text}` } async function getVideoInfoData(sm) { const headers = { 'x-url': getURL(), 'x-title': document.title } try { console.log('[DEBUG]', 'requesting', sm); return await fetch(`${REVERSE_PROXY_API}/${sm}`, { headers }).then(resp => resp.json()) } catch (e) { return { data: -1 } } } function parseVideoInfo(sm, data) { if (data['code'] !== 200) { // throw new Error(`Request API Response Error:${sm}\n${data}`) console.log(data) return { status: '请求视频信息接口失败。可能是因为接口被墙。' } } 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'); if (info['status'] !== 'ok') { infoEle.innerHTML = `出错了:${info['status']}
`; return infoEle; } infoEle.innerHTML = `${toLink('video', info['id'], info['id'])} 的详细信息: 标题: ${info['title']} 播放量: ${info['view']} 评论数(弹幕数): ${info['comment']} 收藏量: ${info['favorite']} 简介: ${info['description'] || '(无简介)'} 投稿时间: ${(new Date(info['post_at'])).toLocaleString()} 投稿者: ${toLink('user', info['user_id'], info['username'])} ${info['tags']} ` return infoEle; } function insertVideoInfoToDesc(data) { const element = createVideoInfoElement(data).innerHTML; const container = document.querySelector('.desc-info.desc-v2 > span'); const html = container.innerHTML const title = `${INSERT_INFO_BEFORE ? '原始简介:' : ''}
` if (INSERT_INFO_BEFORE) { container.innerHTML = element + '\n' + title + html } else { container.innerHTML = html + '

' + element } container.innerHTML = `${container.innerHTML}` } async function setDescription(description) { if (!setDescription.cache) { setDescription.cache = {} } const cache = setDescription.cache const id_list = new Set(description); // 如果简介长度无需折叠,则不会显示展开按钮。但是加上视频详情后可能需要折叠,所以强制开启折叠按钮 const toggleBtn = document.querySelector('.toggle-btn'); if (toggleBtn) { toggleBtn.style.display = 'block'; } id_list.forEach(async id => { cache[id] = cache[id] || parseVideoInfo(id, await getVideoInfoData(id)); insertVideoInfoToDesc(cache[id]); }); } function watingForPageLoaded() { return new Promise((resolve) => { const descEle = document.querySelector('.desc-info.desc-v2') const isOldStyle = document.querySelector('.tip-info') const it = setInterval(() => { if ((descEle.style.height || isOldStyle) && !descEle.querySelector('.nico-video')) { clearInterval(it) resolve() } }, 10); }) } (function () { let currentURL = getURL() new MutationObserver(async () => { const url = getURL() if (url !== currentURL) { currentURL = url; onUrlChange(); } }).observe(document.head, { subtree: true, childList: true }); async function onUrlChange() { await watingForPageLoaded() const descriptionEle = document.querySelector('.desc-info.desc-v2 > span') const description = descriptionEle.textContent.match(/sm\d+/g) setDescription(description) } onUrlChange() })();