Greasy Fork

来自缓存

Greasy Fork is available in English.

bangumi-episode-ratings-gadget

Bangumi 单集评分的超合金组件

当前为 2024-08-27 提交的版本,查看 最新版本

// ==UserScript==
// @namespace   https://github.com/umajho
// @name        bangumi-episode-ratings-gadget
// @version     0.2.4
// @description Bangumi 单集评分的超合金组件
// @license     MIT
// @website     https://github.com/umajho/bangumi-episode-ratings
// @match       https://bangumi.tv/*
// @match       https://bgm.tv/*
// @match       https://chii.in/*
// @grant       GM_info
// @grant       unsafeWindow
// @grant       window.close
// ==/UserScript==

(function() {
  "use strict";
  const env = {
    get APP_ENTRYPOINT() {
      return "https://bgm-ep-ratings.deno.dev/";
    },
    LOCAL_STORAGE_KEY_TOKEN: "bgm_ep_ratings_token",
    SEARCH_PARAMS_KEY_TOKEN_COUPON: "bgm_ep_ratings_token_coupon"
  };
  const version = "0.2.4";
  const ENDPOINT_PATHS = {
    AUTH: {
      BANGUMI_PAGE: "bangumi-page",
      CALLBACK: "callback",
      REDEEM_TOKEN_COUPON: "redeem-token-coupon"
    },
    API: {
      V0: {
        RATE_EPISODE: "rate-episode",
        SUBJECT_EPISODES_RATINGS: "subject-episodes-ratings",
        EPISODE_RATINGS: "episode-ratings",
        MY_EPISODE_RATING: "my-episode-rating"
      }
    }
  };
  class Client {
    constructor(opts) {
      this.subjectEpisodesRatingsCache = {};
      this.entrypoint = opts.entrypoint;
      this.token = opts.token;
    }
    get URL_AUTH_BANGUMI_PAGE() {
      const url = (
        //
        new URL(this.buildFullEndpoint("auth", ENDPOINT_PATHS.AUTH.BANGUMI_PAGE))
      );
      url.searchParams.set("gadget_version", Global.version);
      return url.toString();
    }
    async rateEpisode(opts) {
      if (!this.token) return ["auth_required"];
      const bodyData = {
        claimed_user_id: opts.userID,
        subject_id: opts.subjectID,
        episode_id: opts.episodeID,
        score: opts.score
      };
      return await this.fetch(
        "api/v0",
        ENDPOINT_PATHS.API.V0.RATE_EPISODE,
        {
          method: "POST",
          body: JSON.stringify(bodyData)
        }
      );
    }
    async mustGetSubjectEpisodesRatings(opts) {
      if (this.subjectEpisodesRatingsCache[opts.subjectID]) {
        return this.subjectEpisodesRatingsCache[opts.subjectID];
      }
      const searchParams = new URLSearchParams();
      if (Global.claimedUserID) {
        searchParams.set("claimed_user_id", String(Global.claimedUserID));
        searchParams.set("subject_id", String(opts.subjectID));
      }
      return this.subjectEpisodesRatingsCache[opts.subjectID] = this.fetch(
        "api/v0",
        ENDPOINT_PATHS.API.V0.SUBJECT_EPISODES_RATINGS,
        { method: "GET", searchParams }
      ).then(
        (resp) => this.subjectEpisodesRatingsCache[opts.subjectID] = unwrap(resp)
      );
    }
    async mustGetEpisodeRatings() {
      const searchParams = new URLSearchParams();
      if (Global.claimedUserID) {
        searchParams.set("claimed_user_id", String(Global.claimedUserID));
        searchParams.set("subject_id", String(Global.subjectID));
        searchParams.set("episode_id", String(Global.episodeID));
      }
      const resp = await this.fetch(
        "api/v0",
        ENDPOINT_PATHS.API.V0.EPISODE_RATINGS,
        {
          method: "GET",
          searchParams
        }
      );
      return unwrap(resp);
    }
    async getMyEpisodeRating() {
      const searchParams = new URLSearchParams();
      if (Global.claimedUserID) {
        searchParams.set("claimed_user_id", String(Global.claimedUserID));
        searchParams.set("subject_id", String(Global.subjectID));
        searchParams.set("episode_id", String(Global.episodeID));
      }
      return await this.fetch(
        "api/v0",
        ENDPOINT_PATHS.API.V0.MY_EPISODE_RATING,
        {
          method: "GET",
          searchParams
        }
      );
    }
    async fetch(group, endpointPath, opts) {
      const url = new URL(this.buildFullEndpoint(group, endpointPath));
      if (opts.searchParams) {
        url.search = opts.searchParams.toString();
      }
      const headers = new Headers();
      if (this.token) {
        headers.set("Authorization", `Basic ${this.token}`);
      }
      headers.set("X-Gadget-Version", Global.version);
      try {
        const resp = await fetch(url, {
          method: opts.method,
          headers,
          body: opts.body
        });
        const respJSON = await resp.json();
        if (respJSON[0] === "error" && respJSON[1] === "AUTH_REQUIRED") {
          if (Global.token.getValueOnce() !== null) {
            Global.token.setValue(null);
          }
          return ["auth_required"];
        }
        return respJSON;
      } catch (e) {
        const operation = `fetch \`${opts.method} ${url}\``;
        console.error(`${operation} \u5931\u8D25`, e);
        return ["error", "UNKNOWN", `${operation} \u5931\u8D25\uFF1A ${e}`];
      }
    }
    buildFullEndpoint(group, endpointPath) {
      return join(join(this.entrypoint, group + "/"), endpointPath);
    }
    async mustRedeemTokenCoupon(tokenCoupon) {
      const data = await this.fetch(
        "auth",
        ENDPOINT_PATHS.AUTH.REDEEM_TOKEN_COUPON,
        {
          method: "POST",
          body: JSON.stringify({ tokenCoupon })
        }
      );
      return unwrap(data);
    }
    clearCache() {
      this.subjectEpisodesRatingsCache = {};
    }
  }
  function join(base, url) {
    return new URL(url, base).href;
  }
  function unwrap(resp) {
    if (!Array.isArray(resp) || resp[0] !== "ok" && resp[0] !== "error") {
      console.error("Unsupported response", resp);
      throw new Error(`Unsupported response: ${JSON.stringify(resp)}`);
    }
    if (resp[0] === "error") throw new Error(resp[2]);
    return resp[1];
  }
  class Watched {
    constructor(_value) {
      this._value = _value;
      this._watchers = [];
    }
    getValueOnce() {
      return this._value;
    }
    setValue(newValue) {
      const oldValue = this._value;
      this._value = newValue;
      this._watchers.forEach((w) => w(newValue, oldValue));
    }
    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);
    }
  }
  const global = {};
  const Global = global;
  function initializeGlobal() {
    Object.assign(global, makeGlobal());
  }
  function makeGlobal() {
    const { subjectID, episodeID } = (() => {
      let subjectID2 = null;
      let episodeID2 = null;
      const pathParts = window.location.pathname.split("/").filter(Boolean);
      if (pathParts[0] === "subject") {
        subjectID2 = Number(pathParts[1]);
      } else if (pathParts.length === 2 && pathParts[0] === "ep") {
        episodeID2 = Number(pathParts[1]);
        const subjectHref = $("#headerSubject > .nameSingle > a").attr("href");
        subjectID2 = Number(subjectHref.split("/")[2]);
      }
      return { subjectID: subjectID2, episodeID: episodeID2 };
    })();
    const claimedUserID = (() => {
      if ("unsafeWindow" in window) {
        return window.unsafeWindow.CHOBITS_UID || null;
      }
      return window.CHOBITS_UID || null;
    })();
    if (claimedUserID === null) {
      localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN);
    }
    const token = new Watched(
      localStorage.getItem(env.LOCAL_STORAGE_KEY_TOKEN)
    );
    window.addEventListener("storage", (ev) => {
      if (ev.key !== env.LOCAL_STORAGE_KEY_TOKEN) return;
      if (ev.newValue === token.getValueOnce()) return;
      token.setValue(ev.newValue);
    });
    const client = new Client({
      entrypoint: env.APP_ENTRYPOINT,
      token: token.getValueOnce()
    });
    token.watchDeferred((newToken) => {
      if (newToken) {
        localStorage.setItem(env.LOCAL_STORAGE_KEY_TOKEN, newToken);
      } else {
        localStorage.removeItem(env.LOCAL_STORAGE_KEY_TOKEN);
      }
      client.token = newToken;
      client.clearCache();
    });
    return {
      version,
      subjectID,
      episodeID,
      claimedUserID,
      token,
      client
    };
  }
  const scores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  function describeScore(score) {
    return [
      [9.5, "\u8D85\u795E\u4F5C"],
      [8.5, "\u795E\u4F5C"],
      [7.5, "\u529B\u8350"],
      [6.5, "\u63A8\u8350"],
      [5.5, "\u8FD8\u884C"],
      [4.5, "\u4E0D\u8FC7\u4E0D\u5931"],
      [3.5, "\u8F83\u5DEE"],
      [2.5, "\u5DEE"],
      [1.5, "\u5F88\u5DEE"]
    ].find(([min, _]) => score >= min)?.[1] ?? "\u4E0D\u5FCD\u76F4\u89C6";
  }
  function describeScoreEx(score) {
    let description = `${describeScore(score)} ${score}`;
    if (score === 1 || score === 10) {
      description += " (\u8BF7\u8C28\u614E\u8BC4\u4EF7)";
    }
    return description;
  }
  function renderStars(el, props) {
    el = $(
      /*html*/
      `
    <div class="stars-container">
      <div class="rating-cancel"><a title="Cancel Rating"></a></div>
    </div>
  `
    ).replaceAll(el);
    for (const score of scores) {
      const starEl = $(
        /*html*/
        `
      <div class="star-rating">
        <a></a>
      </div>
    `
      );
      el.append(starEl);
      const aEl = starEl.find("a");
      aEl.text(score);
      aEl.attr("title", describeScoreEx(score));
      starEl.on("mouseover", () => props.hoveredScore.setValue(score)).on("mouseout", () => props.hoveredScore.setValue(null)).on("click", () => props.onRateEpisode(score));
    }
    function updateStarsContainer(params) {
      if (params[0] === "invisible") {
        el.css("display", "none");
        return;
      }
      el.css("display", "");
      const [_, { ratedScore, hoveredScore }] = params;
      const isHovering = hoveredScore !== null;
      const maxScoreToHighlight = hoveredScore ?? ratedScore ?? null;
      {
        let alarmScore = maxScoreToHighlight;
        if (alarmScore === "cancel") {
          alarmScore = ratedScore;
        }
        props.onUpdateScoreToAlarm(alarmScore);
      }
      const starEls = el.find(".star-rating");
      for (const score of scores) {
        const starEl = starEls.eq(score - 1);
        starEl.removeClass("star-rating-on").removeClass("star-rating-hover");
        if (typeof maxScoreToHighlight === "number" && score <= maxScoreToHighlight) {
          starEl.addClass(isHovering ? "star-rating-hover" : "star-rating-on");
        }
      }
      $(el).find(".rating-cancel").removeClass("star-rating-hover");
      if (hoveredScore === "cancel") {
        $(el).find(".rating-cancel").addClass("star-rating-hover");
      }
    }
    return { updateStarsContainer };
  }
  function renderMyRating(el, props) {
    const ratedScore = new Watched(props.ratedScore);
    const hoveredScore = new Watched(null);
    el = $(
      /*html*/
      `
    <div style="float: right; display: flex; flex-direction: column;">
      <p style="font-size: 12px;">\u6211\u7684\u8BC4\u4EF7:
        <span class="alarm"></span>
      </p>
      <div class="stars-container"></div>
      <div class="message"></div>
    </div>
  `
    ).replaceAll(el);
    const starsContainerEl = el.find(".stars-container");
    const { updateStarsContainer } = renderStars(starsContainerEl, {
      hoveredScore,
      onRateEpisode: rateEpisode,
      onUpdateScoreToAlarm: (score) => {
        if (score !== null) {
          $(el).find(".alarm").text(describeScoreEx(score));
        } else {
          $(el).find(".alarm").text("");
        }
      }
    });
    $(el).find(".rating-cancel").on("mouseover", () => hoveredScore.setValue("cancel")).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(null));
    ratedScore.watchDeferred(
      (ratedScore2) => updateStarsContainer(["normal", {
        ratedScore: ratedScore2,
        hoveredScore: hoveredScore.getValueOnce()
      }])
    );
    hoveredScore.watch((hoveredScore2) => {
      updateStarsContainer(["normal", {
        ratedScore: 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("\u5904\u7406\u4E2D\u2026");
          messageEl.css("color", "gray");
          break;
        }
        case "loading": {
          messageEl.text("\u52A0\u8F7D\u4E2D\u2026");
          messageEl.css("color", "gray");
          break;
        }
        case "error": {
          messageEl.text(value[1]);
          messageEl.css("color", "red");
          break;
        }
        case "auth_link": {
          messageEl.html(
            /*html*/
            `
          \u82E5\u8981\u67E5\u770B\u6216\u63D0\u4EA4\u81EA\u5DF1\u7684\u5355\u96C6\u8BC4\u5206\uFF0C
          <br >
          \u8BF7\u5148<a class="l" target="_blank">\u6388\u6743\u6B64\u5E94\u7528</a>\u3002
          <br >
          \u5355\u96C6\u8BC4\u5206\u5E94\u7528\u9700\u8981\u4EE5\u6B64\u6765\u786E\u8BA4\u767B\u5F55\u8005\u3002
        `
          );
          $(messageEl).find("a").attr(
            "href",
            Global.client.URL_AUTH_BANGUMI_PAGE
          );
          break;
        }
        case "requiring_fetch": {
          if (props.canRefetchAfterAuth) {
            messageEl.html(
              /*html*/
              `
            \u70B9\u51FB<button class="l">\u6B64\u5904</button>\u6216\u5237\u65B0\u672C\u9875\u4EE5\u83B7\u53D6\u3002 
          `
            );
            $(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"]);
                ratedScore.setValue(data.score);
              } else ;
            });
          } else {
            messageEl.text("\u8BF7\u5237\u65B0\u672C\u9875\u4EE5\u83B7\u53D6\u3002 ");
          }
          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({
        userID: Global.claimedUserID,
        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"]);
        ratedScore.setValue(data.score);
      } else ;
    }
  }
  function renderScoreboard(el, props) {
    el = $(
      /*html*/
      `
    <div class="global_score" style="float: right;">
      <span class="description"></span>
      <span class="number"></span>
      <div>
        <small class="grey" style="float: right;">\u5355\u96C6\u8BC4\u5206</small>
      </div>
    </div>
  `
    ).replaceAll(el);
    function updateNumber(score) {
      if (Number.isNaN(score)) {
        $(el).find(".number").text(0 .toFixed(1));
        $(el).find(".description").text("--");
      } else {
        $(el).find(".number").text(score.toFixed(4));
        $(el).find(".description").text(describeScore(score));
      }
    }
    updateNumber(props.score);
  }
  function renderScoreChart(el, props) {
    const { votesData } = props;
    el = $(
      /*html*/
      `
    <div id="ChartWarpper" class="chartWrapper" style="float: right; width: 218px;">
      <div class="chart_desc"><small class="grey"><span class="votes"></span> votes</small></div>
      <ul class="horizontalChart">
        <div class="tooltip fade top in" role="tooltip" style="top: -34px; transform: translateX(-50%);">
          <div class="tooltip-arrow" style="left: 50%;"></div>
          <div class="tooltip-inner"></div>
        </div>
      </ul>
    </div>
  `
    ).replaceAll(el);
    $(el).find(".votes").text(votesData.totalVotes);
    const chartEl = el.find(".horizontalChart");
    const totalVotes = votesData.totalVotes;
    const votesOfMostVotedScore = votesData.votesOfMostVotedScore;
    for (const score of scores) {
      const votes = votesData.getScoreVotes(score);
      const barEl = $("<div />");
      chartEl.prepend(barEl);
      renderBar(barEl, {
        score,
        votes,
        totalVotes,
        votesOfMostVotedScore,
        updateTooltip
      });
    }
    function updateTooltip(props2) {
      let tooltipEl = $(chartEl).find(".tooltip");
      if (props2.score === null) {
        tooltipEl.css("display", "none");
        return;
      }
      tooltipEl.css("display", "block");
      const barEl = $(chartEl).find(`li`).eq(10 - props2.score);
      const barElRelativeOffsetLeft = barEl.offset().left - el.offset().left;
      tooltipEl.css("left", `${barElRelativeOffsetLeft + barEl.width() / 2}px`);
      let scoreVotes = votesData.getScoreVotes(props2.score);
      const percentage = votesData.totalVotes ? scoreVotes / votesData.totalVotes * 100 : 0;
      $(tooltipEl).find(".tooltip-inner").text(
        `${percentage.toFixed(2)}% (${scoreVotes}\u4EBA)`
      );
    }
    updateTooltip({ score: null });
    return el;
  }
  function renderBar(el, props) {
    el = $(
      /*html*/
      `
    <li><a class="textTip"><span class="label"></span><span class="count"></span></a></li>
  `
    ).replaceAll(el);
    const percentage = (props.votes / props.totalVotes * 100).toFixed(2);
    $(el).find(".textTip").attr(
      "data-original-title",
      `${percentage}% (${props.votes}\u4EBA)`
    );
    $(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 }));
  }
  class VotesData {
    constructor(data) {
      this.data = data;
      this.totalVotesCache = null;
      this.averageScoreCache = null;
      this.mostVotedScoreCache = null;
    }
    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);
    }
  }
  async function processEpPage() {
    const scoreboardEl = $(
      /*html*/
      `
    <div class="grey" style="float: right;">
      \u5355\u96C6\u8BC4\u5206\u52A0\u8F7D\u4E2D\u2026
    </div>
  `
    );
    $("#columnEpA").prepend(scoreboardEl);
    const ratingsData = await Global.client.mustGetEpisodeRatings();
    const votesData = new VotesData(
      ratingsData.votes
    );
    renderScoreboard(scoreboardEl, { score: votesData.averageScore });
    const scoreChartEl = $("<div />").insertBefore("#columnEpA > .epDesc");
    renderScoreChart(scoreChartEl, { votesData });
    $(
      /*html*/
      `<div class="clear" />`
    ).insertAfter("#columnEpA > .epDesc");
    const myRatingEl = $("<div />").insertAfter(".singleCommentList > .board");
    if (!ratingsData.my_rating) {
      Global.token.setValue(null);
    }
    renderMyRating(myRatingEl, {
      episodeID: Global.episodeID,
      ratedScore: ratingsData.my_rating?.score ?? null,
      isPrimary: true,
      canRefetchAfterAuth: true
    });
  }
  function renderRateInfo(el, props) {
    el = $(
      /*html*/
      `
    <div>
      <p class="rateInfo" style="display: none;">
        <span class="starstop-s">
          <span class="maybe-starlight starlight"></span>
          </span> <small class="fade"></small> <span class="tip_j">
        </span> 
      </p>
      <button type="button" style="display: none;">\u663E\u793A\u8BC4\u5206</button>
    </div>
  `
    ).replaceAll(el);
    const rateInfoEl = el.find(".rateInfo");
    const buttonEl = el.find("button");
    props.votesData.watch((votesData) => {
      if (!votesData) {
        el.css("display", "none");
        return;
      }
      el.css("display", "");
      const score = votesData.averageScore;
      if (Number.isNaN(score)) {
        $(rateInfoEl).find(".maybe-starlight").removeClass().addClass("maybe-starlight");
        $(rateInfoEl).find("small.fade").text("--");
      } else {
        $(rateInfoEl).find(".maybe-starlight").addClass("starlight").addClass(`stars${Math.round(score)}`);
        $(rateInfoEl).find("small.fade").text(score.toFixed(4));
      }
      $(rateInfoEl).find(".tip_j").text(`(${votesData.totalVotes}\u4EBA\u8BC4\u5206)`);
    });
    buttonEl.on("click", () => {
      console.log("111");
      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");
      }
    });
  }
  function processCluetip() {
    const el = $("#cluetip");
    let counter = 0;
    const revealed = {};
    async function update(opts) {
      const popupEl = $(el).find(".prg_popup");
      if (popupEl.attr("data-bgm-ep-ratings-initialized")) return;
      popupEl.attr("data-bgm-ep-ratings-initialized", "true");
      counter++;
      const currentCounter = counter;
      const loadingEl = $(
        /*html*/
        `
      <div class="grey">
        \u5355\u96C6\u8BC4\u5206\u52A0\u8F7D\u4E2D\u2026
      </div>
    `
      ).insertBefore($(popupEl).find(".tip .board"));
      const epsRatings = await Global.client.mustGetSubjectEpisodesRatings({
        subjectID: opts.subjectID
      });
      loadingEl.remove();
      if (currentCounter !== counter) return;
      const votesData = new Watched(
        new VotesData(
          epsRatings.episodes_votes[opts.episodeID] ?? {}
        )
      );
      const requiresClickToReveal = new Watched(false);
      requiresClickToReveal.setValue(
        !(opts.hasUserWatched || revealed[`${opts.subjectID}:${opts.episodeID}`] || !votesData.getValueOnce().totalVotes)
      );
      function revealScore() {
        revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
        requiresClickToReveal.setValue(false);
      }
      const rateInfoEl = $("<div />").insertBefore($(popupEl).find(".tip .board"));
      renderRateInfo(rateInfoEl, {
        votesData,
        requiresClickToReveal,
        onReveal: () => {
          revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
        }
      });
      $(popupEl).find(".epStatusTool > a.ep_status").each((_, epStatusEl) => {
        if (epStatusEl.id.startsWith("Watched")) {
          $(epStatusEl).on("click", () => revealScore());
        }
      });
    }
    return { update };
  }
  async function processRootPage() {
    const { update: updateCluetip } = processCluetip();
    let isMouseOver = false;
    $("ul.prg_list > li").each((_, liEl) => {
      $(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) => {
      $(liEl).on("mouseover", () => {
        if (isMouseOver) return;
        isMouseOver = true;
        const aEl = $(liEl).find("a");
        const episodeID = (() => {
          const href = $(aEl).attr("href");
          const match = href.match(/^\/ep\/(\d+)/);
          return Number(match[1]);
        })();
        updateCluetip({
          subjectID: Global.subjectID,
          episodeID,
          hasUserWatched: aEl.hasClass("epBtnWatched")
        });
      }).on("mouseout", () => {
        isMouseOver = false;
      });
    });
  }
  async function processSubjectEpListPage() {
    const editEpBatchEl = $('[name="edit_ep_batch"]');
    let loadingEl;
    $(editEpBatchEl).find("li").each((_, li) => {
      if (!$(li).find('[name="ep_mod[]"]').length) return;
      $(
        /*html*/
        `<div class="clear"></div>`
      ).insertAfter($(li).find("h6"));
      loadingEl = $(
        /*html*/
        `
      <div class="__bgm-ep-ratings-loading grey" style="float: right;">
        \u5355\u96C6\u8BC4\u5206\u52A0\u8F7D\u4E2D\u2026
      </div>
    `
      );
      $(li).append(loadingEl);
      return false;
    });
    const epsRatings = await Global.client.mustGetSubjectEpisodesRatings({
      subjectID: Global.subjectID
    });
    if (loadingEl) {
      loadingEl.remove();
    }
    if (!epsRatings.my_ratings) {
      Global.token.setValue(null);
    }
    let isFirst_ = true;
    $(editEpBatchEl).find("li").each((_, li) => {
      if (!$(li).find('[name="ep_mod[]"]').length) return;
      const isFirst = isFirst_;
      isFirst_ = false;
      if (!isFirst) {
        $(
          /*html*/
          `<div class="clear"></div>`
        ).insertAfter($(li).find("h6"));
      }
      const episodeID = (() => {
        const href = $(li).find("> h6 > a").attr("href");
        const match = /\/ep\/(\d+)/.exec(href);
        return Number(match[1]);
      })();
      const ratings = epsRatings.episodes_votes[episodeID];
      if (!epsRatings.is_certain_that_episodes_votes_are_integral && ratings === void 0) {
        return;
      }
      const votesData = new VotesData(ratings ?? {});
      const myRating = epsRatings.my_ratings?.[episodeID];
      const hasUserWatched = $(li).find(".statusWatched").length || // 在 “看过” 之类不能修改章节观看状态的情况下,没法确认用户是否看过,但至
      // 少可以假设用户在给了某集评分的时候是看过那一集的。
      myRating !== void 0;
      const myRatingEl = $("<div />");
      $(li).append(myRatingEl);
      renderMyRating(myRatingEl, {
        episodeID,
        ratedScore: myRating ?? null,
        isPrimary: isFirst,
        canRefetchAfterAuth: false
      });
      const rateInfoEl = $("<div />");
      $(li).append(rateInfoEl);
      renderRateInfo(rateInfoEl, {
        votesData: new Watched(votesData),
        requiresClickToReveal: (
          //
          new Watched(!hasUserWatched && !!votesData.totalVotes)
        )
      });
      $(li).append($(
        /*html*/
        `<div class="clear"></div>`
      ));
    });
  }
  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(
        "\u68C0\u6D4B\u5230\u672C\u811A\u672C/\u8D85\u5408\u91D1\u7EC4\u4EF6\uFF08\u5355\u96C6\u8BC4\u5206 by Umajho A.K.A. um\uFF09\u5148\u524D\u5DF2\u7ECF\u521D\u59CB\u5316\u8FC7\uFF0C\u672C\u5B9E\u4F8B\u5C06\u4E0D\u4F1A\u7EE7\u7EED\u8FD0\u884C\u3002",
        { version: Global.version, isInUserScriptRuntime }
      );
      return;
    }
    $('<meta name="__bgm_ep_ratings__initialized" content="true">').appendTo("head");
    const searchParams = new URLSearchParams(window.location.search);
    const tokenCoupon = searchParams.get(env.SEARCH_PARAMS_KEY_TOKEN_COUPON);
    if (tokenCoupon) {
      searchParams.delete(env.SEARCH_PARAMS_KEY_TOKEN_COUPON);
      let newURL = `${window.location.pathname}`;
      if (searchParams.size) {
        newURL += `?${searchParams.toString()}`;
      }
      window.history.replaceState(null, "", newURL);
      Global.token.setValue(
        await Global.client.mustRedeemTokenCoupon(tokenCoupon)
      );
      window.close();
    }
    const pathParts = window.location.pathname.split("/").filter(Boolean);
    if (!pathParts.length) {
      await processRootPage();
    } else if (pathParts.length === 2 && pathParts[0] === "subject") {
      await processSubjectPage();
    } else if (pathParts.length === 3 && pathParts[0] === "subject" && pathParts[2] === "ep") {
      await processSubjectEpListPage();
    } else if (pathParts.length === 2 && pathParts[0] === "ep") {
      await processEpPage();
    }
  }
  migrate();
  initializeGlobal();
  main();
})();