// ==UserScript==
// @namespace https://github.com/umajho
// @name bangumi-episode-ratings-gadget
// @version 0.6.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
// ==/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.6.0";
//#endregion
//#region src/bangumi-client.ts
class BangumiClient {
episodeCache = {};
putEntryIntoEpisodeCache(episodeID, entry) {
this.episodeCache[episodeID] = entry;
}
async getEpisodeTitle(episodeID) {
const cacheEntry = await (this.episodeCache[episodeID] ??= new Promise(async (resolve) => {
const episode = await this.getEpisode(episodeID);
if (!episodeID) {
resolve(null);
} else {
resolve({
name: episode.name,
sort: episode.sort
});
}
}));
if (!cacheEntry) return `获取失败(ID:${episodeID})`;
return `ep.${cacheEntry.sort} ${cacheEntry.name}`;
}
episodeResponseCache = {};
async getEpisode(episodeID) {
return this.episodeResponseCache[episodeID] ??= new Promise(async (resolve) => {
const path = `/v0/episodes/${episodeID}`;
const resp = await this.fetchAPI(path, { method: "GET" });
if (resp[0] === "error") {
resolve(null);
} else {
resolve(resp[1]);
}
});
}
async fetchAPI(path, opts) {
const url = new URL(path, "https://api.bgm.tv");
if (opts.searchParams) {
url.search = opts.searchParams.toString();
}
const resp = await fetch(url.toString(), {
method: opts.method,
...opts.body && { body: opts.body }
});
if (!resp.ok) {
console.warn("调用 bangumi API 失败", await resp.text());
return ["error"];
}
return ["ok", await resp.json()];
}
}
//#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 ("then" in cached) {
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)
});
}
get TIMELINE_ITEMS_PER_PAGE() {
return 10;
}
async getMyTimelineItems(opts) {
const searchParams = new URLSearchParams();
searchParams.set("offset", "" + (opts.pageNumber - 1) * 10);
searchParams.set("limit", "" + this.TIMELINE_ITEMS_PER_PAGE);
return await this.fetch("api/v1", `users/me/timeline/items`, {
tokenType: "jwt",
method: "GET",
searchParams
});
}
async deleteMyTimelineItem(opts) {
return await this.fetch("api/v1", `users/me/timeline/items/${opts.timestampMs}`, {
tokenType: "jwt",
method: "DELETE"
});
}
async downloadMyEpisodeRatingsData() {
const resp = await this.fetch("api/v1", "users/me/episode-ratings-data-file", {
tokenType: "jwt",
method: "GET"
});
if (resp[0] !== "ok") return resp;
const [_, data] = resp;
this.saveFile(data.content, { fileName: data.fileName });
return ["ok", undefined];
}
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();
}
}
saveFile(data, opts) {
const blob = new Blob([data], { type: "text/plain; charset=utf-8" });
const aEl = document.createElement("a");
aEl.href = URL.createObjectURL(blob);
aEl.download = opts.fileName;
aEl.click();
URL.revokeObjectURL(aEl.href);
}
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/watched.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 meAEl = $("#dock .content .first > a");
const claimedUserTextID = meAEl.attr("href")?.split("/")?.at(-1) ?? null;
const claimedUserName = meAEl.text().trim() ?? null;
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()
});
const bangumiClient = new BangumiClient();
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,
claimedUserTextID,
claimedUserName,
token,
client,
bangumiClient,
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/Tooltip.ts
function renderTooltip(el, props) {
el = $(`
<div class="tooltip fade top in" role="tooltip">
<div class="tooltip-arrow" style="left: 50%;"></div>
<div class="tooltip-inner"></div>
</div>
`).replaceAll(el);
el.attr("style", props.initialStyle);
const updateVisibility = (isVisible) => {
el.css("display", isVisible ? "block" : "none");
};
const updateLeft = (leftPx) => {
el.css("left", `${leftPx}px`);
};
const updateTop = (topPx) => {
el.css("top", `${topPx}px`);
};
const updateContent = (text) => {
el.find(".tooltip-inner").text(text);
[];
};
return {
updateVisibility,
updateLeft,
updateTop,
updateContent
};
}
//#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 data-sel="tooltip"></div>
</ul>
</div>
`).replaceAll(el);
const tooltip = renderTooltip(el.find("[data-sel='tooltip']"), { initialStyle: "top: -34px; transform: translateX(-50%);" });
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) {
if (opts.score === null) {
tooltip.updateVisibility(false);
return;
}
tooltip.updateVisibility(true);
const barEl = $(chartEl).find(`li`).eq(10 - opts.score);
const barElRelativeOffsetLeft = barEl.offset().left - el.offset().left;
tooltip.updateLeft(barElRelativeOffsetLeft + barEl.width() / 2);
const votesData = props.votesData.getValueOnce();
let scoreVotes = votesData.getScoreVotes(opts.score);
const percentage = votesData.totalVotes ? scoreVotes / votesData.totalVotes * 100 : 0;
tooltip.updateContent(`${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
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 (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/utils/date-formatting.ts
function formatDate(date, opts) {
const nowDayNumber = calculateDayNumber(opts.now);
const dayNumber = calculateDayNumber(date);
if (dayNumber === nowDayNumber) return "今天";
if (dayNumber === nowDayNumber - 1) return "昨天";
const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate();
return `${y}-${m}-${d}`;
}
function formatDateToTime(date) {
const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate();
const h = date.getHours(), min = date.getMinutes();
const hStr = h < 10 ? `0${h}` : h;
const minStr = min < 10 ? `0${min}` : min;
return `${y}-${m}-${d} ${hStr}:${minStr}`;
}
function formatDatesDifferences(dateA, dateB) {
const diff = dateB.getTime() - dateA.getTime();
let suffix;
if (diff < 0) {
suffix = "后";
[dateA, dateB] = [dateB, dateA];
} else {
suffix = "前";
}
const tsA = Math.floor(dateA.getTime() / 1000), tsB = Math.floor(dateB.getTime() / 1000);
const secondsA = tsA % 60, secondsB = tsB % 60;
let diffSeconds = secondsB - secondsA;
const minutesA = Math.floor(tsA / 60) % 60, minutesB = Math.floor(tsB / 60) % 60;
let diffMinutes = minutesB - minutesA;
if (diffSeconds < 0) {
diffMinutes--;
diffSeconds += 60;
}
const hoursA = dateA.getHours(), hoursB = dateB.getHours();
let diffHours = hoursB - hoursA;
if (diffMinutes < 0) {
diffHours--;
diffMinutes += 60;
}
const daysA = dateA.getDate(), daysB = dateB.getDate();
let diffDays = daysB - daysA;
if (diffHours < 0) {
diffDays--;
diffHours += 24;
}
const monthsA = dateA.getMonth() + 1, monthsB = dateB.getMonth() + 1;
let diffMonths = monthsB - monthsA;
if (diffDays < 0) {
diffMonths--;
const daysInMonth = new Date(dateA.getFullYear(), dateA.getMonth() + 1, 0).getDate();
diffDays += daysInMonth;
}
const yearsA = dateA.getFullYear(), yearsB = dateB.getFullYear();
let diffYears = yearsB - yearsA;
if (diffMonths < 0) {
diffYears--;
const monthsInYear = 12;
diffMonths += monthsInYear;
}
let ret;
if (diffYears !== 0) {
ret = `${diffYears}年${diffMonths > 0 ? `${diffMonths}月` : ""}`;
} else if (diffMonths !== 0) {
ret = `${diffMonths}月${diffDays > 0 ? `${diffDays}天` : ""}`;
} else if (diffDays !== 0) {
ret = `${diffDays}天${diffHours > 0 ? `${diffHours}时` : ""}`;
} else if (diffHours !== 0) {
ret = `${diffHours}小时${diffMinutes > 0 ? `${diffMinutes}分钟` : ""}`;
} else if (diffMinutes !== 0) {
ret = `${diffMinutes}分钟${diffSeconds > 0 ? `${diffSeconds}秒` : ""}`;
} else {
ret = `${diffSeconds}秒`;
}
return `${ret}${suffix}`;
}
const timezoneOffsetSeconds = new Date().getTimezoneOffset() * 60 * 1000;
function calculateDayNumber(date) {
const timestamp = Math.floor(date.getTime() / 1000);
const localTimestamp = timestamp - timezoneOffsetSeconds;
return Math.floor(localTimestamp / (24 * 60 * 60));
}
//#endregion
//#region src/utils/simple-intersection-observer.ts
const callbackMap = new WeakMap();
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const cb = callbackMap.get(entry.target);
if (cb && entry.isIntersecting) {
cb();
observer.unobserve(entry.target);
}
});
});
function observeInteractionWithViewportOnce(el, cb) {
callbackMap.set(el, cb);
observer.observe(el);
}
setInterval(() => {
observer.takeRecords().forEach((record) => {
if (!document.contains(record.target)) {
observer.unobserve(record.target);
}
});
}, 1000);
//#endregion
//#region src/components/TimelineContent.ts
function renderTimelineContent(el, props) {
const now = new Date();
const episodeToSubjectMap = makeEpisodeToSubjectMap(props.data.subjects);
el = $(`
<div id="timeline" style="position: relative;">
</div>
`).replaceAll(el);
el.attr(props.dataAttributeName, "true");
let lastDateStr = null;
let ulEl;
let tooltip;
let lastUserTextID = null;
for (const [timestampMs, type, payload] of props.data.items) {
const date = new Date(timestampMs);
const dateStr = formatDate(date, { now });
if (lastDateStr !== dateStr) {
lastDateStr = dateStr;
el.append(`<h4 class="Header">${dateStr}</h4>`);
ulEl = $("<ul>").appendTo(el);
lastUserTextID = null;
}
let userTextID = null;
let episodeID = null;
let subjectID = null;
let itemEl = null;
if (type === "rate-episode") {
userTextID = global_default.claimedUserTextID;
const userName = global_default.claimedUserName;
episodeID = payload.episode_id;
itemEl = $(`
<li class="clearit tml_item">
<span class="info clearit">
<span>
<a href="/user/${userTextID}" class="l">${userName}</a>
为剧集
<a data-sel="ep-title-link" href="/ep/${episodeID}" class="l">${episodeID}</a>
<span data-sel="action"></span>
</span>
</span>
<a title="删除这条时间线" class="tml_del" style="display: none; cursor: pointer;">del</a>
</li>
`).appendTo(ulEl);
const actionEl = itemEl.find("[data-sel='action']");
if (payload.score !== null) {
actionEl.html("评分 <span data-sel=\"stars\"></span>");
renderSmallStars(actionEl.find("[data-sel='stars']"), {
score: new Watched(payload.score),
shouldShowNumber: false
});
} else {
actionEl.html("取消评分");
}
const delEl = itemEl.find(".tml_del");
itemEl.on("mouseenter", () => delEl.css("display", "block"));
itemEl.on("mouseleave", () => delEl.css("display", "none"));
delEl.on("click", async () => {
const result = await global_default.client.deleteMyTimelineItem({ timestampMs });
switch (result[0]) {
case "ok":
itemEl.remove();
break;
case "error":
alert("删除单集评分时间线项目失败:" + result[2]);
break;
case "auth_required":
alert("认证失败。");
global_default.token.setValue(null);
break;
}
});
subjectID = episodeToSubjectMap[episodeID];
}
const epTitleLinkEl = itemEl?.find("[data-sel='ep-title-link']");
epTitleLinkEl?.each((_, el$1) => {
observeInteractionWithViewportOnce(el$1, async () => {
$(el$1).text($(el$1).text() + "(加载中…)");
const title = await global_default.bangumiClient.getEpisodeTitle(episodeID);
$(el$1).text(title);
});
});
const infoEl = itemEl?.find(".info");
if (infoEl?.length) {
if (subjectID) {
const cardEl = $(`
<div class="card card_tiny">
<div class="container">
<a href="/subject/${subjectID}">
<span class="cover">
<img loading="lazy">
</span>
</a>
</div>
</div>
`).appendTo(infoEl);
const url = `https://api.bgm.tv/v0/subjects/${subjectID}/image?type=grid`;
cardEl.find("img").attr("src", url);
}
{
const extraEl = $(`
<div class="post_actions date">
<span class="titleTip"></span>
· <small class="grey"><a target="_blank">单集评分</a></small>
</div>
`).appendTo(infoEl);
{
extraEl.find("a").attr("href", "/dev/app/3263");
}
const titleTipEl = extraEl.find(".titleTip");
titleTipEl.text(formatDatesDifferences(date, now));
titleTipEl.on("mouseover", () => {
tooltip.updateVisibility(true);
const relativeLeft = titleTipEl.offset().left - el.offset().left;
const relativeTop = titleTipEl.offset().top - el.offset().top;
tooltip.updateLeft(relativeLeft + titleTipEl.width() / 2);
tooltip.updateTop(relativeTop);
tooltip.updateContent(formatDateToTime(date));
}).on("mouseout", () => tooltip.updateVisibility(false));
}
}
if (itemEl && lastUserTextID !== userTextID) {
const avatarEl = $(`
<span class="avatar">
<a href="/user/${userTextID}" class="avatar">
<span class="avatarNeue avatarReSize40 ll"></span>
</a>
</span>
`).prependTo(ulEl.find("> li:last"));
if (userTextID) {
const safeUserTextID = encodeURIComponent(userTextID);
const url = `https://api.bgm.tv/v0/users/${safeUserTextID}/avatar?type=small`;
avatarEl.find(".avatarNeue").css("background-image", `url('${url}')`);
}
lastUserTextID = userTextID;
}
}
{
const pagerEl = $(`
<div id="tmlPager">
<div class="page_inner"></div>
</div>
`).appendTo(el);
const innerEl = pagerEl.find(".page_inner");
if (props.onClickPreviousPageButton) {
const prevEl = $(`<a class="p">‹‹上一页</a>`).appendTo(innerEl);
prevEl.on("click", (ev) => {
ev.preventDefault();
props.onClickPreviousPageButton();
});
}
if (props.onClickNextPageButton) {
const nextEl = $(`<a class="p">下一页››</a>`).appendTo(innerEl);
nextEl.on("click", (ev) => {
ev.preventDefault();
props.onClickNextPageButton();
});
} else {
$(`<span>没有下一页了…</span>`).appendTo(innerEl);
}
}
tooltip = renderTooltip($("<div />").appendTo(el), { initialStyle: "transform: translate(-50%, -100%);" });
}
function makeEpisodeToSubjectMap(subjectsData) {
const map = {};
for (const [subjectID_, subjectData] of Object.entries(subjectsData)) {
const subjectID = Number(subjectID_);
for (const episodeID of subjectData.episode_ids) {
map[episodeID] = subjectID;
}
}
return map;
}
//#endregion
//#region src/page-processors/root.ts
const TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME = "data-bgm-ep-ratings-timeline-content";
const TIMELINE_TOP_BAR_ID = "__bgm_ep_ratings__tl_top_bar";
async function processRootPage() {
$(".load-epinfo").each((_, el) => {
const href = $(el).attr("href");
const title = $(el).attr("title");
if (!href || !title) return;
const episodeID = Number(href.split("/").at(-1));
const m = /^ep\.(.+?) (.+)$/.exec(title);
if (isNaN(episodeID) || !m) return;
const sort = Number(m[1]);
const name = m[2];
if (isNaN(sort)) return;
global_default.bangumiClient.putEntryIntoEpisodeCache(episodeID, {
name,
sort
});
});
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;
});
});
const tlButtonID = "__bgm_ep_ratings__tl_button";
global_default.token.watch((token) => {
if (!token) {
$(`#${tlButtonID}`).remove();
if ($(`#tmlContent [${TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME}]`).length) {
backToMainTimelineTab();
}
return;
}
$(`
<li id="${tlButtonID}">
<a style="cursor: pointer;">
<span>我的单集评分</span>
</a>
</li>`).appendTo("ul#timelineTabs > li:has(a.top) > ul").on("click", async () => {
$("#timelineTabs > li > a.focus").removeClass("focus");
const containerEl = $("#tmlContent");
if (!containerEl.find(`#${TIMELINE_TOP_BAR_ID}`).length) {
$(`
<div id="${TIMELINE_TOP_BAR_ID}">
<button>导出我的单集评分数据</button>
</div>
`).prependTo(containerEl).on("click", () => {
global_default.client.downloadMyEpisodeRatingsData();
});
}
await processMyTimelineContent(containerEl, { pageNumber: 1 });
});
});
}
async function processMyTimelineContent(containerEl, opts) {
renderLoading(clearContainerAndGetNewChildElement(containerEl), { attributeName: TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME });
const resp = await global_default.client.getMyTimelineItems(opts);
if (resp[0] === "auth_required") {
global_default.token.setValue(null);
backToMainTimelineTab();
} else if (resp[0] === "error") {
const [_, _name, message] = resp;
renderErrorWithRetry(clearContainerAndGetNewChildElement(containerEl), {
message,
onRetry: () => processMyTimelineContent(containerEl, opts)
});
} else {
resp[0];
const [_, data] = resp;
const onClickPreviousPageButton = opts.pageNumber > 1 ? () => processMyTimelineContent(containerEl, { pageNumber: opts.pageNumber - 1 }) : null;
const isPageFull = data.items.length === global_default.client.TIMELINE_ITEMS_PER_PAGE;
const onClickNextPageButton = opts.pageNumber < 10 && isPageFull ? () => processMyTimelineContent(containerEl, { pageNumber: opts.pageNumber + 1 }) : null;
renderTimelineContent(clearContainerAndGetNewChildElement(containerEl), {
data,
dataAttributeName: TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME,
onClickPreviousPageButton,
onClickNextPageButton
});
}
}
function renderLoading(el, opts) {
$(`
<div class="loading">
<img src="/img/loadingAnimation.gif">
</div>`).replaceAll(el).attr(opts.attributeName, "true");
}
function backToMainTimelineTab() {
$("#tab_all").trigger("click");
}
function clearContainerAndGetNewChildElement(containerEl) {
containerEl.children().filter((_, el) => el.id !== TIMELINE_TOP_BAR_ID).remove();
return $("<div />").appendTo(containerEl);
}
//#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];
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
})();