// ==UserScript== // @namespace https://github.com/umajho // @name bangumi-episode-ratings-gadget // @version 0.1.4 // @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 // @downloadURL none // ==/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.4"; 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", MY_EPISODE_RATING: "my-episode-rating" } } }; 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 getMyEpisodeRating() { 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)); } return await this.fetch( "api/v0", ENDPOINT_PATHS.API.V0.MY_EPISODE_RATING, { method: "GET", searchParams } ); } 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) { const oldValue = this._value; this._value = newValue; this._watchers.forEach((w) => w(newValue, oldValue)); } watchDeferred(cb) { this._watchers.push(cb); return () => { this._watchers = this._watchers.filter((w) => w !== cb); }; } watch(cb) { cb(this._value, void 0); 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*/ `
\u5355\u96C6\u8BC4\u5206
` ).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*/ `
votes
` ).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 = $("
"); 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*/ `
  • ` ).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 ratedScore = new Watched(props.ratedScore); const hoveredScore = new Watched(null); el = $( /*html*/ `

    \u6211\u7684\u8BC4\u4EF7:

    ` ).replaceAll(el); const starsContainerEl = el.find(".stars-container"); for (const score of scores) { const starEl = $( /*html*/ `
    ` ); 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)); ratedScore.watchDeferred( (ratedScore2) => updateStarsContainer(["normal", { ratedScore: ratedScore2, hoveredScore: hoveredScore.getValueOnce() }]) ); hoveredScore.watch( (hoveredScore2) => updateStarsContainer(["normal", { ratedScore: ratedScore.getValueOnce(), hoveredScore: hoveredScore2 }]) ); function updateStarsContainer(params) { if (params[0] === "invisible") { starsContainerEl.css("display", "none"); return; } starsContainerEl.css("display", ""); const [_, { ratedScore: ratedScore2, hoveredScore: hoveredScore2 }] = params; const isHovering = hoveredScore2 !== null; const maxScoreToHighlight = hoveredScore2 ?? ratedScore2 ?? null; { let alarmScore = maxScoreToHighlight; if (alarmScore === "cancel") { alarmScore = ratedScore2; } 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"); } } 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 "loading": { messageEl.text("\u52A0\u8F7D\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
    \u8BF7\u5148\u6388\u6743\u6B64\u5E94\u7528\u3002
    \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; } case "requiring_fetch": { messageEl.html( /*html*/ ` \u70B9\u51FB\u6216\u5237\u65B0\u672C\u9875\u4EE5\u83B7\u53D6\u3002 ` ); $(messageEl).find("button").on("click", async () => { updateMessage(["loading"]); const resp = await Global.client.getMyEpisodeRating(); if (resp[0] === "auth_required") { Global.token.setValue(null); } else if (resp[0] === "error") { const [_, _name, message] = resp; updateMessage(["error", message]); } else if (resp[0] === "ok") { const [_, data] = resp; updateMessage(["none"]); ratedScore.setValue(data.score); } else ; }); break; } } } updateMessage(["none"]); Global.token.watch((newToken, oldToken) => { if (newToken) { if (oldToken !== void 0) { updateMessage(["requiring_fetch"]); updateStarsContainer(["invisible"]); } else { updateMessage(["none"]); } } else { updateMessage(["auth_link"]); updateStarsContainer(["invisible"]); } }); async function rateEpisode(scoreToRate) { if (!Global.token.getValueOnce()) return; updateMessage(["processing"]); const resp = await Global.client.rateEpisode({ userID: Global.claimedUserID, subjectID: Global.subjectID, episodeID: Global.episodeID, score: scoreToRate }); 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"]); ratedScore.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; } $('').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*/ `
    \u5355\u96C6\u8BC4\u5206\u7EC4\u4EF6\u52A0\u8F7D\u4E2D\u2026
    ` ); $("#columnEpA").prepend(scoreboardEl); const ratingsData = await Global.client.mustGetEpisodeRatings(); const votesData = new VotesData( ratingsData.votes ); renderScoreboard(scoreboardEl, { score: votesData.averageScore }); const scoreChartEl = $("
    ").insertBefore("#columnEpA > .epDesc"); renderScoreChart(scoreChartEl, { votesData }); $( /*html*/ `
    ` ).insertAfter("#columnEpA > .epDesc"); const myRatingEl = $("
    ").insertAfter(".singleCommentList > .board"); if (!ratingsData.my_rating) { Global.token.setValue(null); } renderMyRating(myRatingEl, { ratedScore: ratingsData.my_rating?.score ?? null }); } } migrate(); initializeGlobal(); main(); })();