// ==UserScript== // @namespace https://github.com/umajho // @name bangumi-episode-ratings-gadget // @version 0.4.1 // @description Bangumi 单集评分的超合金组件 // @license MIT // @website https://github.com/umajho/bangumi-episode-ratings // @match https://bangumi.tv/* // @match https://bgm.tv/* // @match https://chii.in/* // @grant GM_info // @grant unsafeWindow // @grant window.close // @downloadURL none // ==/UserScript== (function() { "use strict"; const env = { get APP_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://bgm-ep-ratings.deno.dev/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" }; const version = "0.4.1"; const ENDPOINT_PATHS = { CORS_PREFLIGHT_BYPASS: "cors-preflight-bypass", AUTH: { BANGUMI_PAGE: "bangumi-page", CALLBACK: "callback", REDEEM_TOKEN_COUPON: "redeem-token-coupon", REFRESH_JWT: "refresh-jwt" } }; class Client { constructor(opts) { this.subjectEpisodesRatingsCache = {}; 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.AUTH.BANGUMI_PAGE)) ); url.searchParams.set("gadget_version", Global.version); return url.toString(); } async redeemTokenCoupon(tokenCoupon) { const resp = await this.fetch( "auth", ENDPOINT_PATHS.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" } ); } } hasCachedSubjectEpisodesRatings(subjectID) { return !!this.subjectEpisodesRatingsCache[subjectID]; } async getSubjectEpisodesRatings(opts) { if (this.subjectEpisodesRatingsCache[opts.subjectID]) { const cached = this.subjectEpisodesRatingsCache[opts.subjectID]; if (cached instanceof Promise) { return await cached; } else { return ["ok", cached]; } } return this.subjectEpisodesRatingsCache[opts.subjectID] = this.fetch( "api/v1", `subjects/${opts.subjectID}/episodes/ratings`, { tokenType: "jwt", method: "GET" } ).then((resp) => { if (resp[0] === "auth_required") { throw new Error("unreachable!"); } else if (resp[0] === "error") { delete this.subjectEpisodesRatingsCache[opts.subjectID]; return resp; } else if (resp[0] === "ok") { const [_, data] = resp; return ["ok", this.subjectEpisodesRatingsCache[opts.subjectID] = data]; } else { throw new Error("unreachable!"); } }); } async getEpisodeRatings() { return await this.fetch( "api/v1", `subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings`, { tokenType: "jwt", method: "GET" } ); } async getMyEpisodeRating() { return await this.fetch( "api/v1", `subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings/mine`, { tokenType: "jwt", method: "GET" } ); } async changeUserEpisodeRatingVisibility(opts) { return await this.fetch( "api/v1", `subjects/${Global.subjectID}/episodes/${Global.episodeID}/ratings/mine/is-visible`, { tokenType: "jwt", method: "PUT", body: JSON.stringify(opts.isVisible) } ); } async fetch(group, endpointPath, opts) { const url = new URL(this.buildFullEndpoint(group, endpointPath)); if (opts.searchParams) { url.search = opts.searchParams.toString(); } const headers = new Headers(); if (this.token) { if (opts.tokenType === "basic") { headers.set("Authorization", `Basic ${this.token}`); } else { const resp = await this.fetchJWT(); if (resp[0] !== "ok") return resp; const [_, jwt] = resp; headers.set("Authorization", `Bearer ${jwt}`); } } headers.set("X-Gadget-Version", Global.version); if (Global.claimedUserID !== null) { headers.set("X-Claimed-User-ID", Global.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.token.getValueOnce() !== null) { Global.token.setValue(null); } return ["auth_required"]; } return respJSON; } catch (e) { const operation = `fetch \`${opts.method} ${url}\``; console.error(`${operation} 失败`, e); return ["error", "UNKNOWN", `${operation} 失败: ${e}`]; } } buildRequest(url, init, opts) { if (opts.shouldBypassCORSPreflight) { url.pathname = `/${ENDPOINT_PATHS.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: throw new Error("unreachable"); } })(); return join(entrypoint, endpointPath); } async fetchJWT() { const fn = async () => { const localToken = localStorage.getItem(env.LOCAL_STORAGE_KEY_JWT); if (localToken && checkJWTExpiry(localToken) === "valid") { return ["ok", localToken]; } const resp = await this.fetch( "auth", ENDPOINT_PATHS.AUTH.REFRESH_JWT, { tokenType: "basic", method: "POST" } ); if (resp[0] === "ok") { const [_, jwt] = resp; localStorage.setItem(env.LOCAL_STORAGE_KEY_JWT, jwt); } return resp; }; if (window.navigator.locks) { return window.navigator.locks.request(env.LOCAL_STORAGE_KEY_JWT, fn); } else { return fn(); } } clearCache() { this.subjectEpisodesRatingsCache = {}; } } function join(base, url) { return new URL(url, base).href; } function checkJWTExpiry(jwt) { const decoded = JSON.parse(atob(jwt.split(".")[1])); const exp = decoded.exp; const now = Math.floor(Date.now() / 1e3); return now > exp ? "expired" : "valid"; } class Watched { constructor(_value, opts) { this._value = _value; this._watchers = []; this._shouldDeduplicateShallowly = // opts?.shouldDeduplicateShallowly ?? false; this._broadcastID = opts?.broadcastID ?? null; if (this._broadcastID) { window.addEventListener("storage", (ev) => { if (ev.key === this._broadcastID && ev.newValue) { const oldValue = this._value; const newValue = JSON.parse(ev.newValue); this._value = newValue; this._watchers.forEach((w) => w(newValue, oldValue)); } }); } } getValueOnce() { return this._value; } setValue(newValue) { const oldValue = this._value; if (this._shouldDeduplicateShallowly && oldValue === newValue) { return; } this._value = newValue; this._watchers.forEach((w) => w(newValue, oldValue)); if (this._broadcastID) { localStorage.setItem(this._broadcastID, JSON.stringify(newValue)); localStorage.removeItem(this._broadcastID); } } watchDeferred(cb) { this._watchers.push(cb); return () => { this._watchers = this._watchers.filter((w) => w !== cb); }; } watch(cb) { cb(this._value, void 0); return this.watchDeferred(cb); } createComputed(computeFn) { const computed = new Watched(computeFn(this.getValueOnce())); this.watchDeferred((newValue) => { computed.setValue(computeFn(newValue)); }); return computed; } } const global = {}; const Global = global; function initializeGlobal() { Object.assign(global, makeGlobal()); ((window.unsafeWindow ?? window).__bgm_ep_ratings__debug ??= {}).Global = global; } function makeGlobal() { const { subjectID, episodeID } = (() => { let subjectID2 = null; let episodeID2 = null; const pathParts = window.location.pathname.split("/").filter(Boolean); if (pathParts[0] === "subject") { subjectID2 = Number(pathParts[1]); } else if (pathParts.length === 2 && pathParts[0] === "ep") { episodeID2 = Number(pathParts[1]); const subjectHref = $("#headerSubject > .nameSingle > a").attr("href"); subjectID2 = Number(subjectHref.split("/")[2]); } return { subjectID: subjectID2, episodeID: episodeID2 }; })(); const claimedUserID = (() => { if ("unsafeWindow" in window) { return window.unsafeWindow.CHOBITS_UID || null; } return window.CHOBITS_UID || null; })(); if (claimedUserID === null) { localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN); } const token = new Watched( localStorage.getItem(env.LOCAL_STORAGE_KEY_TOKEN) ); window.addEventListener("storage", (ev) => { if (ev.key !== env.LOCAL_STORAGE_KEY_TOKEN) return; if (ev.newValue === token.getValueOnce()) return; token.setValue(ev.newValue); }); const client = new Client({ authEntrypoint: env.APP_AUTH_ENTRYPOINT, apiEntrypoint: env.APP_API_ENTRYPOINT, token: token.getValueOnce() }); token.watchDeferred((newToken) => { if (newToken) { localStorage.setItem(env.LOCAL_STORAGE_KEY_TOKEN, newToken); } else { localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN); localStorage.removeItem(env.LOCAL_STORAGE_KEY_JWT); } client.token = newToken; client.clearCache(); }); const currentEpisodeVisibilityFromServer = ( // new Watched(null, { broadcastID: `bgm_ep_ratings::broadcasts::${episodeID}::visibility` }) ); function updateCurrentEpisodeVisibilityFromServerRaw(raw) { if (!raw) { currentEpisodeVisibilityFromServer.setValue(null); } else { currentEpisodeVisibilityFromServer.setValue({ isVisible: raw.is_visible }); } } return { version, subjectID, episodeID, claimedUserID, token, client, currentEpisodeVisibilityFromServer, updateCurrentEpisodeVisibilityFromServerRaw }; } const scores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; function describeScore(score) { return [ [9.5, "超神作"], [8.5, "神作"], [7.5, "力荐"], [6.5, "推荐"], [5.5, "还行"], [4.5, "不过不失"], [3.5, "较差"], [2.5, "差"], [1.5, "很差"] ].find(([min, _]) => score >= min)?.[1] ?? "不忍直视"; } function describeScoreEx(score) { let description = `${describeScore(score)} ${score}`; if (score === 1 || score === 10) { description += " (请谨慎评价)"; } return description; } function renderStars(el, props) { el = $( /*html*/ `
` ).replaceAll(el); for (const score of scores) { const starEl = $( /*html*/ `
` ); el.append(starEl); const aEl = starEl.find("a"); aEl.text(score); aEl.attr("title", describeScoreEx(score)); starEl.on("mouseover", () => props.hoveredScore.setValue(score)).on("mouseout", () => props.hoveredScore.setValue(null)).on("click", () => props.onRateEpisode(score)); } function updateStarsContainer(params) { if (params[0] === "invisible") { el.css("display", "none"); return; } el.css("display", ""); const [_, { ratedScore, hoveredScore }] = params; const isHovering = hoveredScore !== null; const maxScoreToHighlight = hoveredScore ?? ratedScore ?? null; { let alarmScore = maxScoreToHighlight; if (alarmScore === "cancel") { alarmScore = ratedScore; } props.onUpdateScoreToAlarm(alarmScore); } const starEls = el.find(".star-rating"); for (const score of scores) { const starEl = starEls.eq(score - 1); starEl.removeClass("star-rating-on").removeClass("star-rating-hover"); if (typeof maxScoreToHighlight === "number" && score <= maxScoreToHighlight) { starEl.addClass(isHovering ? "star-rating-hover" : "star-rating-on"); } } $(el).find(".rating-cancel").removeClass("star-rating-hover"); if (hoveredScore === "cancel") { $(el).find(".rating-cancel").addClass("star-rating-hover"); } } return { updateStarsContainer }; } class VotesData { constructor(data) { this.data = data; this.totalVotesCache = null; this.averageScoreCache = null; this.mostVotedScoreCache = null; } getClonedData() { return { ...this.data }; } getScoreVotes(score) { return this.data[score] ?? 0; } get totalVotes() { if (this.totalVotesCache) return this.totalVotesCache; let totalVotes = 0; for (const score of scores) { totalVotes += this.getScoreVotes(score); } return this.totalVotesCache = totalVotes; } get averageScore() { if (this.averageScoreCache) return this.averageScoreCache; let totalScore = 0; for (const score of scores) { totalScore += this.getScoreVotes(score) * score; } return this.averageScoreCache = totalScore / this.totalVotes; } get mostVotedScore() { if (this.mostVotedScoreCache) return this.mostVotedScoreCache; let mostVotedScore = scores[0]; for (const score of scores.slice(1)) { if (this.getScoreVotes(score) > this.getScoreVotes(mostVotedScore)) { mostVotedScore = score; } } return this.mostVotedScoreCache = mostVotedScore; } get votesOfMostVotedScore() { return this.getScoreVotes(this.mostVotedScore); } } function renderMyRating(el, props) { const hoveredScore = new Watched(null); el = $( /*html*/ `

我的评价:

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

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

    ` ).replaceAll(el); const checkBoxEl = $(el).find('input[type="checkbox"]'); const unwatchFn1 = opts.visibilityCheckboxValue.watch((value) => { $(checkBoxEl).prop("checked", value); }); $(checkBoxEl).on("change", () => { opts.visibilityCheckboxValue.setValue($(checkBoxEl).is(":checked")); }); const unwatchFn2 = opts.isVisibilityCheckboxRelevant.watch((isRelevant) => { $(el).find("label").css("display", isRelevant ? "flex" : "none"); }); const unwatchFn3 = opts.currentVisibility.watch((currentVisibility) => { if (currentVisibility === null) { $(el).find("p").css("display", "none"); } else { $(el).find("p").css("display", ""); if (currentVisibility.isVisible) { $(el).find('[data-sel="negative-word"]').css("display", "none"); } else { $(el).find('[data-sel="negative-word"]').css("display", ""); } } }); const buttonEl = $(el).find('[data-sel="button"]'); renderVisibilityButton(buttonEl, opts); function unmount() { [unwatchFn1, unwatchFn2, unwatchFn3].forEach((fn) => fn()); } return { unmount }; } function renderMyRatingInComment(el, opts) { el = $( /*html*/ `
    ` ).replaceAll(el); const smallStarsEl = el.find('[data-sel="small-stars"]'); const visibilityControlEl = el.find('[data-sel="visibility-control"]'); const visibilityDescriptionEl = $(visibilityControlEl).find('[data-sel="description"]'); const visibilityButtonEl = $(visibilityControlEl).find('[data-sel="visibility-button"]'); renderSmallStars(smallStarsEl, { score: opts.ratedScore, shouldShowNumber: false }); opts.currentVisibility.watch((currentVisibility) => { if (currentVisibility === null) { visibilityControlEl.css("display", "none"); return; } visibilityControlEl.css("display", ""); if (currentVisibility.isVisible) { visibilityDescriptionEl.text("已公开评分"); visibilityButtonEl.text("不再公开"); } else { visibilityDescriptionEl.text("未公开评分"); visibilityButtonEl.text("公开"); } }); renderVisibilityButton(visibilityButtonEl, opts); } function renderErrorWithRetry(el, props) { $(el).css("color", "red"); $(el).html( /*html*/ ` ` ); $(el).find("span").text(`错误:${props.message}`); $(el).find("button").on("click", props.onRetry); return { el }; } async function processEpPage() { const el = $( /*html*/ `
    单集评分加载中…
    ` ); $("#columnEpA").prepend(el); processEpPageInternal({ el }); } async function processEpPageInternal(opts) { const resp = await Global.client.getEpisodeRatings(); if (resp[0] === "auth_required") throw new Error("unreachable"); if (resp[0] === "error") { const [_2, _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.updateCurrentEpisodeVisibilityFromServerRaw( ratingsData.my_rating?.visibility ); renderScoreboard(opts.el, { votesData }); const scoreChartEl = $("
    ").insertBefore("#columnEpA > .epDesc"); renderScoreChart(scoreChartEl, { votesData }); $( /*html*/ `
    ` ).insertAfter("#columnEpA > .epDesc"); const myRatingEl = $("
    ").insertAfter(".singleCommentList > .board"); if (!ratingsData.my_rating) { Global.token.setValue(null); } const ratedScore = new Watched( ratingsData.my_rating?.score ?? null ); renderMyRating(myRatingEl, { episodeID: Global.episodeID, ratedScore, isPrimary: true, canRefetchAfterAuth: true, votesData }); const userReplyMap = await collectUserReplyMap(); const myReplies = new Watched(collectMyReplies()); { const oldInsertFn = chiiLib.ajax_reply.insertJsonComments; chiiLib.ajax_reply.insertJsonComments = function(...args) { oldInsertFn.apply(this, args); myReplies.setValue(collectMyReplies()); }; } const currentVisibility = (() => { const watched = new Watched(null); function update() { if (!myReplies.getValueOnce().length) { watched.setValue(null); } else { watched.setValue( Global.currentEpisodeVisibilityFromServer.getValueOnce() ); } } myReplies.watchDeferred(update); Global.currentEpisodeVisibilityFromServer.watch(update); return watched; })(); const ratedScoreGeneric = ratedScore.createComputed((score) => score ?? NaN); myReplies.watch((myReplies2) => { processMyUnprocessedComments({ ratedScore: ratedScoreGeneric, currentVisibility, replies: myReplies2 }); }); const votersToScore = convertVotersByScoreToVotersToScore( ratingsData.public_ratings.public_voters_by_score ); processOtherPeoplesComments({ votersToScore, userReplyMap, myUserID: Global.claimedUserID }); const isVisibilityCheckboxRelevant = (() => { const watched = new Watched(true); function update() { watched.setValue( ratedScore.getValueOnce() !== null && currentVisibility.getValueOnce() === null ); } ratedScore.watchDeferred(update); currentVisibility.watch(update); return watched; })(); const visibilityCheckboxValue = new Watched(false, { shouldDeduplicateShallowly: true }); processReplyForm({ isVisibilityCheckboxRelevant, visibilityCheckboxValue, currentVisibility }); processReplysForm({ isVisibilityCheckboxRelevant, visibilityCheckboxValue, currentVisibility }); } function processReplyForm(opts) { const el = $("#ReplyForm"); const submitButtonEl = $(el).find("#submitBtnO"); const controlEl = $("
    ").insertBefore(submitButtonEl); renderReplyFormVisibilityControl(controlEl, opts); $(el.on("submit", async () => { changeVisibilityIfNecessary({ isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(), currentVisibility: opts.currentVisibility.getValueOnce(), changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() } }); })); } function processReplysForm(opts) { const unmountFns = []; const oldSubReplyFn = (window.unsafeWindow ?? window).subReply; (window.unsafeWindow ?? window).subReply = function(...args) { oldSubReplyFn(...args); const el = $("#ReplysForm"); const submitButtonEl = $(el).find("#submitBtnO"); const controlEl = $("
    ").insertBefore(submitButtonEl); const { unmount: unmountFn } = ( // renderReplyFormVisibilityControl(controlEl, opts) ); unmountFns.push(unmountFn); $(el.on("submit", async () => { unmountFns.forEach((fn) => fn()); await changeVisibilityIfNecessary({ isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(), currentVisibility: opts.currentVisibility.getValueOnce(), changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() } }); })); }; const oldSubReplycancelFn = (window.unsafeWindow ?? window).subReplycancel; (window.unsafeWindow ?? window).subReplycancel = function(...args) { unmountFns.forEach((fn) => fn()); oldSubReplycancelFn(...args); }; } async function changeVisibilityIfNecessary(opts) { if (!opts.isRelevant) return; if (opts.currentVisibility?.isVisible === opts.changedVisibility.isVisible) { return; } const result = await Global.client.changeUserEpisodeRatingVisibility({ isVisible: opts.changedVisibility.isVisible }); if (result[0] === "auth_required") { Global.token.setValue(null); } else if (result[0] === "error") { console.warn( "单集评分组件", "`changeUserEpisodeRatingVisibility`", result ); } else if (result[0] === "ok") { Global.updateCurrentEpisodeVisibilityFromServerRaw(result[1]); } else ; } function processMyUnprocessedComments(opts) { for (const reply of opts.replies) { const el = $(reply.el); if (el.hasClass("__bgm_ep_ratings__processed")) continue; el.addClass("__bgm_ep_ratings__processed"); const myRatingInCommentEl = $("
    ").insertBefore( $(el).find(".inner > .reply_content,.cmt_sub_content").eq(0) ); renderMyRatingInComment(myRatingInCommentEl, opts); } } function processOtherPeoplesComments(opts) { for (const [voterUserID_, score] of Object.entries(opts.votersToScore)) { const voterUserID = Number(voterUserID_); if (voterUserID === opts.myUserID) continue; for (const reply of opts.userReplyMap[voterUserID] ?? []) { const el = $(reply.el); const smallStarsEl = $("
    ").insertBefore( $(el).find(".inner > .reply_content,.cmt_sub_content").eq(0) ); renderSmallStars(smallStarsEl, { score: new Watched(score), shouldShowNumber: false }); } } } async function collectUserReplyMap() { const replies = await collectReplies(); const output = {}; for (const reply of replies) { (output[reply.userID] ??= []).push(reply); } return output; } async function collectReplies() { let output = []; let timeStart = performance.now(); for (const el of document.querySelectorAll('[id^="post_"]')) { const isSubReply = isElementSubReply(el); const replyOnClickText = ( // $(el).find("a:has(> span.ico_reply)").eq(0).attr("onclick") ); if (!replyOnClickText) { continue; } const args = /\((.*)\)/.exec(replyOnClickText)[1].split(",").map((arg) => arg.trim()); const userID = Number(isSubReply ? args.at(-3) : args.at(-2)); output.push({ el, isSubReply, userID }); if (performance.now() - timeStart >= 10) { await new Promise((resolve) => setTimeout(resolve, 0)); timeStart = performance.now(); } } return output; } function collectMyReplies() { if (!Global.token.getValueOnce()) return []; const myTextUserID = new URL($("#headerNeue2 .idBadgerNeue > .avatar").attr("href")).pathname.split("/").filter(Boolean).at(-1); return $(`[id^="post_"]:has(> a.avatar[href$="/${myTextUserID}"])`).map((_, el) => ({ el, isSubReply: isElementSubReply(el) })).toArray(); } function isElementSubReply(el) { return !!$(el).closest(".topic_sub_reply").length; } function convertVotersByScoreToVotersToScore(votersByScore) { const output = {}; for (const [score, voters] of Object.entries(votersByScore)) { for (const voter of voters) { output[voter] = Number(score); } } return output; } function renderRateInfo(el, props) { el = $( /*html*/ `
    ` ).replaceAll(el); const rateInfoEl = el.find(".rateInfo"); const smallStarsEl = el.find('[data-sel="small-stars"]'); const buttonEl = el.find("button"); const score = props.votesData.createComputed( (votesData) => votesData.averageScore ); renderSmallStars(smallStarsEl, { score, shouldShowNumber: true }); props.votesData.watch((votesData) => { $(el).find(".tip_j").text(`(${votesData.totalVotes}人评分)`); }); buttonEl.on("click", () => { rateInfoEl.css("display", ""); buttonEl.css("display", "none"); props.onReveal?.(); }); props.requiresClickToReveal.watch((requiresClickToReveal) => { if (requiresClickToReveal) { rateInfoEl.css("display", "none"); buttonEl.css("display", ""); } else { rateInfoEl.css("display", ""); buttonEl.css("display", "none"); } }); } let isNavigatingAway = false; $(window).on("beforeunload", () => { isNavigatingAway = true; }); function processCluetip() { 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.client.hasCachedSubjectEpisodesRatings(opts.subjectID)) { await new Promise((resolve) => setTimeout(resolve, 250)); if (isNavigatingAway || currentCounter !== counter || !popupEl.is(":visible")) { return; } } const loadingEl = $( /*html*/ `
    单集评分加载中…
    ` ).insertBefore( // `:first` 用于兼容 https://bangumi.tv/dev/app/3265。 $(popupEl).find(".tip .board:first") ); updateInternal({ ...opts, currentCounter, loadingEl, popupEl }); } async function updateInternal(opts) { const resp = await Global.client.getSubjectEpisodesRatings({ subjectID: opts.subjectID }); if (resp[0] === "error") { const [_2, _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( // `:first` 用于兼容 https://bangumi.tv/dev/app/3265。 $(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( (_2, epStatusEl) => { if (epStatusEl.id.startsWith("Watched")) { $(epStatusEl).on("click", () => revealScore()); } } ); } return { update }; } async function processRootPage() { const { update: updateCluetip } = processCluetip(); let isMouseOver = false; $("ul.prg_list > li").each((_, liEl) => { if (!$(liEl).find(".load-epinfo").length) return; $(liEl).on("mouseover", () => { if (isMouseOver) return; isMouseOver = true; const aEl = $(liEl).find("a"); const subjectID = Number($(aEl).attr("subject_id")); const episodeID = (() => { const href = $(aEl).attr("href"); const match = href.match(/^\/ep\/(\d+)/); return Number(match[1]); })(); updateCluetip({ subjectID, episodeID, hasUserWatched: aEl.hasClass("epBtnWatched") }); }).on("mouseout", () => { isMouseOver = false; }); }); } async function processSubjectPage() { const { update: updateCluetip } = processCluetip(); let isMouseOver = false; $("ul.prg_list > li").each((_, liEl) => { if (!$(liEl).find(".load-epinfo").length) return; $(liEl).on("mouseover", () => { if (isMouseOver) return; isMouseOver = true; const aEl = $(liEl).find("a"); const episodeID = (() => { const href = $(aEl).attr("href"); const match = href.match(/^\/ep\/(\d+)/); return Number(match[1]); })(); updateCluetip({ subjectID: Global.subjectID, episodeID, hasUserWatched: aEl.hasClass("epBtnWatched") }); }).on("mouseout", () => { isMouseOver = false; }); }); } async function processSubjectEpListPage() { const editEpBatchEl = $('[name="edit_ep_batch"]'); let loadingEl = null; $(editEpBatchEl).find("li").each((_, li) => { if (!$(li).find('[name="ep_mod[]"]').length) return; $( /*html*/ `
    ` ).insertAfter($(li).find("h6")); loadingEl = $( /*html*/ `
    单集评分加载中…
    ` ).appendTo(li); return false; }); if (loadingEl) { processSubjectEpListPageInternal({ loadingEl, editEpBatchEl }); } } async function processSubjectEpListPageInternal(opts) { const resp = await Global.client.getSubjectEpisodesRatings({ subjectID: Global.subjectID }); if (resp[0] === "error") { const [_2, _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.token.setValue(null); } let isFirst_ = true; $(opts.editEpBatchEl).find("li").each((_2, li) => { if (!$(li).find('[name="ep_mod[]"]').length) return; const isFirst = isFirst_; isFirst_ = false; if (!isFirst) { $( /*html*/ `
    ` ).insertAfter($(li).find("h6")); } const episodeID = (() => { const href = $(li).find("> h6 > a").attr("href"); const match = /\/ep\/(\d+)/.exec(href); return Number(match[1]); })(); const ratings = epsRatings.episodes_votes[episodeID]; if (!epsRatings.is_certain_that_episodes_votes_are_integral && ratings === void 0) { return; } const votesData = new Watched( new VotesData(ratings ?? {}) ); const myRating = epsRatings.my_ratings?.[episodeID]; const hasUserWatched = $(li).find(".statusWatched").length || // 在 “看过” 之类不能修改章节观看状态的情况下,没法确认用户是否看过,但至 // 少可以假设用户在给了某集评分的时候是看过那一集的。 myRating !== void 0; const myRatingEl = $("
    "); $(li).append(myRatingEl); renderMyRating(myRatingEl, { episodeID, ratedScore: new Watched(myRating ?? null), isPrimary: isFirst, canRefetchAfterAuth: false, votesData }); const rateInfoEl = $("
    "); $(li).append(rateInfoEl); renderRateInfo(rateInfoEl, { votesData, requiresClickToReveal: ( // new Watched(!hasUserWatched && !!votesData.getValueOnce().totalVotes) ) }); $(li).append($( /*html*/ `
    ` )); }); } function migrate() { const tokenInWrongPlace = localStorage.getItem("bgm_test_app_token"); if (tokenInWrongPlace) { localStorage.setItem(env.LOCAL_STORAGE_KEY_TOKEN, tokenInWrongPlace); localStorage.removeItem("bgm_test_app_token"); } const searchParams = new URLSearchParams(window.location.search); const tokenCouponInWrongPlace = searchParams.get("bgm_test_app_token_coupon"); if (tokenCouponInWrongPlace) { searchParams.set( env.SEARCH_PARAMS_KEY_TOKEN_COUPON, tokenCouponInWrongPlace ); searchParams.delete("bgm_test_app_token_coupon"); let newURL = `${window.location.pathname}?${searchParams.toString()}`; window.history.replaceState(null, "", newURL); } } async function main() { const isInUserScriptRuntime = typeof GM_info !== "undefined"; if ($('meta[name="__bgm_ep_ratings__initialized"]').length) { console.warn( "检测到本脚本/超合金组件(单集评分 by Umajho A.K.A. um)先前已经初始化过,本实例将不会继续运行。", { version: Global.version, isInUserScriptRuntime } ); return; } $('').appendTo("head"); const searchParams = new URLSearchParams(window.location.search); const tokenCoupon = searchParams.get(env.SEARCH_PARAMS_KEY_TOKEN_COUPON); if (tokenCoupon) { searchParams.delete(env.SEARCH_PARAMS_KEY_TOKEN_COUPON); let newURL = `${window.location.pathname}`; if (searchParams.size) { newURL += `?${searchParams.toString()}`; } window.history.replaceState(null, "", newURL); const resp = await Global.client.redeemTokenCoupon(tokenCoupon); if (resp[0] === "ok") { Global.token.setValue(resp[1]); } else if (resp[0] === "error") { window.alert(`获取 token 失败:${resp[2]} (${resp[1]})`); } else ; 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(); })();