B站番剧评分统计

自动统计B站番剧评分,支持短评/长评综合统计

  1. // ==UserScript==
  2. // @name B站番剧评分统计
  3. // @namespace https://pro-ivan.com/
  4. // @version 1.4.1
  5. // @description 自动统计B站番剧评分,支持短评/长评综合统计
  6. // @author YujioNako & 看你看过的霓虹
  7. // @match https://www.bilibili.com/bangumi/*
  8. // @grant GM_addStyle
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM_deleteValue
  12. // @homepage https://github.com/YujioNako/true_ranking_plugin/
  13. // @icon https://pro-ivan.com/favicon.ico
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. class ControlPanel {
  21. constructor() {
  22. this.isOpen = false;
  23. this.currentMd = null;
  24. this.createUI();
  25. this.bindEvents();
  26. //this.autoFillInput();
  27. }
  28.  
  29. createUI() {
  30. // 侧边栏按钮
  31. this.toggleBtn = document.createElement('div');
  32. this.toggleBtn.className = 'control-btn';
  33. this.toggleBtn.textContent = '评分统计';
  34.  
  35. // 控制台主体
  36. this.panel = document.createElement('div');
  37. this.panel.className = 'control-panel';
  38. this.panel.innerHTML = `
  39. <div class="panel-header">
  40. <h3>B站番剧评分统计</h3>
  41. <button class="close-btn">×</button>
  42. </div>
  43. <div class="panel-content">
  44. <div class="input-group">
  45. <label>输入MD/EP ID或链接:</label>
  46. <input type="text" id="bInput" class="b-input" placeholder="md123456 或 B站链接">
  47. </div>
  48. <button class="start-btn" id="start-btn">开始统计</button>
  49. <div class="progress-area"></div>
  50. <div class="result-area"></div>
  51. </div>
  52. `;
  53.  
  54. document.body.appendChild(this.toggleBtn);
  55. document.body.appendChild(this.panel);
  56. }
  57.  
  58. bindEvents() {
  59. this.toggleBtn.addEventListener('click', () => this.togglePanel());
  60. this.panel.querySelector('.close-btn').addEventListener('click', () => this.togglePanel());
  61. this.panel.querySelector('.start-btn').addEventListener('click', () => this.startAnalysis());
  62. }
  63.  
  64. togglePanel() {
  65. this.isOpen = !this.isOpen;
  66. this.panel.style.transform = `translateY(-50%) translateX(${this.isOpen ? '0' : '100%'})`;
  67. this.toggleBtn.style.opacity = this.isOpen ? '0' : '1';
  68. this.toggleBtn.style.pointerEvents = this.isOpen ? 'none' : '';
  69.  
  70. // 新增自动填充逻辑
  71. if (this.isOpen) {
  72. this.autoFillInput();
  73. }
  74. }
  75.  
  76. autoFillInput() {
  77. const currentUrl = window.location.href;
  78. const input = this.panel.querySelector('#bInput');
  79. const mdMatch = currentUrl.match(/(\/md|md)(\d+)/i);
  80. const epMatch = currentUrl.match(/(\/ep|ep)(\d+)/i);
  81. const ssMatch = currentUrl.match(/(\/ss|ss)(\d+)/i);
  82. // 清空原有内容
  83. input.value = '正在获取md号...';
  84. if (mdMatch) {
  85. this.currentMd = mdMatch[2];
  86. input.value = `md${this.currentMd}`;
  87. this.loadSavedData();
  88. } else if (epMatch) {
  89. this.epToMd(`https://www.bilibili.com/bangumi/play/ep${epMatch[2]}`)
  90. .then(md => {
  91. this.currentMd = md.replace('md', '');
  92. input.value = md;
  93. this.loadSavedData();
  94. });
  95. } else if (ssMatch) {
  96. this.epToMd(`https://www.bilibili.com/bangumi/play/ss${ssMatch[2]}`)
  97. .then(md => {
  98. this.currentMd = md.replace('md', '');
  99. input.value = md;
  100. this.loadSavedData();
  101. });
  102. }
  103. }
  104.  
  105. async loadSavedData() {
  106. if (!this.currentMd) return;
  107. const saved = GM_getValue(`md${this.currentMd}`);
  108. if (saved) {
  109. saved.isCached = true;
  110. this.showResults(saved);
  111. console.log('找到cache');
  112. }
  113. }
  114.  
  115. async epToMd(url) {
  116. try {
  117. const res = await fetch(url);
  118. const text = await res.text();
  119. const mdMatch = text.match(/www\.bilibili\.com\/bangumi\/media\/md(\d+)/);
  120. return mdMatch ? `md${mdMatch[1]}` : '';
  121. } catch (e) {
  122. console.log("EP转MD失败:", e);
  123. return '';
  124. }
  125. }
  126.  
  127. startAnalysis() {
  128. if (this.currentMd) {
  129. GM_deleteValue(`md${this.currentMd}`);
  130. }
  131.  
  132. this.panel.querySelector('#start-btn').innerHTML = '正在统计';
  133. this.panel.querySelector('#start-btn').style = 'pointer-events: none; background: gray;';
  134. const input = this.panel.querySelector('#bInput').value.trim();
  135. if (!input) {
  136. this.showMessage('输入不能为空!', 'error');
  137. return;
  138. }
  139.  
  140. this.clearResults();
  141. new BScoreAnalyzer(this).analyze(input);
  142. }
  143.  
  144. showMessage(message, type = 'info') {
  145. const progressArea = this.panel.querySelector('.progress-area');
  146. const msg = document.createElement('div');
  147. msg.className = `message ${type}`;
  148. msg.textContent = message;
  149. progressArea.appendChild(msg);
  150. }
  151.  
  152. updateProgress(type, progress, current, total) {
  153. const progressArea = this.panel.querySelector('.progress-area');
  154. const existing = progressArea.querySelector(`.${type}-progress`);
  155. if (existing) {
  156. existing.innerHTML = `${type}进度:${progress}% (${current}/${total})`;
  157. } else {
  158. const p = document.createElement('div');
  159. p.className = `progress-item ${type}-progress`;
  160. p.innerHTML = `${type}进度:${progress}% (${current}/${total})`;
  161. progressArea.appendChild(p);
  162. }
  163. }
  164.  
  165. showResults(data) {
  166. const resultArea = this.panel.querySelector('.result-area');
  167. resultArea.innerHTML = `
  168. <div class="result-section">
  169. <h4>${data.title}<br><small>${data.isCached ? `${new Date(data.timestamp).toLocaleString('sv-SE')}<span style="color:#00a1d6">(缓存数据)</span>` : new Date().toLocaleString('sv-SE')}</small></h4>
  170. <div class="result-grid">
  171. <div class="result-item">
  172. <span class="label">官方评分:</span>
  173. <span class="value">${data.offical_score}</span>
  174. </div>
  175. <div class="result-item">
  176. <span class="label">统计评分:</span>
  177. <span class="value">${data.total_avg}(${data.total_probability}%)</span>
  178. </div>
  179. <div class="result-item">
  180. <span class="label">标称评论数:</span>
  181. <span class="value">${data.offical_count}</span>
  182. </div>
  183. <div class="result-item">
  184. <span class="label">总样本数:</span>
  185. <span class="value">${data.total_samples}</span>
  186. </div>
  187. </div>
  188. <div class="details">
  189. <div class="detail-section short">
  190. <h5>短评统计</h5>
  191. <p>平均分:${data.short_avg}(${data.short_probability}%)</p>
  192. <p>样本数:${data.short_samples}</p>
  193. </div>
  194. <div class="detail-section long">
  195. <h5>长评统计</h5>
  196. <p>平均分:${data.long_avg}(${data.long_probability}%)</p>
  197. <p>样本数:${data.long_samples}</p>
  198. </div>
  199. </div>
  200. <div class="score-distribution">
  201. <h5>分数分布统计</h5>
  202. <div class="chart-container">
  203. ${[2,4,6,8,10].map(score => `
  204. <div class="bar-item">
  205. <div class="bar" style="height: ${50 * data.scoreDistribution[score] / Math.max(...Object.values(data.scoreDistribution)) || 0}px"></div>
  206. <span>${score}分<br>${data.scoreDistributionNum[score] || 0}<br>(${data.scoreDistribution[score] || 0}%)</span>
  207. </div>
  208. `).join('')}
  209. </div>
  210. </div>
  211. </div>
  212. `;
  213. }
  214.  
  215. clearResults() {
  216. this.panel.querySelector('.progress-area').innerHTML = '';
  217. this.panel.querySelector('.result-area').innerHTML = '';
  218. }
  219. }
  220.  
  221. class BScoreAnalyzer {
  222. constructor(ui) {
  223. this.ui = ui;
  224. this.shortScores = [];
  225. this.longScores = [];
  226. this.totalCount = { short: 0, long: 0 };
  227. this.metadata = {};
  228. this.retryLimit = 5;
  229. this.banWaitTime = 60000;
  230. this.mdId = null;
  231. }
  232.  
  233. async analyze(input) {
  234. try {
  235. const mdId = await this.processInput(input);
  236. this.mdId = mdId;
  237. if (!mdId) return;
  238.  
  239. await this.fetchBaseInfo(mdId);
  240. await this.fetchReviews('short', mdId);
  241. await this.fetchReviews('long', mdId);
  242. this.showFinalResults();
  243. } catch (e) {
  244. this.ui.showMessage(`错误: ${e.message}`, 'error');
  245. }
  246. }
  247.  
  248. async processInput(input) {
  249. let mdId = input.replace(/#| |番剧评分| /g, "");
  250. if (mdId.match(/^(https?:)/)) {
  251. const epId = mdId.match(/ep(\d+)/)?.[1];
  252. if (epId) return this.ep2md(epId);
  253. return mdId.match(/md(\d+)/)?.[1];
  254. }
  255. return mdId.replace(/^md/, '');
  256. }
  257.  
  258. async ep2md(epId) {
  259. const url = `https://www.bilibili.com/bangumi/play/ep${epId}`;
  260. const res = await fetch(url);
  261. const text = await res.text();
  262. const mdMatch = text.match(/www\.bilibili\.com\/bangumi\/media\/md(\d+)/);
  263. return mdMatch[1];
  264. }
  265.  
  266. async fetchBaseInfo(mdId) {
  267. const res = await fetch(`https://api.bilibili.com/pgc/review/user?media_id=${mdId}`);
  268. const data = await res.json();
  269. this.metadata = {
  270. title: data.result.media.title,
  271. official_score: data.result.media.rating?.score || "暂无",
  272. official_count: data.result.media.rating?.count || "NaN"
  273. };
  274. }
  275.  
  276. async fetchReviews(type, mdId) {
  277. let cursor;
  278. let collected = 0;
  279. do {
  280. const result = await this.getReviewPage(type, mdId, cursor);
  281. if (!result?.data?.list) break;
  282.  
  283. const scores = result.data.list.map(item => item.score);
  284. type === 'short' ? this.shortScores.push(...scores) : this.longScores.push(...scores);
  285. collected += result.data.list.length;
  286. this.totalCount[type] = result.data.total || this.totalCount[type];
  287. const progress = ((collected / this.totalCount[type]) * 100).toFixed(1);
  288. this.ui.updateProgress(type, progress, collected, this.totalCount[type]);
  289.  
  290. if (cursor && cursor == result.data.next) break;
  291. cursor = result.data.next;
  292. await this.delay(200);
  293. } while (cursor && cursor !== "0");
  294. }
  295.  
  296. async getReviewPage(type, mdId, cursor) {
  297. let retry = 0;
  298. while (retry < this.retryLimit) {
  299. try {
  300. const url = new URL(`https://api.bilibili.com/pgc/review/${type}/list`);
  301. url.searchParams.set('media_id', mdId);
  302. if (cursor) url.searchParams.set('cursor', cursor);
  303.  
  304. const res = await fetch(url, {
  305. headers: {
  306. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
  307. "Referer": "https://www.bilibili.com"
  308. },
  309. credentials: 'include'
  310. });
  311.  
  312. const data = await res.json();
  313. if (data.code !== 0) {
  314. document.querySelector('#start-btn').innerHTML = `等待第${retry + 1}次重试`;
  315. console.log(`等待第${retry + 1}次重试`);
  316. await this.delay(this.banWaitTime);
  317. retry++;
  318. document.querySelector('#start-btn').innerHTML = `正在统计`;
  319. continue;
  320. }
  321. return data;
  322. } catch (e) {
  323. document.querySelector('#start-btn').innerHTML = `等待第${retry + 1}次重试`;
  324. console.log(`等待第${retry + 1}次重试`);
  325. await this.delay(1000);
  326. retry++;
  327. document.querySelector('#start-btn').innerHTML = `正在统计`;
  328. }
  329. }
  330. throw new Error('请求失败,请稍后重试');
  331. }
  332.  
  333. showFinalResults() {
  334. const totalScores = [...this.shortScores, ...this.longScores];
  335. const calcAvg = scores => scores.length
  336. ? (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)
  337. : '暂无';
  338.  
  339. function calculateProbability(totalScores, officialCount) {
  340. const n = totalScores.length;
  341. console.log(n,officialCount,totalScores);
  342. if (n === 0 || officialCount === 0) return 0; // 处理无效输入
  343. if (n >= officialCount) return 1; // 记录数等于甚至大于标称,无误差,概率为1
  344.  
  345. // 计算样本均值和样本标准差
  346. const sum = totalScores.reduce((acc, score) => acc + score, 0);
  347. const sampleMean = sum / n;
  348. const sumSquaredDiffs = totalScores.reduce((acc, score) => acc + Math.pow(score - sampleMean, 2), 0);
  349. const sampleVariance = sumSquaredDiffs / (n - 1);
  350. const s = Math.sqrt(sampleVariance);
  351.  
  352. // 计算标准误,考虑有限总体校正
  353. let standardError;
  354. const populationSize = officialCount;
  355. if (populationSize <= 1) {
  356. standardError = 0;
  357. } else {
  358. const finitePopulationCorrection = Math.sqrt((populationSize - n) / (populationSize - 1));
  359. standardError = (s / Math.sqrt(n)) * finitePopulationCorrection;
  360. }
  361.  
  362. if (standardError === 0) return 1; // 无误差,概率为1
  363.  
  364. const Z = 0.1 / standardError;
  365. console.log(2 * standardNormalCDF(Z) - 1);
  366. return 2 * standardNormalCDF(Z) - 1;
  367. }
  368.  
  369. // 标准正态分布CDF近似计算
  370. function standardNormalCDF(x) {
  371. const sign = x < 0 ? -1 : 1;
  372. x = Math.abs(x) / Math.sqrt(2);
  373. const t = 1.0 / (1.0 + 0.3275911 * x);
  374. const y = t * (0.254829592 + t*(-0.284496736 + t*(1.421413741 + t*(-1.453152027 + t*1.061405429))));
  375. const erf = 1 - y * Math.exp(-x * x);
  376. return 0.5 * (1 + sign * erf);
  377. }
  378.  
  379. // 统计分数分布
  380. const scoreDistribution = {};
  381. [2,4,6,8,10].forEach(score => {
  382. scoreDistribution[score] = (totalScores.filter(s => s === score).length / totalScores.length * 100 || 0).toFixed(1);
  383. });
  384.  
  385. // 统计分数分布
  386. const scoreDistributionNum = {};
  387. [2,4,6,8,10].forEach(score => {
  388. scoreDistributionNum[score] = (totalScores.filter(s => s === score).length || 0);
  389. });
  390.  
  391. const resultData = {
  392. title: this.metadata.title,
  393. offical_score: this.metadata.official_score,
  394. total_avg: calcAvg(totalScores),
  395. offical_count: this.metadata.official_count,
  396. total_samples: totalScores.length,
  397. total_probability: (100*calculateProbability(totalScores, this.metadata.official_count)).toFixed(2),
  398. short_avg: calcAvg(this.shortScores),
  399. short_samples: this.shortScores.length,
  400. short_probability: (100*calculateProbability(this.shortScores, this.totalCount.short)).toFixed(2),
  401. long_avg: calcAvg(this.longScores),
  402. long_samples: this.longScores.length,
  403. long_probability: (100*calculateProbability(this.longScores, this.totalCount.long)).toFixed(2),
  404. scoreDistribution: scoreDistribution,
  405. scoreDistributionNum: scoreDistributionNum,
  406. timestamp: new Date().toISOString()
  407. };
  408. GM_setValue(`md${this.mdId}`, resultData);
  409. console.log('cache已保存');
  410. this.ui.showResults(resultData);
  411.  
  412. document.querySelector('#start-btn').innerHTML = '开始统计';
  413. document.querySelector('#start-btn').style = '';
  414. }
  415.  
  416. delay(ms) {
  417. return new Promise(r => setTimeout(r, ms));
  418. }
  419. }
  420.  
  421. // 添加样式
  422. GM_addStyle(`
  423. .control-btn {
  424. position: fixed;
  425. right: 20px;
  426. top: 50%;
  427. transform: translateY(-50%);
  428. background: #00a1d6;
  429. color: white;
  430. padding: 12px 20px;
  431. border-radius: 25px 0 0 25px;
  432. cursor: pointer;
  433. transition: all 0.3s;
  434. z-index: 9999;
  435. box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  436. }
  437.  
  438. .control-panel {
  439. position: fixed;
  440. right: 0;
  441. top: 50%;
  442. transform: translateY(-50%) translateX(100%);
  443. width: 350px;
  444. background: white;
  445. border-radius: 10px 0 0 10px;
  446. box-shadow: -2px 0 10px rgba(0,0,0,0.1);
  447. transition: transform 0.3s;
  448. z-index: 9998;
  449. padding: 20px;
  450. max-height: 90vh;
  451. overflow-y: auto;
  452. }
  453.  
  454. .panel-header {
  455. display: flex;
  456. justify-content: space-between;
  457. align-items: center;
  458. margin-bottom: 15px;
  459. }
  460.  
  461. .close-btn {
  462. background: none;
  463. border: none;
  464. font-size: 24px;
  465. cursor: pointer;
  466. color: #666;
  467. }
  468.  
  469. .input-group {
  470. margin-bottom: 15px;
  471. }
  472.  
  473. .b-input {
  474. width: 100%;
  475. padding: 8px;
  476. margin-top: 5px;
  477. border: 1px solid #ddd;
  478. border-radius: 4px;
  479. }
  480.  
  481. .start-btn {
  482. width: 100%;
  483. padding: 10px;
  484. background: #00a1d6;
  485. color: white;
  486. border: none;
  487. border-radius: 4px;
  488. cursor: pointer;
  489. transition: background 0.3s;
  490. }
  491.  
  492. .start-btn:hover {
  493. background: #0087b3;
  494. }
  495.  
  496. .progress-area {
  497. margin: 15px 0;
  498. padding: 10px;
  499. background: #f5f5f5;
  500. border-radius: 4px;
  501. }
  502.  
  503. .result-area {
  504. margin-top: 15px;
  505. }
  506.  
  507. .result-section {
  508. background: #f9f9f9;
  509. padding: 15px;
  510. border-radius: 4px;
  511. }
  512.  
  513. .result-grid {
  514. display: grid;
  515. grid-template-columns: repeat(2, 1fr);
  516. gap: 10px;
  517. margin: 10px 0;
  518. }
  519.  
  520. .result-item {
  521. background: white;
  522. padding: 10px;
  523. border-radius: 4px;
  524. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  525. }
  526.  
  527. .details {
  528. display: grid;
  529. grid-template-columns: 1fr 1fr;
  530. gap: 10px;
  531. }
  532.  
  533. .detail-section {
  534. padding: 10px;
  535. background: white;
  536. border-radius: 4px;
  537. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  538. }
  539.  
  540. span,p {
  541. font-size: smaller;
  542. }
  543.  
  544. .score-distribution {
  545. margin-top: 15px;
  546. padding: 15px;
  547. background: #f9f9f9;
  548. border-radius: 4px;
  549. }
  550.  
  551. .chart-container {
  552. display: flex;
  553. justify-content: space-around;
  554. align-items: flex-end;
  555. height: 100px;
  556. margin-top: 10px;
  557. }
  558.  
  559. .bar-item {
  560. width: 18%;
  561. text-align: center;
  562. }
  563.  
  564. .bar {
  565. background: #00a1d6;
  566. transition: height 0.5s;
  567. border-radius: 3px 3px 0 0;
  568. position: relative;
  569. }
  570.  
  571. .bar:hover {
  572. background: #0087b3;
  573. cursor: pointer;
  574. }
  575.  
  576. .bar-item span {
  577. display: block;
  578. margin-top: 5px;
  579. font-size: 12px;
  580. color: #666;
  581. }
  582. `);
  583.  
  584. // 初始化控制台
  585. new ControlPanel();
  586. })();
  587.  

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址