您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
Bangumi 单集评分的超合金组件
当前为
// ==UserScript== // @namespace https://github.com/umajho // @name bangumi-episode-ratings-gadget // @version 0.1.3 // @description Bangumi 单集评分的超合金组件 // @license MIT // @website https://github.com/umajho/bangumi-episode-ratings // @match https://bangumi.tv/* // @match https://bgm.tv/* // @match https://chii.in/* // @grant GM_info // @grant unsafeWindow // @grant window.close // ==/UserScript== (function() { "use strict"; const env = { get APP_ENTRYPOINT() { return "https://bgm-ep-ratings.deno.dev/"; }, LOCAL_STORAGE_KEY_TOKEN: "bgm_ep_ratings_token", SEARCH_PARAMS_KEY_TOKEN_COUPON: "bgm_ep_ratings_token_coupon" }; const version = "0.1.3"; const ENDPOINT_PATHS = { AUTH: { BANGUMI_PAGE: "bangumi-page", CALLBACK: "callback", REDEEM_TOKEN_COUPON: "redeem-token-coupon" }, API: { V0: { RATE_EPISODE: "rate-episode", EPISODE_RATINGS: "episode-ratings" } } }; class Client { constructor(opts) { this.entrypoint = opts.entrypoint; this.token = opts.token; } get URL_AUTH_BANGUMI_PAGE() { const url = ( // new URL(this.buildFullEndpoint("auth", ENDPOINT_PATHS.AUTH.BANGUMI_PAGE)) ); url.searchParams.set("gadget_version", Global.version); return url.toString(); } async rateEpisode(opts) { if (!this.token) return ["auth_required"]; const bodyData = { claimed_user_id: opts.userID, subject_id: opts.subjectID, episode_id: opts.episodeID, score: opts.score }; return await this.fetch( "api/v0", ENDPOINT_PATHS.API.V0.RATE_EPISODE, { method: "POST", body: JSON.stringify(bodyData) } ); } async mustGetEpisodeRatings() { const searchParams = new URLSearchParams(); if (Global.claimedUserID) { searchParams.set("claimed_user_id", String(Global.claimedUserID)); searchParams.set("subject_id", String(Global.subjectID)); searchParams.set("episode_id", String(Global.episodeID)); } const data = await this.fetch( "api/v0", ENDPOINT_PATHS.API.V0.EPISODE_RATINGS, { method: "GET", searchParams } ); return unwrap(data); } async fetch(group, endpointPath, opts) { const url = new URL(this.buildFullEndpoint(group, endpointPath)); if (opts.searchParams) { url.search = opts.searchParams.toString(); } const headers = new Headers(); if (this.token) { headers.set("Authorization", `Basic ${this.token}`); } headers.set("X-Gadget-Version", Global.version); try { const resp = await fetch(url, { method: opts.method, headers, body: opts.body }); const respJSON = await resp.json(); if (respJSON[0] === "error" && respJSON[1] === "AUTH_REQUIRED") { if (Global.token.getValueOnce() !== null) { Global.token.setValue(null); } return ["auth_required"]; } return respJSON; } catch (e) { const operation = `fetch \`${opts.method} ${url}\``; console.error(`${operation} \u5931\u8D25`, e); return ["error", "UNKNOWN", `${operation} \u5931\u8D25\uFF1A ${e}`]; } } buildFullEndpoint(group, endpointPath) { return join(join(this.entrypoint, group + "/"), endpointPath); } async mustRedeemTokenCoupon(tokenCoupon) { const data = await this.fetch( "auth", ENDPOINT_PATHS.AUTH.REDEEM_TOKEN_COUPON, { method: "POST", body: JSON.stringify({ tokenCoupon }) } ); return unwrap(data); } } function join(base, url) { return new URL(url, base).href; } function unwrap(resp) { if (!Array.isArray(resp) || resp[0] !== "ok" && resp[0] !== "error") { console.error("Unsupported response", resp); throw new Error(`Unsupported response: ${JSON.stringify(resp)}`); } if (resp[0] === "error") throw new Error(resp[2]); return resp[1]; } class Watched { constructor(_value) { this._value = _value; this._watchers = []; } getValueOnce() { return this._value; } setValue(newValue) { this._value = newValue; this._watchers.forEach((w) => w(newValue)); } watchDeferred(cb) { this._watchers.push(cb); return () => { this._watchers = this._watchers.filter((w) => w !== cb); }; } watch(cb) { cb(this._value); return this.watchDeferred(cb); } } const global = {}; const Global = global; function initializeGlobal() { Object.assign(global, makeGlobal()); } function makeGlobal() { const { subjectID, episodeID } = (() => { let subjectID2 = null; let episodeID2 = null; if (location.pathname.startsWith("/subject/")) { subjectID2 = Number(location.pathname.split("/")[2]); } else if (location.pathname.startsWith("/ep/")) { episodeID2 = Number(location.pathname.split("/")[2]); const subjectHref = $("#headerSubject > .nameSingle > a").attr("href"); subjectID2 = Number(subjectHref.split("/")[2]); } return { subjectID: subjectID2, episodeID: episodeID2 }; })(); const claimedUserID = (() => { if ("unsafeWindow" in window) { return window.unsafeWindow.CHOBITS_UID || null; } return window.CHOBITS_UID || null; })(); if (claimedUserID === null) { localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN); } const token = new Watched( localStorage.getItem(env.LOCAL_STORAGE_KEY_TOKEN) ); window.addEventListener("storage", (ev) => { if (ev.key !== env.LOCAL_STORAGE_KEY_TOKEN) return; if (ev.newValue === token.getValueOnce()) return; token.setValue(ev.newValue); }); const client = new Client({ entrypoint: env.APP_ENTRYPOINT, token: token.getValueOnce() }); token.watchDeferred((newToken) => { if (newToken) { localStorage.setItem(env.LOCAL_STORAGE_KEY_TOKEN, newToken); } else { localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN); } client.token = newToken; }); return { version, subjectID, episodeID, claimedUserID, token, client }; } const scores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; function describeScore(score) { return [ [9.5, "\u8D85\u795E\u4F5C"], [8.5, "\u795E\u4F5C"], [7.5, "\u529B\u8350"], [6.5, "\u63A8\u8350"], [5.5, "\u8FD8\u884C"], [4.5, "\u4E0D\u8FC7\u4E0D\u5931"], [3.5, "\u8F83\u5DEE"], [2.5, "\u5DEE"], [1.5, "\u5F88\u5DEE"] ].find(([min, _]) => score >= min)?.[1] ?? "\u4E0D\u5FCD\u76F4\u89C6"; } function renderScoreboard(el, props) { el = $( /*html*/ ` <div class="global_score" style="float: right;"> <span class="description"></span> <span class="number"></span> <div> <small class="grey" style="float: right;">\u5355\u96C6\u8BC4\u5206</small> </div> </div> ` ).replaceAll(el); function updateNumber(score) { if (Number.isNaN(score)) { $(el).find(".number").text(0 .toFixed(1)); $(el).find(".description").text("--"); } else { $(el).find(".number").text(score.toFixed(4)); $(el).find(".description").text(describeScore(score)); } } updateNumber(props.score); return el; } class VotesData { constructor(data) { this.data = data; this.totalVotesCache = null; this.averageScoreCache = null; this.mostVotedScoreCache = null; } getScoreVotes(score) { return this.data[score] ?? 0; } get totalVotes() { if (this.totalVotesCache) return this.totalVotesCache; let totalVotes = 0; for (const score of scores) { totalVotes += this.getScoreVotes(score); } return this.totalVotesCache = totalVotes; } get averageScore() { if (this.averageScoreCache) return this.averageScoreCache; let totalScore = 0; for (const score of scores) { totalScore += this.getScoreVotes(score) * score; } return this.averageScoreCache = totalScore / this.totalVotes; } get mostVotedScore() { if (this.mostVotedScoreCache) return this.mostVotedScoreCache; let mostVotedScore = scores[0]; for (const score of scores.slice(1)) { if (this.getScoreVotes(score) > this.getScoreVotes(mostVotedScore)) { mostVotedScore = score; } } return this.mostVotedScoreCache = mostVotedScore; } get votesOfMostVotedScore() { return this.getScoreVotes(this.mostVotedScore); } } function renderScoreChart(el, props) { const { votesData } = props; el = $( /*html*/ ` <div id="ChartWarpper" class="chartWrapper" style="float: right; width: 218px;"> <div class="chart_desc"><small class="grey"><span class="votes"></span> votes</small></div> <ul class="horizontalChart"> <div class="tooltip fade top in" role="tooltip" style="top: -34px; transform: translateX(-50%);"> <div class="tooltip-arrow" style="left: 50%;"></div> <div class="tooltip-inner"></div> </div> </ul> </div> ` ).replaceAll(el); $(el).find(".votes").text(votesData.totalVotes); const chartEl = el.find(".horizontalChart"); const totalVotes = votesData.totalVotes; const votesOfMostVotedScore = votesData.votesOfMostVotedScore; for (const score of scores) { const votes = votesData.getScoreVotes(score); const barEl = $("<div />"); chartEl.prepend(barEl); renderBar(barEl, { score, votes, totalVotes, votesOfMostVotedScore, updateTooltip }); } function updateTooltip(props2) { let tooltipEl = $(chartEl).find(".tooltip"); if (props2.score === null) { tooltipEl.css("display", "none"); return; } tooltipEl.css("display", "block"); const barEl = $(chartEl).find(`li`).eq(10 - props2.score); const barElRelativeOffsetLeft = barEl.offset().left - el.offset().left; tooltipEl.css("left", `${barElRelativeOffsetLeft + barEl.width() / 2}px`); let scoreVotes = votesData.getScoreVotes(props2.score); const percentage = votesData.totalVotes ? scoreVotes / votesData.totalVotes * 100 : 0; $(tooltipEl).find(".tooltip-inner").text( `${percentage.toFixed(2)}% (${scoreVotes}\u4EBA)` ); } updateTooltip({ score: null }); return el; } function renderBar(el, props) { el = $( /*html*/ ` <li><a class="textTip"><span class="label"></span><span class="count"></span></a></li> ` ).replaceAll(el); const percentage = (props.votes / props.totalVotes * 100).toFixed(2); $(el).find(".textTip").attr( "data-original-title", `${percentage}% (${props.votes}\u4EBA)` ); $(el).find(".label").text(props.score); const height = (props.votes / props.votesOfMostVotedScore * 100).toFixed(2); $(el).find(".count").css("height", `${height}%`); $(el).find(".count").text(`(${props.votes})`); $(el).on("mouseover", () => props.updateTooltip({ score: props.score })).on("mouseout", () => props.updateTooltip({ score: null })); return el; } function renderMyRating(el, props) { const hoveredScore = new Watched(null); el = $( /*html*/ ` <div style="float: right; display: flex; flex-direction: column;"> <p>\u6211\u7684\u8BC4\u4EF7: <span class="alarm"></span> </p> <div class="stars-container"> <div class="rating-cancel"><a title="Cancel Rating"></a></div> </div> <div class="message"></div> </div> ` ).replaceAll(el); const starsContainerEl = el.find(".stars-container"); for (const score of scores) { const starEl = $( /*html*/ ` <div class="star-rating"> <a></a> </div> ` ); starsContainerEl.append(starEl); const aEl = starEl.find("a"); aEl.text(score); aEl.attr("title", describeScoreEx(score)); starEl.on("mouseover", () => hoveredScore.setValue(score)).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(score)); } $(".rating-cancel").on("mouseover", () => hoveredScore.setValue("cancel")).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(null)); props.score.watchDeferred( (score) => updateStarsContainer(score, hoveredScore.getValueOnce()) ); hoveredScore.watch( (hoveredScore2) => updateStarsContainer(props.score.getValueOnce(), hoveredScore2) ); function updateStarsContainer(ratedScore, hoveredScore2) { const isHovering = hoveredScore2 !== null; const maxScoreToHighlight = hoveredScore2 ?? ratedScore ?? null; { let alarmScore = maxScoreToHighlight; if (alarmScore === "cancel") { alarmScore = ratedScore; } if (alarmScore !== null) { $(starsContainerEl).find(".alarm").text(describeScoreEx(alarmScore)); } else { $(starsContainerEl).find(".alarm").text(""); } } const starEls = starsContainerEl.find(".star-rating"); for (const score of scores) { const starEl = starEls.eq(score - 1); starEl.removeClass("star-rating-on").removeClass("star-rating-hover"); if (typeof maxScoreToHighlight === "number" && score <= maxScoreToHighlight) { starEl.addClass(isHovering ? "star-rating-hover" : "star-rating-on"); } } $(".rating-cancel").removeClass("star-rating-hover"); if (hoveredScore2 === "cancel") { $(".rating-cancel").addClass("star-rating-hover"); } } function updateStarsContainerVisibility(isVisible) { if (isVisible) { starsContainerEl.css("display", ""); } else { starsContainerEl.css("display", "none"); } } const messageEl = el.find(".message"); function updateMessage(value) { messageEl.attr("style", ""); switch (value[0]) { case "none": { messageEl.text(""); messageEl.css("display", "none"); break; } case "processing": { messageEl.text("\u5904\u7406\u4E2D\u2026"); messageEl.css("color", "gray"); break; } case "error": { messageEl.text(value[1]); messageEl.css("color", "red"); break; } case "auth_link": { messageEl.html( /*html*/ ` \u82E5\u8981\u4E3A\u5355\u96C6\u8BC4\u5206\uFF0C\u6216\u67E5\u770B\u81EA\u5DF1\u5148\u524D\u7684\u5355\u96C6\u8BC4\u5206\uFF0C <br > \u8BF7\u5148<a class="l" target="_blank">\u6388\u6743\u6B64\u5E94\u7528</a>\u3002 <br > \u5355\u96C6\u8BC4\u5206\u5E94\u7528\u9700\u8981\u4EE5\u6B64\u6765\u786E\u8BA4\u767B\u5F55\u8005\u3002 ` ); $(messageEl).find("a").attr( "href", Global.client.URL_AUTH_BANGUMI_PAGE ); break; } } } updateMessage(["none"]); Global.token.watch((newToken) => { if (newToken) { updateMessage(["none"]); updateStarsContainerVisibility(true); } else { updateMessage(["auth_link"]); updateStarsContainerVisibility(false); } }); async function rateEpisode(score) { if (!Global.token.getValueOnce()) return; updateMessage(["processing"]); const resp = await Global.client.rateEpisode({ userID: Global.claimedUserID, subjectID: Global.subjectID, episodeID: Global.episodeID, score }); if (resp[0] === "auth_required") { updateMessage(["auth_link"]); } else if (resp[0] === "error") { const [_, _name, message] = resp; updateMessage(["error", message]); } else if (resp[0] === "ok") { const [_, data] = resp; updateMessage(["none"]); props.score.setValue(data.score); } else ; } return el; } function describeScoreEx(score) { let description = `${describeScore(score)} ${score}`; if (score === 1 || score === 10) { description += " (\u8BF7\u8C28\u614E\u8BC4\u4EF7)"; } return description; } function migrate() { const tokenInWrongPlace = localStorage.getItem("bgm_test_app_token"); if (tokenInWrongPlace) { localStorage.setItem(env.LOCAL_STORAGE_KEY_TOKEN, tokenInWrongPlace); localStorage.removeItem("bgm_test_app_token"); } const searchParams = new URLSearchParams(window.location.search); const tokenCouponInWrongPlace = searchParams.get("bgm_test_app_token_coupon"); if (tokenCouponInWrongPlace) { searchParams.set( env.SEARCH_PARAMS_KEY_TOKEN_COUPON, tokenCouponInWrongPlace ); searchParams.delete("bgm_test_app_token_coupon"); let newURL = `${window.location.pathname}?${searchParams.toString()}`; window.history.replaceState(null, "", newURL); } } async function main() { const isInUserScriptRuntime = typeof GM_info !== "undefined"; if ($('meta[name="__bgm_ep_ratings__initialized"]').length) { console.warn( "\u68C0\u6D4B\u5230\u672C\u811A\u672C/\u8D85\u5408\u91D1\u7EC4\u4EF6\uFF08\u5355\u96C6\u8BC4\u5206 by Umajho A.K.A. um\uFF09\u5148\u524D\u5DF2\u7ECF\u521D\u59CB\u5316\u8FC7\uFF0C\u672C\u5B9E\u4F8B\u5C06\u4E0D\u4F1A\u7EE7\u7EED\u8FD0\u884C\u3002", { version: Global.version, isInUserScriptRuntime } ); return; } $('<meta name="__bgm_ep_ratings__initialized" content="true">').appendTo("head"); const searchParams = new URLSearchParams(window.location.search); const tokenCoupon = searchParams.get(env.SEARCH_PARAMS_KEY_TOKEN_COUPON); if (tokenCoupon) { searchParams.delete(env.SEARCH_PARAMS_KEY_TOKEN_COUPON); let newURL = `${window.location.pathname}`; if (searchParams.size) { newURL += `?${searchParams.toString()}`; } window.history.replaceState(null, "", newURL); Global.token.setValue( await Global.client.mustRedeemTokenCoupon(tokenCoupon) ); window.close(); } if (location.pathname.startsWith("/ep/")) { const scoreboardEl = $( /*html*/ ` <div class="grey" style="float: right;"> \u5355\u96C6\u8BC4\u5206\u7EC4\u4EF6\u52A0\u8F7D\u4E2D\u2026 </div> ` ); $("#columnEpA").prepend(scoreboardEl); const ratingsData = await Global.client.mustGetEpisodeRatings(); const votesData = new VotesData( ratingsData.votes ); renderScoreboard(scoreboardEl, { score: votesData.averageScore }); const scoreChartEl = $("<div />").insertBefore("#columnEpA > .epDesc"); renderScoreChart(scoreChartEl, { votesData }); $( /*html*/ `<div class="clear" />` ).insertAfter("#columnEpA > .epDesc"); const myRatingEl = $("<div />").insertAfter(".singleCommentList > .board"); const myScore = new Watched( ratingsData.userScore ?? null ); renderMyRating(myRatingEl, { score: myScore }); } } migrate(); initializeGlobal(); main(); })();