// ==UserScript==
// @namespace https://github.com/umajho
// @name bangumi-episode-ratings-gadget
// @version 0.4.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
// @downloadURL none
// ==/UserScript==
(function() {
"use strict";
//#region src/env.ts
const env = {
get APP_AUTH_ENTRYPOINT() {
return "https://bgm-ep-ratings.deno.dev/auth/";
},
get APP_API_ENTRYPOINT() {
const debugURL = localStorage.getItem(this.LOCAL_STORAGE_KEY_DEBUG_API_ENTRYPOINT_URL);
if (debugURL) return debugURL;
return "https://xn--kbrs5al25jbhj.bgm.zone/api/";
},
LOCAL_STORAGE_KEY_DEBUG_API_ENTRYPOINT_URL: "bgm_ep_ratings_debug_api_entrypoint_url",
LOCAL_STORAGE_KEY_TOKEN: "bgm_ep_ratings_token",
LOCAL_STORAGE_KEY_JWT: "bgm_ep_ratings_jwt",
SEARCH_PARAMS_KEY_TOKEN_COUPON: "bgm_ep_ratings_token_coupon"
};
var env_default = env;
//#endregion
//#region package.json
const version = "0.4.3";
//#endregion
//#region ../app/src/shared/endpoint-paths.ts
var endpoint_paths_default = {
CORS_PREFLIGHT_BYPASS: "cors-preflight-bypass",
AUTH: {
BANGUMI_PAGE: "bangumi-page",
CALLBACK: "callback",
REDEEM_TOKEN_COUPON: "redeem-token-coupon",
REFRESH_JWT: "refresh-jwt"
}
};
//#endregion
//#region src/client.ts
class Client {
authEntrypoint;
apiEntrypoint;
token;
constructor(opts) {
this.authEntrypoint = opts.authEntrypoint;
this.apiEntrypoint = opts.apiEntrypoint;
this.token = opts.token;
}
get URL_AUTH_BANGUMI_PAGE() {
const url = new URL(this.buildFullEndpoint("auth", endpoint_paths_default.AUTH.BANGUMI_PAGE));
url.searchParams.set("gadget_version", global_default.version);
url.searchParams.set("referrer", window.location.origin);
return url.toString();
}
async redeemTokenCoupon(tokenCoupon) {
const resp = await this.fetch("auth", endpoint_paths_default.AUTH.REDEEM_TOKEN_COUPON, {
tokenType: "basic",
method: "POST",
body: JSON.stringify({ tokenCoupon })
});
if (resp[0] === "auth_required") throw new Error("unreachable!");
return resp;
}
async rateEpisode(opts) {
if (!this.token) return ["auth_required"];
if (opts.score !== null) {
const bodyData = { score: opts.score };
return await this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/${opts.episodeID}/ratings/mine`, {
tokenType: "jwt",
method: "PUT",
body: JSON.stringify(bodyData)
});
} else {
return await this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/${opts.episodeID}/ratings/mine`, {
tokenType: "jwt",
method: "DELETE"
});
}
}
subjectEpisodesRatingsCache = {};
hasCachedSubjectEpisodesRatings(subjectID) {
return !!this.subjectEpisodesRatingsCache[subjectID];
}
async getSubjectEpisodesRatings(opts) {
if (this.subjectEpisodesRatingsCache[opts.subjectID]) {
const cached = this.subjectEpisodesRatingsCache[opts.subjectID];
if (cached instanceof Promise) {
return await cached;
} else {
return ["ok", cached];
}
}
return this.subjectEpisodesRatingsCache[opts.subjectID] = this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/ratings`, {
tokenType: "jwt",
method: "GET"
}).then((resp) => {
if (resp[0] === "auth_required") {
throw new Error("unreachable!");
} else if (resp[0] === "error") {
delete this.subjectEpisodesRatingsCache[opts.subjectID];
return resp;
} else if (resp[0] === "ok") {
const [_, data] = resp;
return ["ok", this.subjectEpisodesRatingsCache[opts.subjectID] = data];
} else {
resp;
throw new Error("unreachable!");
}
});
}
async getEpisodeRatings() {
return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings`, {
tokenType: "jwt",
method: "GET"
});
}
async getMyEpisodeRating() {
return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings/mine`, {
tokenType: "jwt",
method: "GET"
});
}
async changeUserEpisodeRatingVisibility(opts) {
return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings/mine/is-visible`, {
tokenType: "jwt",
method: "PUT",
body: JSON.stringify(opts.isVisible)
});
}
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) {
if (opts.tokenType === "basic") {
headers.set("Authorization", `Basic ${this.token}`);
} else {
const resp = await this.fetchJWT();
if (resp[0] !== "ok") return resp;
const [_, jwt] = resp;
headers.set("Authorization", `Bearer ${jwt}`);
}
}
headers.set("X-Gadget-Version", global_default.version);
if (global_default.claimedUserID !== null) {
headers.set("X-Claimed-User-ID", global_default.claimedUserID.toString());
}
try {
const resp = await fetch(this.buildRequest(url, {
method: opts.method,
headers,
body: opts.body
}, { shouldBypassCORSPreflight: group === "api/v1" }));
const respJSON = await resp.json();
if (respJSON[0] === "error" && respJSON[1] === "AUTH_REQUIRED") {
if (global_default.token.getValueOnce() !== null) {
global_default.token.setValue(null);
}
return ["auth_required"];
}
return respJSON;
} catch (e) {
const operation = `fetch \`${opts.method} ${url}\``;
console.error(`${operation} 失败`, e);
return ["error", "UNKNOWN", `${operation} 失败: ${e}`];
}
}
buildRequest(url, init, opts) {
if (opts.shouldBypassCORSPreflight) {
url.pathname = `/${endpoint_paths_default.CORS_PREFLIGHT_BYPASS}/${init.method}${url.pathname}`;
const body = [Object.fromEntries(init.headers.entries()), init.body ?? null,];
return new Request(url, {
method: "POST",
body: JSON.stringify(body)
});
} else {
return new Request(url, init);
}
}
buildFullEndpoint(group, endpointPath) {
const entrypoint = (() => {
switch (group) {
case "auth": return this.authEntrypoint;
case "api/v1": return this.apiEntrypoint + "v1/";
default:
group;
throw new Error("unreachable");
}
})();
return join(entrypoint, endpointPath);
}
async fetchJWT() {
const fn = async () => {
const localToken = localStorage.getItem(env_default.LOCAL_STORAGE_KEY_JWT);
if (localToken && checkJWTExpiry(localToken) === "valid") {
return ["ok", localToken];
}
const resp = await this.fetch("auth", endpoint_paths_default.AUTH.REFRESH_JWT, {
tokenType: "basic",
method: "POST"
});
if (resp[0] === "ok") {
const [_, jwt] = resp;
localStorage.setItem(env_default.LOCAL_STORAGE_KEY_JWT, jwt);
}
return resp;
};
if (window.navigator.locks) {
return window.navigator.locks.request(env_default.LOCAL_STORAGE_KEY_JWT, fn);
} else {
return fn();
}
}
clearCache() {
this.subjectEpisodesRatingsCache = {};
}
}
function join(base, url) {
return new URL(url, base).href;
}
function checkJWTExpiry(jwt) {
const decoded = JSON.parse(atob(jwt.split(".")[1]));
const exp = decoded.exp;
const now = Math.floor(Date.now() / 1000);
return now > exp ? "expired" : "valid";
}
//#endregion
//#region src/utils.ts
class Watched {
_watchers = [];
_shouldDeduplicateShallowly;
_broadcastID;
constructor(_value, opts) {
this._value = _value;
this._shouldDeduplicateShallowly = opts?.shouldDeduplicateShallowly ?? false;
this._broadcastID = opts?.broadcastID ?? null;
if (this._broadcastID) {
window.addEventListener("storage", (ev) => {
if (ev.key === this._broadcastID && ev.newValue) {
const oldValue = this._value;
const newValue = JSON.parse(ev.newValue);
this._value = newValue;
this._watchers.forEach((w) => w(newValue, oldValue));
}
});
}
}
getValueOnce() {
return this._value;
}
setValue(newValue) {
const oldValue = this._value;
if (this._shouldDeduplicateShallowly && oldValue === newValue) {
return;
}
this._value = newValue;
this._watchers.forEach((w) => w(newValue, oldValue));
if (this._broadcastID) {
localStorage.setItem(this._broadcastID, JSON.stringify(newValue));
localStorage.removeItem(this._broadcastID);
}
}
watchDeferred(cb) {
this._watchers.push(cb);
return () => {
this._watchers = this._watchers.filter((w) => w !== cb);
};
}
watch(cb) {
cb(this._value, undefined);
return this.watchDeferred(cb);
}
createComputed(computeFn) {
const computed = new Watched(computeFn(this.getValueOnce()));
this.watchDeferred((newValue) => {
computed.setValue(computeFn(newValue));
});
return computed;
}
}
//#endregion
//#region src/global.ts
const global = {};
var global_default = global;
function initializeGlobal() {
Object.assign(global, makeGlobal());
((window.unsafeWindow ?? window).__bgm_ep_ratings__debug ??= {}).Global = global;
}
function makeGlobal() {
const { subjectID, episodeID } = (() => {
let subjectID$1 = null;
let episodeID$1 = null;
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts[0] === "subject") {
subjectID$1 = Number(pathParts[1]);
} else if (pathParts.length === 2 && pathParts[0] === "ep") {
episodeID$1 = Number(pathParts[1]);
const subjectHref = $("#headerSubject > .nameSingle > a").attr("href");
subjectID$1 = Number(subjectHref.split("/")[2]);
}
return {
subjectID: subjectID$1,
episodeID: episodeID$1
};
})();
const claimedUserID = (() => {
if ("unsafeWindow" in window) {
return window.unsafeWindow.CHOBITS_UID || null;
}
return window.CHOBITS_UID || null;
})();
if (claimedUserID === null) {
localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_TOKEN);
}
const token = new Watched(localStorage.getItem(env_default.LOCAL_STORAGE_KEY_TOKEN));
window.addEventListener("storage", (ev) => {
if (ev.key !== env_default.LOCAL_STORAGE_KEY_TOKEN) return;
if (ev.newValue === token.getValueOnce()) return;
token.setValue(ev.newValue);
});
const client = new Client({
authEntrypoint: env_default.APP_AUTH_ENTRYPOINT,
apiEntrypoint: env_default.APP_API_ENTRYPOINT,
token: token.getValueOnce()
});
token.watchDeferred((newToken) => {
if (newToken) {
localStorage.setItem(env_default.LOCAL_STORAGE_KEY_TOKEN, newToken);
} else {
localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_TOKEN);
localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_JWT);
}
client.token = newToken;
client.clearCache();
});
const currentEpisodeVisibilityFromServer = new Watched(null, { broadcastID: `bgm_ep_ratings::broadcasts::${episodeID}::visibility` });
function updateCurrentEpisodeVisibilityFromServerRaw(raw) {
if (!raw) {
currentEpisodeVisibilityFromServer.setValue(null);
} else {
currentEpisodeVisibilityFromServer.setValue({ isVisible: raw.is_visible });
}
}
return {
version,
subjectID,
episodeID,
claimedUserID,
token,
client,
currentEpisodeVisibilityFromServer,
updateCurrentEpisodeVisibilityFromServerRaw
};
}
//#endregion
//#region src/definitions.ts
const scores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function describeScore(score) {
return [[9.5, "超神作"], [8.5, "神作"], [7.5, "力荐"], [6.5, "推荐"], [5.5, "还行"], [4.5, "不过不失"], [3.5, "较差"], [2.5, "差"], [1.5, "很差"],].find(([min, _]) => score >= min)?.[1] ?? "不忍直视";
}
function describeScoreEx(score) {
let description = `${describeScore(score)} ${score}`;
if (score === 1 || score === 10) {
description += " (请谨慎评价)";
}
return description;
}
//#endregion
//#region src/components/Stars.ts
function renderStars(el, props) {
el = $(`
`).replaceAll(el);
for (const score of scores) {
const starEl = $(`
`);
el.append(starEl);
const aEl = starEl.find("a");
aEl.text(score);
aEl.attr("title", describeScoreEx(score));
starEl.on("mouseover", () => props.hoveredScore.setValue(score)).on("mouseout", () => props.hoveredScore.setValue(null)).on("click", () => props.onRateEpisode(score));
}
function updateStarsContainer(params) {
if (params[0] === "invisible") {
el.css("display", "none");
return;
}
el.css("display", "");
const [_, { ratedScore, hoveredScore }] = params;
const isHovering = hoveredScore !== null;
const maxScoreToHighlight = hoveredScore ?? ratedScore ?? null;
{
let alarmScore = maxScoreToHighlight;
if (alarmScore === "cancel") {
alarmScore = ratedScore;
}
props.onUpdateScoreToAlarm(alarmScore);
}
const starEls = el.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");
}
}
$(el).find(".rating-cancel").removeClass("star-rating-hover");
if (hoveredScore === "cancel") {
$(el).find(".rating-cancel").addClass("star-rating-hover");
}
}
return { updateStarsContainer };
}
//#endregion
//#region src/models/VotesData.ts
class VotesData {
constructor(data) {
this.data = data;
}
getClonedData() {
return { ...this.data };
}
getScoreVotes(score) {
return this.data[score] ?? 0;
}
totalVotesCache = null;
get totalVotes() {
if (this.totalVotesCache) return this.totalVotesCache;
let totalVotes = 0;
for (const score of scores) {
totalVotes += this.getScoreVotes(score);
}
return this.totalVotesCache = totalVotes;
}
averageScoreCache = null;
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;
}
mostVotedScoreCache = null;
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);
}
}
//#endregion
//#region src/components/MyRating.ts
function renderMyRating(el, props) {
const hoveredScore = new Watched(null);
el = $(`
`).replaceAll(el);
const starsContainerEl = el.find(".stars-container");
const { updateStarsContainer } = renderStars(starsContainerEl, {
hoveredScore,
onRateEpisode: rateEpisode,
onUpdateScoreToAlarm: (score) => {
if (score !== null) {
$(el).find(".alarm").text(describeScoreEx(score));
} else {
$(el).find(".alarm").text("");
}
}
});
$(el).find(".rating-cancel").on("mouseover", () => hoveredScore.setValue("cancel")).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(null));
props.ratedScore.watchDeferred((ratedScore) => updateStarsContainer(["normal", {
ratedScore,
hoveredScore: hoveredScore.getValueOnce()
}]));
hoveredScore.watch((hoveredScore$1) => {
updateStarsContainer(["normal", {
ratedScore: props.ratedScore.getValueOnce(),
hoveredScore: hoveredScore$1
}]);
});
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("处理中…");
messageEl.css("color", "grey");
break;
}
case "loading": {
messageEl.text("加载中…");
messageEl.css("color", "grey");
break;
}
case "error": {
messageEl.text(value[1]);
messageEl.css("color", "red");
break;
}
case "auth_link": {
messageEl.html(`
若要查看或提交自己的单集评分,
请先授权此应用。
单集评分应用需要以此来确认登录者。
`);
$(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE);
break;
}
case "requiring_fetch": {
if (props.canRefetchAfterAuth) {
messageEl.html(`
点击或刷新本页以获取。
`);
$(messageEl).find("button").on("click", async () => {
updateMessage(["loading"]);
const resp = await global_default.client.getMyEpisodeRating();
if (resp[0] === "auth_required") {
global_default.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"]);
updateVotesData(props.votesData, {
oldScore: props.ratedScore.getValueOnce(),
newScore: data.score
});
props.ratedScore.setValue(data.score);
global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
} else {
resp;
}
});
} else {
messageEl.text("请刷新本页以获取。");
}
break;
}
default: value;
}
}
updateMessage(["none"]);
global_default.token.watch((newToken, oldToken) => {
if (newToken) {
if (oldToken !== undefined) {
if (props.isPrimary) {
updateMessage(["requiring_fetch"]);
}
updateStarsContainer(["invisible"]);
} else {
updateMessage(["none"]);
}
} else {
if (props.isPrimary) {
updateMessage(["auth_link"]);
} else {
el.css("display", "none");
}
updateStarsContainer(["invisible"]);
}
});
async function rateEpisode(scoreToRate) {
if (!global_default.token.getValueOnce()) return;
updateMessage(["processing"]);
const resp = await global_default.client.rateEpisode({
subjectID: global_default.subjectID,
episodeID: props.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"]);
updateVotesData(props.votesData, {
oldScore: props.ratedScore.getValueOnce(),
newScore: data.score
});
props.ratedScore.setValue(data.score);
global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
} else {
resp;
}
}
}
function updateVotesData(votesData, opts) {
const newVotesData = votesData.getValueOnce().getClonedData();
if (opts.oldScore !== null) {
newVotesData[opts.oldScore]--;
}
if (opts.newScore !== null) {
newVotesData[opts.newScore] ??= 0;
newVotesData[opts.newScore]++;
}
votesData.setValue(new VotesData(newVotesData));
}
//#endregion
//#region src/components/Scoreboard.ts
function renderScoreboard(el, props) {
el = $(`
`).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));
}
}
props.votesData.watch((votesData) => {
updateNumber(votesData.averageScore);
});
}
//#endregion
//#region src/components/ScoreChart.ts
function renderScoreChart(el, props) {
el = $(`
`).replaceAll(el);
const chartEl = el.find(".horizontalChart");
const barEls = scores.map(() => $("").appendTo(chartEl));
props.votesData.watch((votesData) => {
$(el).find(".votes").text(votesData.totalVotes);
const totalVotes = votesData.totalVotes;
const votesOfMostVotedScore = votesData.votesOfMostVotedScore;
for (const score of scores) {
const votes = votesData.getScoreVotes(score);
const barIndex = 10 - score;
const { el: newBarEl } = renderBar(barEls[barIndex], {
score,
votes,
totalVotes,
votesOfMostVotedScore,
updateTooltip
});
barEls[barIndex] = newBarEl;
}
});
function updateTooltip(opts) {
let tooltipEl = $(chartEl).find(".tooltip");
if (opts.score === null) {
tooltipEl.css("display", "none");
return;
}
tooltipEl.css("display", "block");
const barEl = $(chartEl).find(`li`).eq(10 - opts.score);
const barElRelativeOffsetLeft = barEl.offset().left - el.offset().left;
tooltipEl.css("left", `${barElRelativeOffsetLeft + barEl.width() / 2}px`);
const votesData = props.votesData.getValueOnce();
let scoreVotes = votesData.getScoreVotes(opts.score);
const percentage = votesData.totalVotes ? scoreVotes / votesData.totalVotes * 100 : 0;
$(tooltipEl).find(".tooltip-inner").text(`${percentage.toFixed(2)}% (${scoreVotes}人)`);
}
updateTooltip({ score: null });
return el;
}
function renderBar(el, props) {
el = $(`
`).replaceAll(el);
const percentage = (props.votes / props.totalVotes * 100).toFixed(2);
$(el).find(".textTip").attr("data-original-title", `${percentage}% (${props.votes}人)`);
$(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 };
}
//#endregion
//#region src/components/SmallStars.ts
function renderSmallStars(el, props) {
el = $(`
`).replaceAll(el);
const starlightEl = $(el).find("[data-sel=\"starlight\"]");
if (!props.shouldShowNumber) {
$(el).find("small.fade").remove();
}
props.score.watch((score) => {
if (Number.isNaN(score)) {
$(starlightEl).removeClass();
if (props.shouldShowNumber) {
$(el).find("small.fade").text("--");
}
} else {
$(starlightEl).removeClass().addClass("starlight").addClass(`stars${Math.round(score)}`);
if (props.shouldShowNumber) {
$(el).find("small.fade").text(score.toFixed(4));
}
}
});
}
//#endregion
//#region src/components/VisibilityButton.ts
function renderVisibilityButton(el, opts) {
el = $(`
`).replaceAll(el);
const isDisabled = new Watched(false);
const buttonEl = $(el).find("button");
const messageEl = $(el).find("[data-sel='message']");
opts.currentVisibility.watch((currentVisibility) => {
if (currentVisibility === null) {
$(el).css("display", "none");
return;
}
$(el).css("display", "");
if (currentVisibility.isVisible) {
$(buttonEl).text("不再公开");
} else {
$(buttonEl).text("公开");
}
});
isDisabled.watch((isDisabled$1) => {
if (isDisabled$1) {
$(buttonEl).attr("disabled", "disabled");
} else {
$(buttonEl).removeAttr("disabled");
}
});
$(buttonEl).on("click", async () => {
const currentVisibility = opts.currentVisibility.getValueOnce().isVisible;
isDisabled.setValue(true);
updateMessage(["processing"]);
const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: !currentVisibility });
if (result[0] === "auth_required") {
global_default.token.setValue(null);
updateMessage(["auth_link"]);
} else if (result[0] === "error") {
updateMessage(["error", result[1]]);
} else if (result[0] === "ok") {
isDisabled.setValue(false);
updateMessage(["none"]);
global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
} else {
result;
}
});
function updateMessage(value) {
messageEl.attr("style", "");
switch (value[0]) {
case "none": {
messageEl.text("");
messageEl.css("display", "none");
break;
}
case "processing": {
messageEl.text("处理中…");
messageEl.css("color", "grey");
break;
}
case "error": {
messageEl.text(value[1]);
messageEl.css("color", "red");
break;
}
case "auth_link": {
messageEl.html(`
请先授权此应用。
`);
$(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE);
break;
}
case "requiring_reload": {
messageEl.text("请刷新本页以操作。");
break;
}
default: value;
}
}
updateMessage(["none"]);
global_default.token.watch((newToken, oldToken) => {
if (newToken) {
if (oldToken !== undefined) {
isDisabled.setValue(true);
updateMessage(["requiring_reload"]);
} else {
updateMessage(["none"]);
}
} else {
isDisabled.setValue(true);
updateMessage(["auth_link"]);
}
});
}
//#endregion
//#region src/components/ReplyFormVisibilityControl.ts
function renderReplyFormVisibilityControl(el, opts) {
el = $(`
`).replaceAll(el);
const checkBoxEl = $(el).find("input[type=\"checkbox\"]");
const unwatchFn1 = opts.visibilityCheckboxValue.watch((value) => {
$(checkBoxEl).prop("checked", value);
});
$(checkBoxEl).on("change", () => {
opts.visibilityCheckboxValue.setValue($(checkBoxEl).is(":checked"));
});
const unwatchFn2 = opts.isVisibilityCheckboxRelevant.watch((isRelevant) => {
$(el).find("label").css("display", isRelevant ? "flex" : "none");
});
const unwatchFn3 = opts.currentVisibility.watch((currentVisibility) => {
if (currentVisibility === null) {
$(el).find("p").css("display", "none");
} else {
$(el).find("p").css("display", "");
if (currentVisibility.isVisible) {
$(el).find("[data-sel=\"negative-word\"]").css("display", "none");
} else {
$(el).find("[data-sel=\"negative-word\"]").css("display", "");
}
}
});
const buttonEl = $(el).find("[data-sel=\"button\"]");
renderVisibilityButton(buttonEl, opts);
function unmount() {
[unwatchFn1, unwatchFn2, unwatchFn3].forEach((fn) => fn());
}
return { unmount };
}
//#endregion
//#region src/components/MyRatingInComment.ts
function renderMyRatingInComment(el, opts) {
el = $(`
`).replaceAll(el);
const smallStarsEl = el.find("[data-sel=\"small-stars\"]");
const visibilityControlEl = el.find("[data-sel=\"visibility-control\"]");
const visibilityDescriptionEl = $(visibilityControlEl).find("[data-sel=\"description\"]");
const visibilityButtonEl = $(visibilityControlEl).find("[data-sel=\"visibility-button\"]");
renderSmallStars(smallStarsEl, {
score: opts.ratedScore,
shouldShowNumber: false
});
opts.currentVisibility.watch((currentVisibility) => {
if (currentVisibility === null) {
visibilityControlEl.css("display", "none");
return;
}
visibilityControlEl.css("display", "");
if (currentVisibility.isVisible) {
visibilityDescriptionEl.text("已公开评分");
visibilityButtonEl.text("不再公开");
} else {
visibilityDescriptionEl.text("未公开评分");
visibilityButtonEl.text("公开");
}
});
renderVisibilityButton(visibilityButtonEl, opts);
}
//#endregion
//#region src/components/ErrorWithRetry.ts
function renderErrorWithRetry(el, props) {
$(el).css("color", "red");
$(el).html(`
`);
$(el).find("span").text(`错误:${props.message}`);
$(el).find("button").on("click", props.onRetry);
return { el };
}
//#endregion
//#region src/page-processors/ep.ts
async function processEpPage() {
const el = $(`
单集评分加载中…
`);
$("#columnEpA").prepend(el);
processEpPageInternal({ el });
}
async function processEpPageInternal(opts) {
const resp = await global_default.client.getEpisodeRatings();
if (resp[0] === "auth_required") throw new Error("unreachable");
if (resp[0] === "error") {
const [_$1, _name, message] = resp;
const { el } = renderErrorWithRetry(opts.el, {
message,
onRetry: () => processEpPageInternal(opts)
});
opts.el = el;
return;
}
resp[0];
const [_, ratingsData] = resp;
const votesData = new Watched(new VotesData(ratingsData.votes));
global_default.updateCurrentEpisodeVisibilityFromServerRaw(ratingsData.my_rating?.visibility);
renderScoreboard(opts.el, { votesData });
const scoreChartEl = $("").insertBefore("#columnEpA > .epDesc");
renderScoreChart(scoreChartEl, { votesData });
$(``).insertAfter("#columnEpA > .epDesc");
const myRatingEl = $("").insertAfter(".singleCommentList > .board");
if (!ratingsData.my_rating) {
global_default.token.setValue(null);
}
const ratedScore = new Watched(ratingsData.my_rating?.score ?? null);
renderMyRating(myRatingEl, {
episodeID: global_default.episodeID,
ratedScore,
isPrimary: true,
canRefetchAfterAuth: true,
votesData
});
const userReplyMap = await collectUserReplyMap();
const myReplies = new Watched(collectMyReplies());
{
const oldInsertFn = chiiLib.ajax_reply.insertJsonComments;
chiiLib.ajax_reply.insertJsonComments = function(...args) {
oldInsertFn.apply(this, args);
myReplies.setValue(collectMyReplies());
};
}
const currentVisibility = (() => {
const watched = new Watched(null);
function update() {
if (!myReplies.getValueOnce().length) {
watched.setValue(null);
} else {
watched.setValue(global_default.currentEpisodeVisibilityFromServer.getValueOnce());
}
}
myReplies.watchDeferred(update);
global_default.currentEpisodeVisibilityFromServer.watch(update);
return watched;
})();
const ratedScoreGeneric = ratedScore.createComputed((score) => score ?? NaN);
myReplies.watch((myReplies$1) => {
processMyUnprocessedComments({
ratedScore: ratedScoreGeneric,
currentVisibility,
replies: myReplies$1
});
});
const votersToScore = convertVotersByScoreToVotersToScore(ratingsData.public_ratings.public_voters_by_score);
processOtherPeoplesComments({
votersToScore,
userReplyMap,
myUserID: global_default.claimedUserID
});
const isVisibilityCheckboxRelevant = (() => {
const watched = new Watched(true);
function update() {
watched.setValue(ratedScore.getValueOnce() !== null && currentVisibility.getValueOnce() === null);
}
ratedScore.watchDeferred(update);
currentVisibility.watch(update);
return watched;
})();
const visibilityCheckboxValue = new Watched(false, { shouldDeduplicateShallowly: true });
processReplyForm({
isVisibilityCheckboxRelevant,
visibilityCheckboxValue,
currentVisibility
});
processReplysForm({
isVisibilityCheckboxRelevant,
visibilityCheckboxValue,
currentVisibility
});
}
function processReplyForm(opts) {
const el = $("#ReplyForm");
const submitButtonEl = $(el).find("#submitBtnO");
const controlEl = $("").insertBefore(submitButtonEl);
renderReplyFormVisibilityControl(controlEl, opts);
$(el.on("submit", async () => {
changeVisibilityIfNecessary({
isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(),
currentVisibility: opts.currentVisibility.getValueOnce(),
changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() }
});
}));
}
function processReplysForm(opts) {
const unmountFns = [];
const oldSubReplyFn = (window.unsafeWindow ?? window).subReply;
(window.unsafeWindow ?? window).subReply = function(...args) {
oldSubReplyFn(...args);
const el = $("#ReplysForm");
const submitButtonEl = $(el).find("#submitBtnO");
const controlEl = $("").insertBefore(submitButtonEl);
const { unmount: unmountFn } = renderReplyFormVisibilityControl(controlEl, opts);
unmountFns.push(unmountFn);
$(el.on("submit", async () => {
unmountFns.forEach((fn) => fn());
await changeVisibilityIfNecessary({
isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(),
currentVisibility: opts.currentVisibility.getValueOnce(),
changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() }
});
}));
};
const oldSubReplycancelFn = (window.unsafeWindow ?? window).subReplycancel;
(window.unsafeWindow ?? window).subReplycancel = function(...args) {
unmountFns.forEach((fn) => fn());
oldSubReplycancelFn(...args);
};
}
async function changeVisibilityIfNecessary(opts) {
if (!opts.isRelevant) return;
if (opts.currentVisibility?.isVisible === opts.changedVisibility.isVisible) {
return;
}
const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: opts.changedVisibility.isVisible });
if (result[0] === "auth_required") {
global_default.token.setValue(null);
} else if (result[0] === "error") {
console.warn("单集评分组件", "`changeUserEpisodeRatingVisibility`", result);
} else if (result[0] === "ok") {
global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
} else {
result;
}
}
function processMyUnprocessedComments(opts) {
for (const reply of opts.replies) {
const el = $(reply.el);
if (el.hasClass("__bgm_ep_ratings__processed")) continue;
el.addClass("__bgm_ep_ratings__processed");
const myRatingInCommentEl = $("").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0));
renderMyRatingInComment(myRatingInCommentEl, opts);
}
}
function processOtherPeoplesComments(opts) {
for (const [voterUserID_, score] of Object.entries(opts.votersToScore)) {
const voterUserID = Number(voterUserID_);
if (voterUserID === opts.myUserID) continue;
for (const reply of opts.userReplyMap[voterUserID] ?? []) {
const el = $(reply.el);
const smallStarsEl = $("").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0));
renderSmallStars(smallStarsEl, {
score: new Watched(score),
shouldShowNumber: false
});
}
}
}
async function collectUserReplyMap() {
const replies = await collectReplies();
const output = {};
for (const reply of replies) {
(output[reply.userID] ??= []).push(reply);
}
return output;
}
async function collectReplies() {
let output = [];
let timeStart = performance.now();
for (const el of document.querySelectorAll("[id^=\"post_\"]")) {
const isSubReply = isElementSubReply(el);
const replyOnClickText = $(el).find("a:has(> span.ico_reply)").eq(0).attr("onclick");
if (!replyOnClickText) {
continue;
}
const args = /\((.*)\)/.exec(replyOnClickText)[1].split(",").map((arg) => arg.trim());
const userID = Number(isSubReply ? args.at(-3) : args.at(-2));
output.push({
el,
isSubReply,
userID
});
if (performance.now() - timeStart >= 10) {
await new Promise((resolve) => setTimeout(resolve, 0));
timeStart = performance.now();
}
}
return output;
}
function collectMyReplies() {
if (!global_default.token.getValueOnce()) return [];
const myTextUserID = new URL($("#headerNeue2 .idBadgerNeue > .avatar").attr("href")).pathname.split("/").filter(Boolean).at(-1);
return $(`[id^="post_"]:has(> a.avatar[href$="/${myTextUserID}"])`).map((_, el) => ({
el,
isSubReply: isElementSubReply(el)
})).toArray();
}
function isElementSubReply(el) {
return !!$(el).closest(".topic_sub_reply").length;
}
function convertVotersByScoreToVotersToScore(votersByScore) {
const output = {};
for (const [score, voters] of Object.entries(votersByScore)) {
for (const voter of voters) {
output[voter] = Number(score);
}
}
return output;
}
//#endregion
//#region src/components/RateInfo.ts
function renderRateInfo(el, props) {
el = $(`
`).replaceAll(el);
const rateInfoEl = el.find(".rateInfo");
const smallStarsEl = el.find("[data-sel=\"small-stars\"]");
const buttonEl = el.find("button");
const score = props.votesData.createComputed((votesData) => votesData.averageScore);
renderSmallStars(smallStarsEl, {
score,
shouldShowNumber: true
});
props.votesData.watch((votesData) => {
$(el).find(".tip_j").text(`(${votesData.totalVotes}人评分)`);
});
buttonEl.on("click", () => {
rateInfoEl.css("display", "");
buttonEl.css("display", "none");
props.onReveal?.();
});
props.requiresClickToReveal.watch((requiresClickToReveal) => {
if (requiresClickToReveal) {
rateInfoEl.css("display", "none");
buttonEl.css("display", "");
} else {
rateInfoEl.css("display", "");
buttonEl.css("display", "none");
}
});
}
//#endregion
//#region src/element-processors/cluetip.ts
let isNavigatingAway = false;
$(window).on("beforeunload", () => {
isNavigatingAway = true;
});
function processCluetip() {
let counter = 0;
const revealed = {};
async function update(opts) {
const el = $("#cluetip");
const popupEl = $(el).find(".prg_popup");
if (popupEl.attr("data-bgm-ep-ratings-initialized")) return;
popupEl.attr("data-bgm-ep-ratings-initialized", "true");
counter++;
const currentCounter = counter;
if (!global_default.client.hasCachedSubjectEpisodesRatings(opts.subjectID)) {
await new Promise((resolve) => setTimeout(resolve, 250));
if (isNavigatingAway || currentCounter !== counter || !popupEl.is(":visible")) {
return;
}
}
const loadingEl = $(`
单集评分加载中…
`).insertBefore($(popupEl).find(".tip .board:first"));
updateInternal({
...opts,
currentCounter,
loadingEl,
popupEl
});
}
async function updateInternal(opts) {
const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: opts.subjectID });
if (resp[0] === "error") {
const [_$1, _name, message] = resp;
const { el } = renderErrorWithRetry(opts.loadingEl, {
message,
onRetry: () => updateInternal(opts)
});
opts.loadingEl = el;
return;
}
resp[0];
const [_, epsRatings] = resp;
opts.loadingEl.remove();
if (opts.currentCounter !== counter) return;
const votesData = new Watched(new VotesData(epsRatings.episodes_votes[opts.episodeID] ?? {}));
const requiresClickToReveal = new Watched(false);
requiresClickToReveal.setValue(!(opts.hasUserWatched || revealed[`${opts.subjectID}:${opts.episodeID}`] || !votesData.getValueOnce().totalVotes));
function revealScore() {
revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
requiresClickToReveal.setValue(false);
}
const rateInfoEl = $("").insertBefore($(opts.popupEl).find(".tip .board:first"));
renderRateInfo(rateInfoEl, {
votesData,
requiresClickToReveal,
onReveal: () => {
revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
}
});
$(opts.popupEl).find(".epStatusTool > a.ep_status").each((_$1, epStatusEl) => {
if (epStatusEl.id.startsWith("Watched")) {
$(epStatusEl).on("click", () => revealScore());
}
});
}
return { update };
}
//#endregion
//#region src/page-processors/root.ts
async function processRootPage() {
const { update: updateCluetip } = processCluetip();
let isMouseOver = false;
$("ul.prg_list > li").each((_, liEl) => {
if (!$(liEl).find(".load-epinfo").length) return;
$(liEl).on("mouseover", () => {
if (isMouseOver) return;
isMouseOver = true;
const aEl = $(liEl).find("a");
const subjectID = Number($(aEl).attr("subject_id"));
const episodeID = (() => {
const href = $(aEl).attr("href");
const match = href.match(/^\/ep\/(\d+)/);
return Number(match[1]);
})();
updateCluetip({
subjectID,
episodeID,
hasUserWatched: aEl.hasClass("epBtnWatched")
});
}).on("mouseout", () => {
isMouseOver = false;
});
});
}
//#endregion
//#region src/page-processors/subject.ts
async function processSubjectPage() {
const { update: updateCluetip } = processCluetip();
let isMouseOver = false;
$("ul.prg_list > li").each((_, liEl) => {
if (!$(liEl).find(".load-epinfo").length) return;
$(liEl).on("mouseover", () => {
if (isMouseOver) return;
isMouseOver = true;
const aEl = $(liEl).find("a");
const episodeID = (() => {
const href = $(aEl).attr("href");
const match = href.match(/^\/ep\/(\d+)/);
return Number(match[1]);
})();
updateCluetip({
subjectID: global_default.subjectID,
episodeID,
hasUserWatched: aEl.hasClass("epBtnWatched")
});
}).on("mouseout", () => {
isMouseOver = false;
});
});
}
//#endregion
//#region src/page-processors/subject-ep-list.ts
async function processSubjectEpListPage() {
const editEpBatchEl = $("[name=\"edit_ep_batch\"]");
let loadingEl = null;
$(editEpBatchEl).find("li").each((_, li) => {
if (!$(li).find("[name=\"ep_mod[]\"]").length) return;
$(``).insertAfter($(li).find("h6"));
loadingEl = $(`
单集评分加载中…
`).appendTo(li);
return false;
});
if (loadingEl) {
processSubjectEpListPageInternal({
loadingEl,
editEpBatchEl
});
}
}
async function processSubjectEpListPageInternal(opts) {
const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: global_default.subjectID });
if (resp[0] === "error") {
const [_$1, _name, message] = resp;
const { el } = renderErrorWithRetry(opts.loadingEl, {
message,
onRetry: () => processSubjectEpListPageInternal(opts)
});
opts.loadingEl = el;
return;
}
resp[0];
const [_, epsRatings] = resp;
if (opts.loadingEl) {
opts.loadingEl.remove();
}
if (!epsRatings.my_ratings) {
global_default.token.setValue(null);
}
let isFirst_ = true;
$(opts.editEpBatchEl).find("li").each((_$1, li) => {
if (!$(li).find("[name=\"ep_mod[]\"]").length) return;
const isFirst = isFirst_;
isFirst_ = false;
if (!isFirst) {
$(``).insertAfter($(li).find("h6"));
}
const episodeID = (() => {
const href = $(li).find("> h6 > a").attr("href");
const match = /\/ep\/(\d+)/.exec(href);
return Number(match[1]);
})();
const ratings = epsRatings.episodes_votes[episodeID];
if (!epsRatings.is_certain_that_episodes_votes_are_integral && ratings === undefined) {
return;
}
const votesData = new Watched(new VotesData(ratings ?? {}));
const myRating = epsRatings.my_ratings?.[episodeID];
const hasUserWatched = $(li).find(".statusWatched").length || myRating !== undefined;
const myRatingEl = $("");
$(li).append(myRatingEl);
renderMyRating(myRatingEl, {
episodeID,
ratedScore: new Watched(myRating ?? null),
isPrimary: isFirst,
canRefetchAfterAuth: false,
votesData
});
const rateInfoEl = $("");
$(li).append(rateInfoEl);
renderRateInfo(rateInfoEl, {
votesData,
requiresClickToReveal: new Watched(!hasUserWatched && !!votesData.getValueOnce().totalVotes)
});
$(li).append($(``));
});
}
//#endregion
//#region src/main.ts
function migrate() {
const tokenInWrongPlace = localStorage.getItem("bgm_test_app_token");
if (tokenInWrongPlace) {
localStorage.setItem(env_default.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_default.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("检测到本脚本/超合金组件(单集评分 by Umajho A.K.A. um)先前已经初始化过,本实例将不会继续运行。", {
version: global_default.version,
isInUserScriptRuntime
});
return;
}
$("").appendTo("head");
const searchParams = new URLSearchParams(window.location.search);
const tokenCoupon = searchParams.get(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON);
if (tokenCoupon) {
searchParams.delete(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON);
let newURL = `${window.location.pathname}`;
if (searchParams.size) {
newURL += `?${searchParams.toString()}`;
}
window.history.replaceState(null, "", newURL);
const resp = await global_default.client.redeemTokenCoupon(tokenCoupon);
if (resp[0] === "ok") {
global_default.token.setValue(resp[1]);
} else if (resp[0] === "error") {
window.alert(`获取 token 失败:${resp[2]} (${resp[1]})`);
} else {
resp;
}
window.close();
}
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (!pathParts.length) {
await processRootPage();
} else if (pathParts.length === 2 && pathParts[0] === "subject") {
await processSubjectPage();
} else if (pathParts.length === 3 && pathParts[0] === "subject" && pathParts[2] === "ep") {
await processSubjectEpListPage();
} else if (pathParts.length === 2 && pathParts[0] === "ep") {
await processEpPage();
}
}
migrate();
initializeGlobal();
main();
//#endregion
})();