// ==UserScript==
// @namespace https://github.com/umajho
// @name bangumi-episode-ratings-gadget
// @version 0.3.0
// @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.3.0";
const ENDPOINT_PATHS = {
AUTH: {
BANGUMI_PAGE: "bangumi-page",
CALLBACK: "callback",
REDEEM_TOKEN_COUPON: "redeem-token-coupon"
}
};
class Client {
constructor(opts) {
this.subjectEpisodesRatingsCache = {};
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 mustRedeemTokenCoupon(tokenCoupon) {
const data = await this.fetch(
"auth",
ENDPOINT_PATHS.AUTH.REDEEM_TOKEN_COUPON,
{
method: "POST",
body: JSON.stringify({ tokenCoupon })
}
);
return unwrap(data);
}
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`,
{ method: "PUT", body: JSON.stringify(bodyData) }
);
} else {
return await this.fetch(
"api/v1",
`subjects/${opts.subjectID}/episodes/${opts.episodeID}/ratings/mine`,
{ method: "DELETE" }
);
}
}
hasCachedSubjectEpisodesRatings(subjectID) {
return !!this.subjectEpisodesRatingsCache[subjectID];
}
async mustGetSubjectEpisodesRatings(opts) {
if (this.subjectEpisodesRatingsCache[opts.subjectID]) {
return this.subjectEpisodesRatingsCache[opts.subjectID];
}
return this.subjectEpisodesRatingsCache[opts.subjectID] = this.fetch(
"api/v1",
`subjects/${opts.subjectID}/episodes/ratings`,
{ method: "GET" }
).then(
(resp) => this.subjectEpisodesRatingsCache[opts.subjectID] = unwrap(resp)
);
}
async mustGetEpisodeRatings() {
const resp = await this.fetch(
"api/v1",
`subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings`,
{ method: "GET" }
);
return unwrap(resp);
}
async getMyEpisodeRating() {
return await this.fetch(
"api/v1",
`subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings/mine`,
{ method: "GET" }
);
}
async changeUserEpisodeRatingVisibility(opts) {
return await this.fetch(
"api/v1",
`subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings/mine/is-visible`,
{ 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) {
headers.set("Authorization", `Basic ${this.token}`);
}
headers.set("X-Gadget-Version", Global.version);
if (Global.claimedUserID !== null) {
headers.set("X-Claimed-User-ID", Global.claimedUserID.toString());
}
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} 失败`, e);
return ["error", "UNKNOWN", `${operation} 失败: ${e}`];
}
}
buildFullEndpoint(group, endpointPath) {
return join(join(this.entrypoint, group + "/"), endpointPath);
}
clearCache() {
this.subjectEpisodesRatingsCache = {};
}
}
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, opts) {
this._value = _value;
this._watchers = [];
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, void 0);
return this.watchDeferred(cb);
}
createComputed(computeFn) {
const computed = new Watched(computeFn(this.getValueOnce()));
this.watchDeferred((newValue) => {
computed.setValue(computeFn(newValue));
});
return computed;
}
}
const global = {};
const Global = global;
function initializeGlobal() {
Object.assign(global, makeGlobal());
((window.unsafeWindow ?? window).__bgm_ep_ratings__debug ??= {}).Global = global;
}
function makeGlobal() {
const { subjectID, episodeID } = (() => {
let subjectID2 = null;
let episodeID2 = null;
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts[0] === "subject") {
subjectID2 = Number(pathParts[1]);
} else if (pathParts.length === 2 && pathParts[0] === "ep") {
episodeID2 = Number(pathParts[1]);
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;
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
};
}
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;
}
function renderStars(el, props) {
el = $(
/*html*/
`
`
).replaceAll(el);
for (const score of scores) {
const starEl = $(
/*html*/
`
`
);
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 };
}
class VotesData {
constructor(data) {
this.data = data;
this.totalVotesCache = null;
this.averageScoreCache = null;
this.mostVotedScoreCache = null;
}
getClonedData() {
return { ...this.data };
}
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 renderMyRating(el, props) {
const hoveredScore = new Watched(null);
el = $(
/*html*/
`
`
).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((hoveredScore2) => {
updateStarsContainer(["normal", {
ratedScore: props.ratedScore.getValueOnce(),
hoveredScore: hoveredScore2
}]);
});
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", "gray");
break;
}
case "loading": {
messageEl.text("加载中…");
messageEl.css("color", "gray");
break;
}
case "error": {
messageEl.text(value[1]);
messageEl.css("color", "red");
break;
}
case "auth_link": {
messageEl.html(
/*html*/
`
若要查看或提交自己的单集评分,
请先授权此应用。
单集评分应用需要以此来确认登录者。
`
);
$(messageEl).find("a").attr(
"href",
Global.client.URL_AUTH_BANGUMI_PAGE
);
break;
}
case "requiring_fetch": {
if (props.canRefetchAfterAuth) {
messageEl.html(
/*html*/
`
点击或刷新本页以获取。
`
);
$(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"]);
updateVotesData(props.votesData, {
oldScore: props.ratedScore.getValueOnce(),
newScore: data.score
});
props.ratedScore.setValue(data.score);
Global.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
} else ;
});
} else {
messageEl.text("请刷新本页以获取。");
}
break;
}
}
}
updateMessage(["none"]);
Global.token.watch((newToken, oldToken) => {
if (newToken) {
if (oldToken !== void 0) {
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.token.getValueOnce()) return;
updateMessage(["processing"]);
const resp = await Global.client.rateEpisode({
subjectID: Global.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.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
} else ;
}
}
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));
}
function renderScoreboard(el, props) {
el = $(
/*html*/
`
`
).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);
});
}
function renderScoreChart(el, props) {
el = $(
/*html*/
`
`
).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 = $(
/*html*/
`
`
).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 };
}
function renderSmallStars(el, props) {
el = $(
/*html*/
`
`
).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));
}
}
});
}
function renderVisibilityButton(el, opts) {
el = $(
/*html*/
`
`
).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((isDisabled2) => {
if (isDisabled2) {
$(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.client.changeUserEpisodeRatingVisibility({
isVisible: !currentVisibility
});
if (result[0] === "auth_required") {
Global.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.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
} else ;
});
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", "gray");
break;
}
case "error": {
messageEl.text(value[1]);
messageEl.css("color", "red");
break;
}
case "auth_link": {
messageEl.html(
/*html*/
`
请先授权此应用。
`
);
$(messageEl).find("a").attr(
"href",
Global.client.URL_AUTH_BANGUMI_PAGE
);
break;
}
case "requiring_reload": {
messageEl.text("请刷新本页以操作。");
break;
}
}
}
updateMessage(["none"]);
Global.token.watch((newToken, oldToken) => {
if (newToken) {
if (oldToken !== void 0) {
isDisabled.setValue(true);
updateMessage(["requiring_reload"]);
} else {
updateMessage(["none"]);
}
} else {
isDisabled.setValue(true);
updateMessage(["auth_link"]);
}
});
}
function renderReplyFormVisibilityControl(el, opts) {
el = $(
/*html*/
`
`
).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 };
}
function renderMyRatingInComment(el, opts) {
el = $(
/*html*/
`
`
).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);
}
async function processEpPage() {
const scoreboardEl = $(
/*html*/
`
单集评分加载中…
`
);
$("#columnEpA").prepend(scoreboardEl);
const ratingsData = await Global.client.mustGetEpisodeRatings();
const votesData = new Watched(
new VotesData(
ratingsData.votes
)
);
Global.updateCurrentEpisodeVisibilityFromServerRaw(
ratingsData.my_rating?.visibility
);
renderScoreboard(scoreboardEl, { votesData });
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);
}
const ratedScore = new Watched(
ratingsData.my_rating?.score ?? null
);
renderMyRating(myRatingEl, {
episodeID: Global.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.currentEpisodeVisibilityFromServer.getValueOnce()
);
}
}
myReplies.watchDeferred(update);
Global.currentEpisodeVisibilityFromServer.watch(update);
return watched;
})();
const ratedScoreGeneric = ratedScore.createComputed((score) => score ?? NaN);
myReplies.watch((myReplies2) => {
processMyUnprocessedComments({
ratedScore: ratedScoreGeneric,
currentVisibility,
replies: myReplies2
});
});
const votersToScore = convertVotersByScoreToVotersToScore(
ratingsData.public_ratings.public_voters_by_score
);
processOtherPeoplesComments({
votersToScore,
userReplyMap,
myUserID: Global.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.client.changeUserEpisodeRatingVisibility({
isVisible: opts.changedVisibility.isVisible
});
if (result[0] === "auth_required") {
Global.token.setValue(null);
} else if (result[0] === "error") {
console.warn(
"单集评分组件",
"`changeUserEpisodeRatingVisibility`",
result
);
} else if (result[0] === "ok") {
Global.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
} else ;
}
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.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;
}
function renderRateInfo(el, props) {
el = $(
/*html*/
`
`
).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");
}
});
}
let isNavigatingAway = false;
$(window).on("beforeunload", () => {
isNavigatingAway = true;
});
function processCluetip() {
const el = $("#cluetip");
let counter = 0;
const revealed = {};
async function update(opts) {
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.client.hasCachedSubjectEpisodesRatings(opts.subjectID)) {
await new Promise((resolve) => setTimeout(resolve, 250));
if (isNavigatingAway || currentCounter !== counter || !popupEl.is(":visible")) {
return;
}
}
const loadingEl = $(
/*html*/
`
单集评分加载中…
`
).insertBefore($(popupEl).find(".tip .board"));
const epsRatings = await Global.client.mustGetSubjectEpisodesRatings({
subjectID: opts.subjectID
});
loadingEl.remove();
if (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($(popupEl).find(".tip .board"));
renderRateInfo(rateInfoEl, {
votesData,
requiresClickToReveal,
onReveal: () => {
revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
}
});
$(popupEl).find(".epStatusTool > a.ep_status").each((_, epStatusEl) => {
if (epStatusEl.id.startsWith("Watched")) {
$(epStatusEl).on("click", () => revealScore());
}
});
}
return { update };
}
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;
});
});
}
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.subjectID,
episodeID,
hasUserWatched: aEl.hasClass("epBtnWatched")
});
}).on("mouseout", () => {
isMouseOver = false;
});
});
}
async function processSubjectEpListPage() {
const editEpBatchEl = $('[name="edit_ep_batch"]');
let loadingEl;
$(editEpBatchEl).find("li").each((_, li) => {
if (!$(li).find('[name="ep_mod[]"]').length) return;
$(
/*html*/
``
).insertAfter($(li).find("h6"));
loadingEl = $(
/*html*/
`
单集评分加载中…
`
);
$(li).append(loadingEl);
return false;
});
const epsRatings = await Global.client.mustGetSubjectEpisodesRatings({
subjectID: Global.subjectID
});
if (loadingEl) {
loadingEl.remove();
}
if (!epsRatings.my_ratings) {
Global.token.setValue(null);
}
let isFirst_ = true;
$(editEpBatchEl).find("li").each((_, li) => {
if (!$(li).find('[name="ep_mod[]"]').length) return;
const isFirst = isFirst_;
isFirst_ = false;
if (!isFirst) {
$(
/*html*/
``
).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 === void 0) {
return;
}
const votesData = new Watched(
new VotesData(ratings ?? {})
);
const myRating = epsRatings.my_ratings?.[episodeID];
const hasUserWatched = $(li).find(".statusWatched").length || // 在 “看过” 之类不能修改章节观看状态的情况下,没法确认用户是否看过,但至
// 少可以假设用户在给了某集评分的时候是看过那一集的。
myRating !== void 0;
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($(
/*html*/
``
));
});
}
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(
"检测到本脚本/超合金组件(单集评分 by Umajho A.K.A. um)先前已经初始化过,本实例将不会继续运行。",
{ 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();
}
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();
})();