// ==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 // @downloadURL https://update.greasyfork.icu/scripts/505098/bangumi-episode-ratings-gadget.user.js // @updateURL https://update.greasyfork.icu/scripts/505098/bangumi-episode-ratings-gadget.meta.js // ==/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 = $(`
`).replaceAll(el); for (const score of scores) { const starEl = $(`
`); el.append(starEl); const aEl = starEl.find("a"); aEl.text(score); aEl.attr("title", describeScoreEx(score)); starEl.on("mouseover", () => props.hoveredScore.setValue(score)).on("mouseout", () => props.hoveredScore.setValue(null)).on("click", () => props.onRateEpisode(score)); } function updateStarsContainer(params) { if (params[0] === "invisible") { el.css("display", "none"); return; } el.css("display", ""); const [_, { ratedScore, hoveredScore }] = params; const isHovering = hoveredScore !== null; const maxScoreToHighlight = hoveredScore ?? ratedScore ?? null; { let alarmScore = maxScoreToHighlight; if (alarmScore === "cancel") { alarmScore = ratedScore; } props.onUpdateScoreToAlarm(alarmScore); } const starEls = el.find(".star-rating"); for (const score of scores) { const starEl = starEls.eq(score - 1); starEl.removeClass("star-rating-on").removeClass("star-rating-hover"); if (typeof maxScoreToHighlight === "number" && score <= maxScoreToHighlight) { starEl.addClass(isHovering ? "star-rating-hover" : "star-rating-on"); } } $(el).find(".rating-cancel").removeClass("star-rating-hover"); if (hoveredScore === "cancel") { $(el).find(".rating-cancel").addClass("star-rating-hover"); } } return { updateStarsContainer }; } //#endregion //#region src/models/VotesData.ts class VotesData { constructor(data) { this.data = data; } getClonedData() { return { ...this.data }; } getScoreVotes(score) { return this.data[score] ?? 0; } totalVotesCache = null; get totalVotes() { if (this.totalVotesCache) return this.totalVotesCache; let totalVotes = 0; for (const score of scores) { totalVotes += this.getScoreVotes(score); } return this.totalVotesCache = totalVotes; } averageScoreCache = null; get averageScore() { if (this.averageScoreCache) return this.averageScoreCache; let totalScore = 0; for (const score of scores) { totalScore += this.getScoreVotes(score) * score; } return this.averageScoreCache = totalScore / this.totalVotes; } mostVotedScoreCache = null; get mostVotedScore() { if (this.mostVotedScoreCache) return this.mostVotedScoreCache; let mostVotedScore = scores[0]; for (const score of scores.slice(1)) { if (this.getScoreVotes(score) > this.getScoreVotes(mostVotedScore)) { mostVotedScore = score; } } return this.mostVotedScoreCache = mostVotedScore; } get votesOfMostVotedScore() { return this.getScoreVotes(this.mostVotedScore); } } //#endregion //#region src/components/MyRating.ts function renderMyRating(el, props) { const hoveredScore = new Watched(null); el = $(`

我的评价:

`).replaceAll(el); const starsContainerEl = el.find(".stars-container"); const { updateStarsContainer } = renderStars(starsContainerEl, { hoveredScore, onRateEpisode: rateEpisode, onUpdateScoreToAlarm: (score) => { if (score !== null) { $(el).find(".alarm").text(describeScoreEx(score)); } else { $(el).find(".alarm").text(""); } } }); $(el).find(".rating-cancel").on("mouseover", () => hoveredScore.setValue("cancel")).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(null)); props.ratedScore.watchDeferred((ratedScore) => updateStarsContainer(["normal", { ratedScore, hoveredScore: hoveredScore.getValueOnce() }])); hoveredScore.watch((hoveredScore$1) => { updateStarsContainer(["normal", { ratedScore: props.ratedScore.getValueOnce(), hoveredScore: hoveredScore$1 }]); }); const messageEl = el.find(".message"); function updateMessage(value) { messageEl.attr("style", ""); switch (value[0]) { case "none": { messageEl.text(""); messageEl.css("display", "none"); break; } case "processing": { messageEl.text("处理中…"); messageEl.css("color", "grey"); break; } case "loading": { messageEl.text("加载中…"); messageEl.css("color", "grey"); break; } case "error": { messageEl.text(value[1]); messageEl.css("color", "red"); break; } case "auth_link": { messageEl.html(` 若要查看或提交自己的单集评分,
请先授权此应用
单集评分应用需要以此来确认登录者。 `); $(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE); break; } case "requiring_fetch": { if (props.canRefetchAfterAuth) { messageEl.html(` 点击或刷新本页以获取。 `); $(messageEl).find("button").on("click", async () => { updateMessage(["loading"]); const resp = await global_default.client.getMyEpisodeRating(); if (resp[0] === "auth_required") { global_default.token.setValue(null); } else if (resp[0] === "error") { const [_, _name, message] = resp; updateMessage(["error", message]); } else if (resp[0] === "ok") { const [_, data] = resp; updateMessage(["none"]); updateVotesData(props.votesData, { oldScore: props.ratedScore.getValueOnce(), newScore: data.score }); props.ratedScore.setValue(data.score); global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility); } else { resp; } }); } else { messageEl.text("请刷新本页以获取。"); } break; } default: value; } } updateMessage(["none"]); global_default.token.watch((newToken, oldToken) => { if (newToken) { if (oldToken !== undefined) { if (props.isPrimary) { updateMessage(["requiring_fetch"]); } updateStarsContainer(["invisible"]); } else { updateMessage(["none"]); } } else { if (props.isPrimary) { updateMessage(["auth_link"]); } else { el.css("display", "none"); } updateStarsContainer(["invisible"]); } }); async function rateEpisode(scoreToRate) { if (!global_default.token.getValueOnce()) return; updateMessage(["processing"]); const resp = await global_default.client.rateEpisode({ subjectID: global_default.subjectID, episodeID: props.episodeID, score: scoreToRate }); if (resp[0] === "auth_required") { updateMessage(["auth_link"]); } else if (resp[0] === "error") { const [_, _name, message] = resp; updateMessage(["error", message]); } else if (resp[0] === "ok") { const [_, data] = resp; updateMessage(["none"]); updateVotesData(props.votesData, { oldScore: props.ratedScore.getValueOnce(), newScore: data.score }); props.ratedScore.setValue(data.score); global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility); } else { resp; } } } function updateVotesData(votesData, opts) { const newVotesData = votesData.getValueOnce().getClonedData(); if (opts.oldScore !== null) { newVotesData[opts.oldScore]--; } if (opts.newScore !== null) { newVotesData[opts.newScore] ??= 0; newVotesData[opts.newScore]++; } votesData.setValue(new VotesData(newVotesData)); } //#endregion //#region src/components/Scoreboard.ts function renderScoreboard(el, props) { el = $(`
单集评分
`).replaceAll(el); function updateNumber(score) { if (Number.isNaN(score)) { $(el).find(".number").text(0 .toFixed(1)); $(el).find(".description").text("--"); } else { $(el).find(".number").text(score.toFixed(4)); $(el).find(".description").text(describeScore(score)); } } props.votesData.watch((votesData) => { updateNumber(votesData.averageScore); }); } //#endregion //#region src/components/Tooltip.ts function renderTooltip(el, props) { el = $(` `).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 = $(`
votes
`).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(() => $("
").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 = $(`
  • `).replaceAll(el); const percentage = (props.votes / props.totalVotes * 100).toFixed(2); $(el).find(".textTip").attr("data-original-title", `${percentage}% (${props.votes}人)`); $(el).find(".label").text(props.score); const height = (props.votes / props.votesOfMostVotedScore * 100).toFixed(2); $(el).find(".count").css("height", `${height}%`); $(el).find(".count").text(`(${props.votes})`); $(el).on("mouseover", () => props.updateTooltip({ score: props.score })).on("mouseout", () => props.updateTooltip({ score: null })); return { el }; } //#endregion //#region src/components/SmallStars.ts function renderSmallStars(el, props) { el = $(` `).replaceAll(el); const starlightEl = $(el).find("[data-sel=\"starlight\"]"); if (!props.shouldShowNumber) { $(el).find("small.fade").remove(); } props.score.watch((score) => { if (Number.isNaN(score)) { $(starlightEl).removeClass(); if (props.shouldShowNumber) { $(el).find("small.fade").text("--"); } } else { $(starlightEl).removeClass().addClass("starlight").addClass(`stars${Math.round(score)}`); if (props.shouldShowNumber) { $(el).find("small.fade").text(score.toFixed(4)); } } }); } //#endregion //#region src/components/VisibilityButton.ts function renderVisibilityButton(el, opts) { el = $(` `).replaceAll(el); const isDisabled = new Watched(false); const buttonEl = $(el).find("button"); const messageEl = $(el).find("[data-sel='message']"); opts.currentVisibility.watch((currentVisibility) => { if (currentVisibility === null) { $(el).css("display", "none"); return; } $(el).css("display", ""); if (currentVisibility.isVisible) { $(buttonEl).text("不再公开"); } else { $(buttonEl).text("公开"); } }); isDisabled.watch((isDisabled$1) => { if (isDisabled$1) { $(buttonEl).attr("disabled", "disabled"); } else { $(buttonEl).removeAttr("disabled"); } }); $(buttonEl).on("click", async () => { const currentVisibility = opts.currentVisibility.getValueOnce().isVisible; isDisabled.setValue(true); updateMessage(["processing"]); const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: !currentVisibility }); if (result[0] === "auth_required") { global_default.token.setValue(null); updateMessage(["auth_link"]); } else if (result[0] === "error") { updateMessage(["error", result[1]]); } else if (result[0] === "ok") { isDisabled.setValue(false); updateMessage(["none"]); global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]); } else { result; } }); function updateMessage(value) { messageEl.attr("style", ""); switch (value[0]) { case "none": { messageEl.text(""); messageEl.css("display", "none"); break; } case "processing": { messageEl.text("处理中…"); messageEl.css("color", "grey"); break; } case "error": { messageEl.text(value[1]); messageEl.css("color", "red"); break; } case "auth_link": { messageEl.html(` 请先授权此应用。 `); $(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE); break; } case "requiring_reload": { messageEl.text("请刷新本页以操作。"); break; } default: value; } } updateMessage(["none"]); global_default.token.watch((newToken, oldToken) => { if (newToken) { if (oldToken !== undefined) { isDisabled.setValue(true); updateMessage(["requiring_reload"]); } else { updateMessage(["none"]); } } else { isDisabled.setValue(true); updateMessage(["auth_link"]); } }); } //#endregion //#region src/components/ReplyFormVisibilityControl.ts function renderReplyFormVisibilityControl(el, opts) { el = $(`

    我的吐槽旁会公开我对本集的评分

    `).replaceAll(el); const checkBoxEl = $(el).find("input[type=\"checkbox\"]"); const unwatchFn1 = opts.visibilityCheckboxValue.watch((value) => { $(checkBoxEl).prop("checked", value); }); $(checkBoxEl).on("change", () => { opts.visibilityCheckboxValue.setValue($(checkBoxEl).is(":checked")); }); const unwatchFn2 = opts.isVisibilityCheckboxRelevant.watch((isRelevant) => { $(el).find("label").css("display", isRelevant ? "flex" : "none"); }); const unwatchFn3 = opts.currentVisibility.watch((currentVisibility) => { if (currentVisibility === null) { $(el).find("p").css("display", "none"); } else { $(el).find("p").css("display", ""); if (currentVisibility.isVisible) { $(el).find("[data-sel=\"negative-word\"]").css("display", "none"); } else { $(el).find("[data-sel=\"negative-word\"]").css("display", ""); } } }); const buttonEl = $(el).find("[data-sel=\"button\"]"); renderVisibilityButton(buttonEl, opts); function unmount() { [unwatchFn1, unwatchFn2, unwatchFn3].forEach((fn) => fn()); } return { unmount }; } //#endregion //#region src/components/MyRatingInComment.ts function renderMyRatingInComment(el, opts) { el = $(`
    `).replaceAll(el); const smallStarsEl = el.find("[data-sel=\"small-stars\"]"); const visibilityControlEl = el.find("[data-sel=\"visibility-control\"]"); const visibilityDescriptionEl = $(visibilityControlEl).find("[data-sel=\"description\"]"); const visibilityButtonEl = $(visibilityControlEl).find("[data-sel=\"visibility-button\"]"); renderSmallStars(smallStarsEl, { score: opts.ratedScore, shouldShowNumber: false }); opts.currentVisibility.watch((currentVisibility) => { if (currentVisibility === null) { visibilityControlEl.css("display", "none"); return; } visibilityControlEl.css("display", ""); if (currentVisibility.isVisible) { visibilityDescriptionEl.text("已公开评分"); visibilityButtonEl.text("不再公开"); } else { visibilityDescriptionEl.text("未公开评分"); visibilityButtonEl.text("公开"); } }); renderVisibilityButton(visibilityButtonEl, opts); } //#endregion //#region src/components/ErrorWithRetry.ts function renderErrorWithRetry(el, props) { $(el).css("color", "red"); $(el).html(` `); $(el).find("span").text(`错误:${props.message}`); $(el).find("button").on("click", props.onRetry); return { el }; } //#endregion //#region src/page-processors/ep.ts async function processEpPage() { const el = $(`
    单集评分加载中…
    `); $("#columnEpA").prepend(el); processEpPageInternal({ el }); } async function processEpPageInternal(opts) { const resp = await global_default.client.getEpisodeRatings(); if (resp[0] === "auth_required") throw new Error("unreachable"); if (resp[0] === "error") { const [_$1, _name, message] = resp; const { el } = renderErrorWithRetry(opts.el, { message, onRetry: () => processEpPageInternal(opts) }); opts.el = el; return; } resp[0]; const [_, ratingsData] = resp; const votesData = new Watched(new VotesData(ratingsData.votes)); global_default.updateCurrentEpisodeVisibilityFromServerRaw(ratingsData.my_rating?.visibility); renderScoreboard(opts.el, { votesData }); const scoreChartEl = $("
    ").insertBefore("#columnEpA > .epDesc"); renderScoreChart(scoreChartEl, { votesData }); $(`
    `).insertAfter("#columnEpA > .epDesc"); const myRatingEl = $("
    ").insertAfter(".singleCommentList > .board"); if (!ratingsData.my_rating) { global_default.token.setValue(null); } const ratedScore = new Watched(ratingsData.my_rating?.score ?? null); renderMyRating(myRatingEl, { episodeID: global_default.episodeID, ratedScore, isPrimary: true, canRefetchAfterAuth: true, votesData }); const userReplyMap = await collectUserReplyMap(); const myReplies = new Watched(collectMyReplies()); { const oldInsertFn = chiiLib.ajax_reply.insertJsonComments; chiiLib.ajax_reply.insertJsonComments = function(...args) { oldInsertFn.apply(this, args); myReplies.setValue(collectMyReplies()); }; } const currentVisibility = (() => { const watched = new Watched(null); function update() { if (!myReplies.getValueOnce().length) { watched.setValue(null); } else { watched.setValue(global_default.currentEpisodeVisibilityFromServer.getValueOnce()); } } myReplies.watchDeferred(update); global_default.currentEpisodeVisibilityFromServer.watch(update); return watched; })(); const ratedScoreGeneric = ratedScore.createComputed((score) => score ?? NaN); myReplies.watch((myReplies$1) => { processMyUnprocessedComments({ ratedScore: ratedScoreGeneric, currentVisibility, replies: myReplies$1 }); }); const votersToScore = convertVotersByScoreToVotersToScore(ratingsData.public_ratings.public_voters_by_score); processOtherPeoplesComments({ votersToScore, userReplyMap, myUserID: global_default.claimedUserID }); const isVisibilityCheckboxRelevant = (() => { const watched = new Watched(true); function update() { watched.setValue(ratedScore.getValueOnce() !== null && currentVisibility.getValueOnce() === null); } ratedScore.watchDeferred(update); currentVisibility.watch(update); return watched; })(); const visibilityCheckboxValue = new Watched(false, { shouldDeduplicateShallowly: true }); processReplyForm({ isVisibilityCheckboxRelevant, visibilityCheckboxValue, currentVisibility }); processReplysForm({ isVisibilityCheckboxRelevant, visibilityCheckboxValue, currentVisibility }); } function processReplyForm(opts) { const el = $("#ReplyForm"); const submitButtonEl = $(el).find("#submitBtnO"); const controlEl = $("
    ").insertBefore(submitButtonEl); renderReplyFormVisibilityControl(controlEl, opts); $(el.on("submit", async () => { changeVisibilityIfNecessary({ isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(), currentVisibility: opts.currentVisibility.getValueOnce(), changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() } }); })); } function processReplysForm(opts) { const unmountFns = []; const oldSubReplyFn = (window.unsafeWindow ?? window).subReply; (window.unsafeWindow ?? window).subReply = function(...args) { oldSubReplyFn(...args); const el = $("#ReplysForm"); const submitButtonEl = $(el).find("#submitBtnO"); const controlEl = $("
    ").insertBefore(submitButtonEl); const { unmount: unmountFn } = renderReplyFormVisibilityControl(controlEl, opts); unmountFns.push(unmountFn); $(el.on("submit", async () => { unmountFns.forEach((fn) => fn()); await changeVisibilityIfNecessary({ isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(), currentVisibility: opts.currentVisibility.getValueOnce(), changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() } }); })); }; const oldSubReplycancelFn = (window.unsafeWindow ?? window).subReplycancel; (window.unsafeWindow ?? window).subReplycancel = function(...args) { unmountFns.forEach((fn) => fn()); oldSubReplycancelFn(...args); }; } async function changeVisibilityIfNecessary(opts) { if (!opts.isRelevant) return; if (opts.currentVisibility?.isVisible === opts.changedVisibility.isVisible) { return; } const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: opts.changedVisibility.isVisible }); if (result[0] === "auth_required") { global_default.token.setValue(null); } else if (result[0] === "error") { console.warn("单集评分组件", "`changeUserEpisodeRatingVisibility`", result); } else if (result[0] === "ok") { global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]); } else { result; } } function processMyUnprocessedComments(opts) { for (const reply of opts.replies) { const el = $(reply.el); if (el.hasClass("__bgm_ep_ratings__processed")) continue; el.addClass("__bgm_ep_ratings__processed"); const myRatingInCommentEl = $("
    ").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0)); renderMyRatingInComment(myRatingInCommentEl, opts); } } function processOtherPeoplesComments(opts) { for (const [voterUserID_, score] of Object.entries(opts.votersToScore)) { const voterUserID = Number(voterUserID_); if (voterUserID === opts.myUserID) continue; for (const reply of opts.userReplyMap[voterUserID] ?? []) { const el = $(reply.el); const smallStarsEl = $("
    ").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0)); renderSmallStars(smallStarsEl, { score: new Watched(score), shouldShowNumber: false }); } } } async function collectUserReplyMap() { const replies = await collectReplies(); const output = {}; for (const reply of replies) { (output[reply.userID] ??= []).push(reply); } return output; } async function collectReplies() { let output = []; let timeStart = performance.now(); for (const el of document.querySelectorAll("[id^=\"post_\"]")) { const isSubReply = isElementSubReply(el); const replyOnClickText = $(el).find("a:has(> span.ico_reply)").eq(0).attr("onclick"); if (!replyOnClickText) { continue; } const args = /\((.*)\)/.exec(replyOnClickText)[1].split(",").map((arg) => arg.trim()); const userID = Number(isSubReply ? args.at(-3) : args.at(-2)); output.push({ el, isSubReply, userID }); if (performance.now() - timeStart >= 10) { await new Promise((resolve) => setTimeout(resolve, 0)); timeStart = performance.now(); } } return output; } function collectMyReplies() { if (!global_default.token.getValueOnce()) return []; const myTextUserID = new URL($("#headerNeue2 .idBadgerNeue > .avatar").attr("href")).pathname.split("/").filter(Boolean).at(-1); return $(`[id^="post_"]:has(> a.avatar[href$="/${myTextUserID}"])`).map((_, el) => ({ el, isSubReply: isElementSubReply(el) })).toArray(); } function isElementSubReply(el) { return !!$(el).closest(".topic_sub_reply").length; } function convertVotersByScoreToVotersToScore(votersByScore) { const output = {}; for (const [score, voters] of Object.entries(votersByScore)) { for (const voter of voters) { output[voter] = Number(score); } } return output; } //#endregion //#region src/components/RateInfo.ts function renderRateInfo(el, props) { el = $(`
    `).replaceAll(el); const rateInfoEl = el.find(".rateInfo"); const smallStarsEl = el.find("[data-sel=\"small-stars\"]"); const buttonEl = el.find("button"); const score = props.votesData.createComputed((votesData) => votesData.averageScore); renderSmallStars(smallStarsEl, { score, shouldShowNumber: true }); props.votesData.watch((votesData) => { $(el).find(".tip_j").text(`(${votesData.totalVotes}人评分)`); }); buttonEl.on("click", () => { rateInfoEl.css("display", ""); buttonEl.css("display", "none"); props.onReveal?.(); }); props.requiresClickToReveal.watch((requiresClickToReveal) => { if (requiresClickToReveal) { rateInfoEl.css("display", "none"); buttonEl.css("display", ""); } else { rateInfoEl.css("display", ""); buttonEl.css("display", "none"); } }); } //#endregion //#region src/element-processors/cluetip.ts 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 = $(`
    单集评分加载中…
    `).insertBefore($(popupEl).find(".tip .board:first")); updateInternal({ ...opts, currentCounter, loadingEl, popupEl }); } async function updateInternal(opts) { const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: opts.subjectID }); if (resp[0] === "error") { const [_$1, _name, message] = resp; const { el } = renderErrorWithRetry(opts.loadingEl, { message, onRetry: () => updateInternal(opts) }); opts.loadingEl = el; return; } resp[0]; const [_, epsRatings] = resp; opts.loadingEl.remove(); if (opts.currentCounter !== counter) return; const votesData = new Watched(new VotesData(epsRatings.episodes_votes[opts.episodeID] ?? {})); const requiresClickToReveal = new Watched(false); requiresClickToReveal.setValue(!(opts.hasUserWatched || revealed[`${opts.subjectID}:${opts.episodeID}`] || !votesData.getValueOnce().totalVotes)); function revealScore() { revealed[`${opts.subjectID}:${opts.episodeID}`] = true; requiresClickToReveal.setValue(false); } const rateInfoEl = $("
    ").insertBefore($(opts.popupEl).find(".tip .board:first")); renderRateInfo(rateInfoEl, { votesData, requiresClickToReveal, onReveal: () => { revealed[`${opts.subjectID}:${opts.episodeID}`] = true; } }); $(opts.popupEl).find(".epStatusTool > a.ep_status").each((_$1, epStatusEl) => { if (epStatusEl.id.startsWith("Watched")) { $(epStatusEl).on("click", () => revealScore()); } }); } return { update }; } //#endregion //#region src/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 = $(`
    `).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(`

    ${dateStr}

    `); ulEl = $("
      ").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 = $(`
    • ${userName} 为剧集 ${episodeID}
    • `).appendTo(ulEl); const actionEl = itemEl.find("[data-sel='action']"); if (payload.score !== null) { actionEl.html("评分 "); 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 = $(` `).appendTo(infoEl); const url = `https://api.bgm.tv/v0/subjects/${subjectID}/image?type=grid`; cardEl.find("img").attr("src", url); } { const extraEl = $(`
      · 单集评分
      `).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 = $(` `).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 = $(`
      `).appendTo(el); const innerEl = pagerEl.find(".page_inner"); if (props.onClickPreviousPageButton) { const prevEl = $(`‹‹上一页`).appendTo(innerEl); prevEl.on("click", (ev) => { ev.preventDefault(); props.onClickPreviousPageButton(); }); } if (props.onClickNextPageButton) { const nextEl = $(`下一页››`).appendTo(innerEl); nextEl.on("click", (ev) => { ev.preventDefault(); props.onClickNextPageButton(); }); } else { $(`没有下一页了…`).appendTo(innerEl); } } tooltip = renderTooltip($("
      ").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; } $(`
    • 我的单集评分
    • `).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) { $(`
      `).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) { $(`
      `).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 $("
      ").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; $(`
      `).insertAfter($(li).find("h6")); loadingEl = $(`
      单集评分加载中…
      `).appendTo(li); return false; }); if (loadingEl) { processSubjectEpListPageInternal({ loadingEl, editEpBatchEl }); } } async function processSubjectEpListPageInternal(opts) { const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: global_default.subjectID }); if (resp[0] === "error") { const [_$1, _name, message] = resp; const { el } = renderErrorWithRetry(opts.loadingEl, { message, onRetry: () => processSubjectEpListPageInternal(opts) }); opts.loadingEl = el; return; } resp[0]; const [_, epsRatings] = resp; if (opts.loadingEl) { opts.loadingEl.remove(); } if (!epsRatings.my_ratings) { global_default.token.setValue(null); } let isFirst_ = true; $(opts.editEpBatchEl).find("li").each((_$1, li) => { if (!$(li).find("[name=\"ep_mod[]\"]").length) return; const isFirst = isFirst_; isFirst_ = false; if (!isFirst) { $(`
      `).insertAfter($(li).find("h6")); } const episodeID = (() => { const href = $(li).find("> h6 > a").attr("href"); const match = /\/ep\/(\d+)/.exec(href); return Number(match[1]); })(); const ratings = epsRatings.episodes_votes[episodeID]; const votesData = new Watched(new VotesData(ratings ?? {})); const myRating = epsRatings.my_ratings?.[episodeID]; const hasUserWatched = $(li).find(".statusWatched").length || myRating !== undefined; const myRatingEl = $("
      "); $(li).append(myRatingEl); renderMyRating(myRatingEl, { episodeID, ratedScore: new Watched(myRating ?? null), isPrimary: isFirst, canRefetchAfterAuth: false, votesData }); const rateInfoEl = $("
      "); $(li).append(rateInfoEl); renderRateInfo(rateInfoEl, { votesData, requiresClickToReveal: new Watched(!hasUserWatched && !!votesData.getValueOnce().totalVotes) }); $(li).append($(`
      `)); }); } //#endregion //#region src/main.ts function migrate() { const tokenInWrongPlace = localStorage.getItem("bgm_test_app_token"); if (tokenInWrongPlace) { localStorage.setItem(env_default.LOCAL_STORAGE_KEY_TOKEN, tokenInWrongPlace); localStorage.removeItem("bgm_test_app_token"); } const searchParams = new URLSearchParams(window.location.search); const tokenCouponInWrongPlace = searchParams.get("bgm_test_app_token_coupon"); if (tokenCouponInWrongPlace) { searchParams.set(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON, tokenCouponInWrongPlace); searchParams.delete("bgm_test_app_token_coupon"); let newURL = `${window.location.pathname}?${searchParams.toString()}`; window.history.replaceState(null, "", newURL); } } async function main() { const isInUserScriptRuntime = typeof GM_info !== "undefined"; if ($("meta[name=\"__bgm_ep_ratings__initialized\"]").length) { console.warn("检测到本脚本/超合金组件(单集评分 by Umajho A.K.A. um)先前已经初始化过,本实例将不会继续运行。", { version: global_default.version, isInUserScriptRuntime }); return; } $("").appendTo("head"); const searchParams = new URLSearchParams(window.location.search); const tokenCoupon = searchParams.get(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON); if (tokenCoupon) { searchParams.delete(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON); let newURL = `${window.location.pathname}`; if (searchParams.size) { newURL += `?${searchParams.toString()}`; } window.history.replaceState(null, "", newURL); const resp = await global_default.client.redeemTokenCoupon(tokenCoupon); if (resp[0] === "ok") { global_default.token.setValue(resp[1]); } else if (resp[0] === "error") { window.alert(`获取 token 失败:${resp[2]} (${resp[1]})`); } else { resp; } window.close(); } const pathParts = window.location.pathname.split("/").filter(Boolean); if (!pathParts.length) { await processRootPage(); } else if (pathParts.length === 2 && pathParts[0] === "subject") { await processSubjectPage(); } else if (pathParts.length === 3 && pathParts[0] === "subject" && pathParts[2] === "ep") { await processSubjectEpListPage(); } else if (pathParts.length === 2 && pathParts[0] === "ep") { await processEpPage(); } } migrate(); initializeGlobal(); main(); //#endregion })();