Greasy Fork

Greasy Fork is available in English.

bangumi-episode-ratings-gadget

Bangumi 单集评分的超合金组件

当前为 2024-10-03 提交的版本,查看 最新版本

// ==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
// ==/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 = $(`
    <div class="stars-container">
      <div class="rating-cancel"><a title="Cancel Rating"></a></div>
    </div>
  `).replaceAll(el);
	for (const score of scores) {
		const starEl = $(`
      <div class="star-rating">
        <a></a>
      </div>
    `);
		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 = $(`
    <div style="float: right; display: flex; flex-direction: column;">
      <p style="font-size: 12px;">我的评价:
        <span class="alarm"></span>
      </p>
      <div class="stars-container"></div>
      <div class="message"></div>
    </div>
  `).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(`
          若要查看或提交自己的单集评分,
          <br >
          请先<a class="l" target="_blank">授权此应用</a>。
          <br >
          单集评分应用需要以此来确认登录者。
        `);
				$(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE);
				break;
			}
			case "requiring_fetch": {
				if (props.canRefetchAfterAuth) {
					messageEl.html(`
            点击<button class="l">此处</button>或刷新本页以获取。 
          `);
					$(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 = $(`
    <div class="global_score" style="float: right;">
      <span class="description"></span>
      <span class="number"></span>
      <div>
        <small class="grey" style="float: right;">单集评分</small>
      </div>
    </div>
  `).replaceAll(el);
	function updateNumber(score) {
		if (Number.isNaN(score)) {
			$(el).find(".number").text(0 .toFixed(1));
			$(el).find(".description").text("--");
		} else {
			$(el).find(".number").text(score.toFixed(4));
			$(el).find(".description").text(describeScore(score));
		}
	}
	props.votesData.watch((votesData) => {
		updateNumber(votesData.averageScore);
	});
}

//#endregion
//#region src/components/ScoreChart.ts
function renderScoreChart(el, props) {
	el = $(`
    <div id="ChartWarpper" class="chartWrapper" style="float: right; width: 218px;">
      <div class="chart_desc"><small class="grey"><span class="votes"></span> votes</small></div>
      <ul class="horizontalChart">
        <div class="tooltip fade top in" role="tooltip" style="top: -34px; transform: translateX(-50%);">
          <div class="tooltip-arrow" style="left: 50%;"></div>
          <div class="tooltip-inner"></div>
        </div>
      </ul>
    </div>
  `).replaceAll(el);
	const chartEl = el.find(".horizontalChart");
	const barEls = scores.map(() => $("<div />").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 = $(`
    <li><a class="textTip"><span class="label"></span><span class="count"></span></a></li>
  `).replaceAll(el);
	const percentage = (props.votes / props.totalVotes * 100).toFixed(2);
	$(el).find(".textTip").attr("data-original-title", `${percentage}% (${props.votes}人)`);
	$(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 = $(`
    <span>
      <span class="starstop-s">
        <span data-sel="starlight" class="starlight"></span>
      </span>
      <small class="fade"></small>
    </span>

  `).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 = $(`
    <span>
      <button></button>
      <span data-sel="message"></span>
    </span>
  `).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(`
          请先<a class="l" target="_blank">授权此应用</a>。
        `);
				$(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 = $(`
    <div style="height: 30.5px; float: right; display: flex; align-items: center;">
      <label>
        <input type="checkbox" />不要在我的吐槽旁公开我对本集的评分
      </label>
      <p>
        我的吐槽旁<span data-sel="negative-word">不</span>会公开我对本集的评分
        <div data-sel="button"></div>
      </p>
    </div>
  `).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 = $(` 
    <span>
      <div data-sel="small-stars"></div>
      <span data-sel="visibility-control">
        <span data-sel="description" style="font-size: 12px;"></span>
        <div data-sel="visibility-button"></div>
      </span>
    </span>
  `).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(`
    <span></span>
    <button type="button">重试</button>
  `);
	$(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 = $(`
    <div style="color: grey; float: right;">
      单集评分加载中…
    </div>
  `);
	$("#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 = $("<div />").insertBefore("#columnEpA > .epDesc");
	renderScoreChart(scoreChartEl, { votesData });
	$(`<div class="clear" />`).insertAfter("#columnEpA > .epDesc");
	const myRatingEl = $("<div />").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 = $("<div />").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 = $("<div />").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 = $("<div />").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 = $("<div />").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 = $(`
    <div>
      <div class="rateInfo" style="display: none;">
        <div data-sel="small-stars"></div>
        <span class="tip_j"></span>
      </div>
      <button type="button" style="display: none;">显示评分</button>
    </div>
  `).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 = $(`
      <div style="color: grey">
        单集评分加载中…
      </div>
    `).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 = $("<div />").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;
		$(`<div class="clear"></div>`).insertAfter($(li).find("h6"));
		loadingEl = $(`
      <div style="color: grey; float: right;">
        单集评分加载中…
      </div>
    `).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) {
			$(`<div class="clear"></div>`).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 = $("<div />");
		$(li).append(myRatingEl);
		renderMyRating(myRatingEl, {
			episodeID,
			ratedScore: new Watched(myRating ?? null),
			isPrimary: isFirst,
			canRefetchAfterAuth: false,
			votesData
		});
		const rateInfoEl = $("<div />");
		$(li).append(rateInfoEl);
		renderRateInfo(rateInfoEl, {
			votesData,
			requiresClickToReveal: new Watched(!hasUserWatched && !!votesData.getValueOnce().totalVotes)
		});
		$(li).append($(`<div class="clear"></div>`));
	});
}

//#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;
	}
	$("<meta name=\"__bgm_ep_ratings__initialized\" content=\"true\">").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
})();