コンテンツにスキップ

英文维基 | 中文维基 | 日文维基 | 草榴社区

利用者:Kohaku2005/DisplayUsersGroups.js

お知らせ: 保存した後、ブラウザのキャッシュをクリアしてページを再読み込みする必要があります。

多くの WindowsLinux のブラウザ

  • Ctrl を押しながら F5 を押す。

Mac における Safari

  • Shift を押しながら、更新ボタン をクリックする。

Mac における ChromeFirefox

  • Cmd Shift を押しながら R を押す。

詳細についてはWikipedia:キャッシュを消すをご覧ください。

// @ts-check

/**
 * @typedef {{
 *   get: (args: Record<string, any>) => Promise<any>
 * }} Api
 */

/**
 * @typedef {{
 *    Api: new () => Api,
 *    util: any
 * }} Mw
 */

/** @typedef {"*" | "bot" | "interface-admin" | "sysop" | "extendedconfirmed" | "confirmed" | "autoconfirmed" | "user" | "bureaucrat" | "eliminator" | "abusefilter" | "rollbacker" | "steward" | "accountcreator" | "checkuser" | "import" | "transwiki" | "push-subscription-manager" | "ipblock-exempt" | "suppress"} WpGroups */

/**
 * @typedef {{
 *   name: string
 * } & (({
 *   userid: number,
 *   groups: WpGroups[],
 *   missing?: false,
 *   invalid?: false
 * } & ({
 *   blockanononly: boolean,
 *   blockedby: string,
 *   blockedbyid: number,
 *   blockedtimestamp: string,
 *   blockedtimestampformatted: string,
 *   blockexpiry: string,
 *   blockid: number,
 *   blocknocreate: boolean,
 *   blockpartial: boolean,
 *   blockreason: string
 * } | {
 *   blockanononly: undefined
 *   blockedby: undefined
 *   blockedbyid: undefined
 *   blockedtimestamp: undefined
 *   blockedtimestampformatted: undefined
 *   blockexpiry: undefined
 *   blockid: undefined
 *   blocknocreate: undefined
 *   blockpartial: undefined
 *   blockreason: undefined
 * })) | {
 *   missing: true,
 *   invalid?: false
 * } | {
 *   invalid: true,
 *   missing?: false
 * })} UsersApiResult
 */

/**
 * @typedef {{
 *   user: string,
 *   expiry: string,
 *   reason: string
 * }} BlocksApiResult
 */

/**
 * @typedef {{
 *   query: {
 *     users?: UsersApiResult[],
 *     blocks?: BlocksApiResult[]
 *   };
 * }} UserApiResult
 */

/**
 * @typedef {{
 *   parse: {
 *     text: string
 *   }
 * }} ParseApiResult
 */

/**
 * @typedef {{
 *   query: {
 *     pages: {
 *       pageid: number,
 *       ns: number,
 *       title: string
 *     }[],
 *     redirects?: {
 *       from: string,
 *       to: string
 *     }[]
 *   }
 * }} RedirectsApiResult
 */

/**
 * @typedef {{
 *   skip?: WpGroups[],
 *   labels: {
 *     group: WpGroups,
 *     label: string,
 *     text: string
 *   }[]
 * }[]} LabelGroups
 */

/**
 * @typedef {{
 *   label: string;
 *   isWikitext?: boolean;
 *   text: string;
 * }} Label
 */

(/**
 * 
 * @param {Mw} mw
 */
async (mw) => {
  //#region user link query
  const USER_LINK_QUERY = '#content a:is([href^="/wiki/%E5%88%A9%E7%94%A8%E8%80%85:"], [href^="/wiki/利用者:"], [href^="/w/index.php"], [href^="/wiki/%E7%89%B9%E5%88%A5:%E6%8A%95%E7%A8%BF%E8%A8%98%E9%8C%B2/"], [href^="/wiki/特別:投稿記録/"])';
  //#endregion
  
  //#region user name regexp
  const USER_NAME_REGEXP = /(?:\/w\/index.php(?:\?|.*&)title=|\/wiki\/)(?:(?:%E5%88%A9%E7%94%A8%E8%80%85|利用者):|(%E7%89%B9%E5%88%A5:%E6%8A%95%E7%A8%BF%E8%A8%98%E9%8C%B2|特別:投稿記録)\/)([^?&#]*)/;
  //#endregion
  
  //#region ip regexp
  const IP_REGEXP = /^((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
  //#endregion
  
  //#region label groups
  /**
   * @type {LabelGroups}
   */
  const LABEL_GROUPS = [
    {
      skip: ["bot", "interface-admin", "sysop"],
      labels: [
        {
          group: "extendedconfirmed",
          label: "拡",
          text: "拡張承認された利用者"
        },
        {
          group: "confirmed",
          label: "承",
          text: "承認された利用者"
        },
        {
          group: "autoconfirmed",
          label: "自",
          text: "自動承認された利用者"
        },
        {
          group: "user",
          label: "新",
          text: "新規利用者"
        }
      ]
    },
    {
      labels: [
        {
          group: "sysop",
          label: "管",
          text: "管理者"
        }
      ]
    },
    {
      labels: [
        {
          group: "bureaucrat",
          label: "ビ",
          text: "ビューロクラット"
        }
      ]
    },
    {
      skip: ["sysop"],
      labels: [
        {
          group: "eliminator",
          label: "削",
          text: "削除者"
        }
      ]
    },
    {
      labels: [
        {
          group: "interface-admin",
          label: "イ",
          text: "インターフェース管理者"
        }
      ]
    },
    {
      labels: [
        {
          group: "abusefilter",
          label: "編",
          text: "編集フィルター編集者"
        }
      ]
    },
    {
      skip: ["sysop"],
      labels: [
        {
          group: "rollbacker",
          label: "巻",
          text: "巻き戻し者"
        }
      ]
    },
    {
      labels: [
        {
          group: "bot",
          label: "ボ",
          text: "ボット"
        }
      ]
    },
    {
      skip: ["sysop", "steward", "bot", "bureaucrat", "eliminator"],
      labels: [
        {
          group: "accountcreator",
          label: "ア",
          text: "アカウント作成者"
        }
      ]
    },
    {
      labels: [
        {
          group: "checkuser",
          label: "チ",
          text: "チェックユーザー"
        }
      ]
    },
    {
      labels: [
        {
          group: "suppress",
          label: "オ",
          text: "オーバーサイト"
        }
      ]
    },
    {
      labels: [
        {
          group: "steward",
          label: "ス",
          text: "スチュワード"
        }
      ]
    },
    {
      labels: [
        {
          group: "import",
          label: "ト",
          text: "インポート担当者"
        }
      ]
    },
    {
      skip: ["sysop", "import"],
      labels: [
        {
          group: "transwiki",
          label: "ウ",
          text: "ウィキ間インポート担当者"
        }
      ]
    },
    {
      labels: [
        {
          group: "push-subscription-manager",
          label: "プ",
          text: "プッシュ・サブスクリプション取扱者"
        }
      ]
    },
    {
      labels: [
        {
          group: "ipblock-exempt",
          label: "除",
          text: "IPブロック適用除外者"
        }
      ]
    }
  ];
  //#endregion

  const api = new mw.Api();

  /**
   * @template {any} T
   * @param {T | undefined | null} value
   * @returns {T}
   */
  const nonNull = (value) => /** @type {T} */(/** @type {unknown} */(value));

  /**
   * @template {any} T
   * @param {T[]} array
   * @returns {T[][]}
   */
  // @ts-ignore
  const split50 = (array) => array.reduce((a, b, i) => i % 50 == 0 ? [...a, [b]] : [...a.slice(0, -1), [...a[a.length - 1], b]], []);

  /**
   * 
   * @param {Date} date
   * @returns
   */
  const formatDate = (date) => {
    if (Number.isNaN(date.getTime())) return "期限不明";
    const year = date.getFullYear();
    const month = date.getMonth() + 1;
    const dateN = date.getDate();
    const day = ["日", "月", "火", "水", "木", "金", "土"][date.getDay()];
    const hours = String(date.getHours()).padStart(2, "0");
    const minutes = String(date.getMinutes()).padStart(2, "0");
  
    return `${year}${month}${dateN}日 (${day}) ${hours}:${minutes}`;
  };


  /**
   * @type {WeakMap<Element, Element>}
   */
  const labelElements = new WeakMap();

  /**
   * @type {Map<string, Label[]>}
   */
  const userLabels = new Map();

  /**
   * 
   * @param {Label[]} labels
   * @param {string} user
   * @param {Element[]} elements
   * @param {boolean} isMissing
   */
  const setLabel = (labels, user, elements, isMissing = false) => {
    userLabels.set(user, labels);

    /**
     * @type {WeakMap<Label, string>}
     */
    const wikitexts = new WeakMap();

    for (const element of elements) {
      const labelElement = document.createElement("sub");
      labelElement.style.margin = "0 3px";
      labelElement.style.padding = "1px";
      labelElement.style.color = "black";
      labelElement.style.fontSize = "70%";
      labelElement.style.fontStyle = "normal";
      labelElement.style.border = ".9px solid black";
      labelElement.style.display = "inline-flex";
      labelElement.style.gap = "3px";
      labelElement.style.cursor = "default";

      for (const labelItem of labels) {
        const { label = "", isWikitext = false, text = "" } = labelItem;

        const groupElement = document.createElement("span");
        groupElement.setAttribute("title", text);
        groupElement.innerText = label;

        if (isWikitext) {
          const overEvent = async () => {
            const startOverEvent = () => {
              groupElement.addEventListener("pointerover", overEvent, {
                once: true
              });
              groupElement.setAttribute("title", text);
            };

            if (!wikitexts.has(labelItem)) {
              try {
                await api.get({
                  action: "parse",
                  format: "json",
                  text,
                  prop: "text",
                  disablelimitreport: 1,
                  formatversion: "2"
                }).then((/** @type {ParseApiResult} */response) => {
                  wikitexts.set(labelItem, response.parse.text);
                });
              } catch {
                startOverEvent();
                return;
              }
            }

            const wikitext = wikitexts.get(labelItem);

            if (!wikitext) {
              startOverEvent();
              return;
            }

            groupElement.removeAttribute("title");

            const tooltipElement = document.createElement("div");

            {
              // To get the size. Remove later.
              tooltipElement.style.opacity = "0";
              tooltipElement.style.whiteSpace = "nowrap";
              tooltipElement.style.overflowX = "hidden";

              tooltipElement.style.position = "absolute";
              tooltipElement.style.padding = "5px";
              tooltipElement.style.maxWidth = "300px";
              tooltipElement.style.fontSize = ".75rem";
              tooltipElement.style.boxShadow = "black 0px 0px 5px 0px";
              tooltipElement.style.backgroundColor = "white";

              tooltipElement.innerHTML = wikitext;

              const { right, bottom } = groupElement.getBoundingClientRect();
              const { scrollX, scrollY } = window;

              const x = scrollX + right;
              const y = scrollY + bottom;

              tooltipElement.style.top = `${y}px`;
              tooltipElement.style.left = `${x}px`;

              document.body.append(tooltipElement);

              setTimeout(() => {
                const { clientWidth: windowWidth } = document.documentElement;
                const { clientWidth: tooltipWidth } = tooltipElement;

                if (x + tooltipWidth + 10 > windowWidth) {
                  tooltipElement.style.left = "";
                  tooltipElement.style.right = "10px";
                }

                tooltipElement.style.whiteSpace = "";
                tooltipElement.style.opacity = "";
                tooltipElement.style.overflowX = "";
              });
            }

            /**
             * @type {number | null}
             */
            let timeoutId;
            const resetLeaveTimeout = () => {
              if (timeoutId != null) return;
              clearLeaveTimeout();

              timeoutId = window.setTimeout(() => {
                tooltipElement.remove();
                window.removeEventListener("pointermove", moveEvent);
                startOverEvent();
              }, 300);
            };
            const clearLeaveTimeout = () => {
              if (timeoutId != null) clearTimeout(timeoutId);
              timeoutId = null;
            };

            /**
             * 
             * @param {PointerEvent} e
             */
            const moveEvent = (e) => {
              const { target } = e;
              if (!target) return;
              if (!(target instanceof Node)) return;

              if (groupElement.contains(target) || tooltipElement.contains(target)) {
                clearLeaveTimeout();
                return;
              }
              resetLeaveTimeout();
            };

            setTimeout(() => {
              window.addEventListener("pointermove", moveEvent);
            });
          };

          groupElement.addEventListener("pointerover", overEvent, {
            once: true
          });
        }

        labelElement.append(groupElement);
      }
  
      if (!isMissing) {
        [["会", "会話ページに移動", "User_talk:"], ["記", "投稿記録に移動", "Special:Contributions/"]].forEach(v => {
          const contributionsElement = document.createElement("a");
          contributionsElement.href = mw.util.getUrl(`${v[2]}${user}`);
          contributionsElement.innerText = v[0];
          contributionsElement.title = v[1];
          contributionsElement.style.color = "#0645ad";
          contributionsElement.style.fontWeight = "bold";
          labelElement.append(contributionsElement);
        });
      }

      element.after(labelElement);

      labelElements.set(element, labelElement);
    }
  };

  /**
   * @type {Record<"user" | "ip", Map<string, Set<Element>>>}
   */
  const untreatedUsers = {
    user: new Map(),
    ip: new Map()
  };

  const setUsers = () => {
    const [users, ipUsers] = [untreatedUsers.user, untreatedUsers.ip].map((users) => {
      /**
       * @type {Map<string, Element[]>}
       */
      const usersMap = new Map();

      for (const [name, elements] of users) {
        const filteredElements = [...elements.values()].filter((element) => {
          return labelElements.get(element) !== element.nextElementSibling;
        });

        if (!filteredElements.length) {
          usersMap.delete(name);
          continue;
        }

        if (userLabels.has(name)) {
          const labels = nonNull(userLabels.get(name));
          setLabel(labels, name, filteredElements);

          usersMap.delete(name);
          continue;
        }

        usersMap.set(name, filteredElements);
      }

      return usersMap;
    });

    untreatedUsers.user.clear();
    untreatedUsers.ip.clear();

    /**
     * @param {Map<string, Element[]>} users
     * @param {Map<string, Element[]> | null} ipUsers
     * @param {number} depth
     */
    const setUsersChild = async (
      users,
      ipUsers,
      depth = 0
    ) => {
      /**
       * @type {string[]}
       */
      const missingUsers = [];

      const userNames = [...users.keys()];
      const ipAddresses = ipUsers ? [...ipUsers.keys()] : [];

      const splittedUsers = split50(userNames);
      const splittedIpAddresses = split50(ipAddresses);

      const maxLength = Math.max(splittedUsers.length, splittedIpAddresses.length);
      for (let i = 0; i < maxLength; i++) {
        const users50 = splittedUsers[i] ?? [];
        const ipAddresses50 = splittedIpAddresses[i] ?? [];

        const parameters = (() => {
          /**
           * @type {Record<string, any>}
           */
          const parameters = {
            "action": "query",
            "format": "json",
            "formatversion": "2"
          };

          /**
           * @type {string[]}
           */
          const list = [];

          if (ipAddresses50.length) {
            list.push("blocks");

            parameters.bkusers = ipAddresses50.join("|");
            parameters.bkprop = "user|expiry|reason";
            parameters.bklimit = "max";
          }
          if (users50.length) {
            list.push("users");

            parameters.usprop = "groups|blockinfo";
            parameters.ususers = users50.join("|");
          }
          parameters.list = list;

          return parameters;
        })();
        await api.get(parameters).then((/** @type {UserApiResult} */result) => {
          /**
           * @type {Map<string, BlocksApiResult>}
           */
          const blocks = new Map();
          if (result.query.blocks) {
            for (const block of result.query.blocks) {
              blocks.set(block.user, block);
            }
          }

          for (const ipAddress of ipAddresses50) {
            const elements = nonNull(nonNull(ipUsers).get(ipAddress));

            /**
             * @type {Label[]}
             */
            const labels = [];
            labels.push({
              label: "IP",
              text: "IP利用者"
            });

            if (blocks.has(ipAddress)) {
              const block = nonNull(blocks.get(ipAddress));
              const expiry = "期限:" + (block.expiry === "infinity" ?
                "無期限" :
                formatDate(new Date(block.expiry))
              );
              const reason = (() => {
                if (block.reason) {
                  return `理由:${block.reason}`;
                }
                return;
              })();
              const brackets = /** @type {string[]} */([expiry, reason].filter(Boolean)).join("、");
              labels.push({
                label: "ブ",
                isWikitext: true,
                text: `投稿ブロック(${brackets})`
              });
            }
            setLabel(labels, ipAddress, elements);
          }

          if (result.query.users) {
            for (const user of result.query.users) {
              const { name } = user;
              const elements = users.get(name);
              if (!elements) continue;

              if (user.missing) {
                if (depth >= 10) {
                  setLabel([{
                    label: "?",
                    text: "存在しない利用者(リダイレクト上限)"
                  }], name, elements, true);
                } else {
                  missingUsers.push(name);
                }
                continue;
              }
              if (user.invalid) {
                setLabel([{
                  label: "?",
                  text: "存在しない利用者(無効な利用者名)"
                }], name, elements, true);
                continue;
              }

              const { groups } = user;
              if (!groups) continue;

              /**
               * @type {Label[]}
               */
              const labels = [];

              if (depth > 0) {
                labels.push({
                  label: `リ${depth}`,
                  text: `リダイレクト(${depth}回)`
                });
              }

              for (const { skip, labels: targetLabels } of LABEL_GROUPS) {
                if (skip && skip.some(item => groups.includes(item))) {
                  continue;
                }
                for (const label of targetLabels) {
                  if (groups.includes(label.group)) {
                    labels.push({
                      label: label.label,
                      text: label.text
                    });
                    break;
                  }
                }
              }
              if (user.blockid) {
                const expiry = "期限:" + (user.blockexpiry === "infinite" ?
                  "無期限" :
                  formatDate(new Date(user.blockexpiry))
                );
                const reason = (() => {
                  if (user.blockreason) {
                    return `理由:${user.blockreason}`;
                  }
                  return;
                })();
                const brackets = /** @type {string[]} */([expiry, reason].filter(Boolean)).join("、");
                labels.push({
                  label: "ブ",
                  isWikitext: true,
                  text: `投稿ブロック(${brackets})`
                });
              }
              setLabel(labels, name, elements);
            }
          }
        });
      }

      if (missingUsers.length) {
        /**
         * @type {Map<string, Element[]>}
         */
        const redirectUsers = new Map();

        const missingUsers50 = split50(missingUsers);

        for (const missingUsers of missingUsers50) {
          const missingUsersSet = new Set(missingUsers);
          /**
           * @type {RedirectsApiResult}
           */
          const result = await api.get({
            "action": "query",
            "format": "json",
            "titles": missingUsers.map(user => `利用者:${user}`).join("|"),
            "redirects": true,
            "formatversion": "2",
          });

          if (result.query.redirects) {
            for (const redirect of result.query.redirects) {
              if (!redirect.from.startsWith("利用者:")) continue;
              if (!redirect.to.startsWith("利用者:")) continue;

              const from = redirect.from.slice(4);
              const to = redirect.to.slice(4);

              if (!missingUsersSet.has(from)) continue;

              if (!users.has(from)) continue;

              missingUsersSet.delete(from);

              redirectUsers.set(to, nonNull(users.get(from)));
            }
          }

          for (const missingUser of missingUsersSet) {
            if (!users.has(missingUser)) continue;

            setLabel([{
              label: "?",
              text: "存在しない利用者"
            }], missingUser, nonNull(users.get(missingUser)), true);
          }
        }

        await setUsersChild(redirectUsers, null, depth + 1);
      }
    };

    return setUsersChild(users, ipUsers);
  }

  /**
   * @param {Document | Element} elements
   */
  const addUsers = (elements) => {
    for (const userElement of elements.querySelectorAll(USER_LINK_QUERY)) {
      const { href } = /** @type { HTMLAnchorElement } */(userElement);
      if (!href) continue;

      const matchUser = href.match(USER_NAME_REGEXP);

      if (!matchUser) continue;

      let [, isContributions, user] = matchUser;

      const isIpUser = IP_REGEXP.test(user);

      if (user.includes("/")) continue;
      if (isContributions && !isIpUser) continue;

      const { searchParams } = new URL(href, location.href);

      if (searchParams.has("oldid") || searchParams.has("diff") || searchParams.has("target")) continue;

      if (searchParams.has("action") && searchParams.get("action") !== "view") {
        if (searchParams.get("action") !== "edit") continue;
        if (searchParams.get("redlink") !== "1") continue;
      }

      user = decodeURIComponent(user);
      // @ts-ignore
      user = user.replaceAll("_", " ");
      user = user.trim();
      user = user.slice(0, 1).toUpperCase() + user.slice(1);

      const targetUsers = isIpUser ? untreatedUsers.ip : untreatedUsers.user;

      const targetUser = (() => {
        if (!targetUsers.has(user)) {
          targetUsers.set(user, new Set());
        }
        return nonNull(targetUsers.get(user));
      })();
      targetUser.add(userElement);
    }
  };

  new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
      if (mutation.type !== "childList") continue;
      if (!mutation.addedNodes.length) continue;
      for (const node of mutation.addedNodes) {
        if (!(node instanceof Element)) continue;
        addUsers(node);
      }
    }
  }).observe(document.body, { childList: true, subtree: true });

  addUsers(document);

  setUsers();
  setInterval(() => {
    setUsers();
  }, 1000);
// @ts-ignore
})(mw);