NGA Noimg Fix

尝试将泥潭无法加载的图片修复

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name              NGA Noimg Fix
// @name:zh-CN        NGA Noimg 修复
// @namespace         https://greasyfork.org/users/263018
// @version           1.2.0
// @author            snyssss
// @description       尝试将泥潭无法加载的图片修复
// @description:zh-cn 尝试将泥潭无法加载的图片修复
// @license           MIT

// @match             *://bbs.nga.cn/*
// @match             *://ngabbs.com/*
// @match             *://nga.178.com/*

// @require           https://update.greasyfork.org/scripts/486070/1405682/NGA%20Library.js

// @grant             GM_setValue
// @grant             GM_getValue
// @grant             GM_registerMenuCommand
// @grant             unsafeWindow

// @run-at            document-start
// @noframes
// ==/UserScript==

(() => {
  // 声明泥潭主模块、回复模块
  let commonui, replyModule;

  // 急速模式
  const FAST_MODE_KEY = "FAST_MODE";
  const FAST_MODE = GM_getValue(FAST_MODE_KEY, true);

  // 图片属性
  const IMG_ATTRS_KEY = "IMG_ATTRS";
  const IMG_ATTRS = GM_getValue(IMG_ATTRS_KEY, { style: "max-width: 100%" });

  // 缓存,避免重复请求
  const cache = {};

  // 监听元素变化并重新修复
  const observer = new MutationObserver((mutationsList) => {
    const list = [];

    mutationsList.forEach(({ target }) => {
      const content = target.classList.contains("ubbcode")
        ? target
        : target.closest(".ubbcode");

      const item = Object.values(replyModule.data).find(
        (item) => item.contentC === content
      );

      if (item && list.includes(item) === false) {
        list.push(item);
      }
    });

    list.forEach(fixReply);
  });

  /**
   * 修复无法加载的图片
   * @param {*} tid      帖子 ID
   * @param {*} pid      回复 ID
   * @param {*} content  回复容器
   * @param {*} postTime 回复时间
   */
  const fixNoimg = async (tid, pid, content, postTime) => {
    // 用正则匹配所有 [noimg] 标记
    const matches = content.innerHTML.match(/\[noimg\]\.(.+?)\[\/noimg\]/g);

    // 没有匹配结果,跳过
    if (matches === null) {
      return;
    }

    // 替换图片方法
    const replace = (key, value) => {
      // 写入缓存
      cache[key] = value;

      // 生成图片
      const img = document.createElement("img");

      // 设置图片属性
      Object.entries({
        ...IMG_ATTRS,
        src: value,
      }).forEach(([key, value]) => {
        img.setAttribute(key, value);
      });

      // 替换图片
      content.innerHTML = content.innerHTML.replace(key, img.outerHTML);
    };

    // 转换时间戳至时间
    const time = new Date(postTime * 1000);

    // 尝试从缓存里直接读取
    const list = matches.filter((item) => {
      // 缓存模式
      if (cache[item]) {
        replace(item, cache[item]);

        return false;
      }

      // 极速模式
      if (FAST_MODE) {
        // 取得 Noimg 里的图片地址
        const src = item.replace(/\[noimg\]\.(.+?)\[\/noimg\]/, "$1");

        // 加入时间前缀
        const realSrc =
          `./mon_` +
          `${time.getFullYear()}` +
          `${String(time.getMonth() + 1).padStart(2, "0")}/` +
          `${String(time.getDate()).padStart(2, "0")}` +
          `${src}`;

        // 计算完整的图片地址
        const fullSrc = commonui.correctAttachUrl(realSrc);

        // 替换图片
        replace(item, fullSrc);

        return false;
      }

      return true;
    });

    // 无需再次修复
    if (list.length === 0) {
      return;
    }

    // 尝试请求带有正确图片地址的回复原文
    const url = `/post.php?action=quote&tid=${tid}&pid=${pid}&lite=js`;

    const response = await fetch(url);

    const result = await Tools.readForumData(response, false);

    // 用正则匹配所有 [img] 标记
    const imgs = result.match(/\[img\](.+?)\[\/img\]/g) || [];

    // 声明前缀
    let prefix = "";

    // 对比图片结果,修复无法加载的图片
    for (let i = 0; i < list.length; i += 1) {
      const item = list[i];

      // 取得 Noimg 里的图片地址
      const src = item.replace(/\[noimg\]\.(.+?)\[\/noimg\]/, "$1");

      // 取得原文里的图片地址
      const realSrc = (() => {
        const img = imgs.find((item) => item.indexOf(src) > 0);

        // 引用会超字数限制,我们姑且认为所有图片都是在同一时间内发出的
        // 如果有图片,更新前缀,反之直接使用前一个前缀
        if (img) {
          prefix = img.replace(/\[img\](.+?)\[\/img\]/, "$1").replace(src, "");
        }

        // 返回结果
        if (prefix) {
          return `${prefix}${src}`;
        }
      })();

      // 如果有图片地址,修复
      if (realSrc) {
        // 计算完整的图片地址
        const fullSrc = commonui.correctAttachUrl(realSrc);

        // 替换图片
        replace(item, fullSrc);
      }
    }
  };

  /**
   * 修复回复
   * @param {*} item 回复内容,见 commonui.postArg.data
   */
  const fixReply = async (item) => {
    // 跳过泥潭增加的额外内容
    if (Tools.getType(item) !== "object") {
      return;
    }

    // 获取帖子 ID、回复 ID、内容、回复时间
    const { tid, pid, contentC, postTime } = item;

    // 处理引用
    await fixQuote(item);

    // 修复图片
    await fixNoimg(tid, pid, contentC, postTime);

    // 监听元素变化并重新修复
    // 兼容屏蔽脚本
    observer.observe(contentC, { childList: true, subtree: true });
  };

  /**
   * 修复引用
   * @param {*} item 回复内容,见 commonui.postArg.data
   */
  const fixQuote = async (item) => {
    // 跳过泥潭增加的额外内容
    if (Tools.getType(item) !== "object") {
      return;
    }

    // 获取内容
    const content = item.contentC;

    // 找到所有引用
    const quotes = content.querySelectorAll(".quote");

    // 处理引用
    await Promise.all(
      [...quotes].map(async (quote) => {
        const { tid, pid } = (() => {
          const ele = quote.querySelector("[title='快速浏览这个帖子']");

          if (ele) {
            const res = ele
              .getAttribute("onclick")
              .match(/fastViewPost(.+,(\S+),(\S+|undefined),.+)/);

            if (res) {
              return {
                tid: parseInt(res[2], 10),
                pid: parseInt(res[3], 10) || 0,
              };
            }
          }

          return {};
        })();

        const timeElement = quote.querySelector(".xtxt");
        const time = timeElement
          ? timeElement.innerHTML.replace(/\((.+)\)/, "$1")
          : null;

        if (time) {
          // 转换为泥潭的时间戳
          const postTime = new Date(time).getTime() / 1000;

          // 修复图片
          await fixNoimg(tid, pid, quote, postTime);
        }
      })
    );
  };

  /**
   * 处理 postArg 模块
   * @param {*} value commonui.postArg
   */
  const handleReplyModule = async (value) => {
    // 绑定回复模块
    replyModule = value;

    if (value === undefined) {
      return;
    }

    // 修复
    const afterGet = (_, args) => {
      // 楼层号
      const index = args[0];

      // 找到对应数据
      const data = replyModule.data[index];

      // 开始修复
      if (data) {
        fixReply(data);
      }
    };

    // 如果已经有数据,则直接修复
    Object.values(replyModule.data).forEach(fixReply);

    // 拦截 proc 函数,这是泥潭的回复添加事件
    Tools.interceptProperty(replyModule, "proc", {
      afterGet,
    });
  };

  /**
   * 处理 commonui 模块
   * @param {*} value commonui
   */
  const handleCommonui = (value) => {
    // 绑定主模块
    commonui = value;

    // 拦截 postArg 模块,这是泥潭的回复入口
    Tools.interceptProperty(commonui, "postArg", {
      afterSet: (value) => {
        handleReplyModule(value);
      },
    });
  };

  /**
   * 注册脚本菜单
   */
  const registerMenu = () => {
    // 极速模式
    {
      const func = () => {
        if (
          FAST_MODE === false &&
          confirm(
            `是否开启极速模式?\n极速模式即为不请求原文,而是根据发帖时间推测图片地址。\n对于复制他人图片链接至帖子里的解析可能会失败。`
          ) === false
        ) {
          return;
        }

        GM_setValue(FAST_MODE_KEY, !FAST_MODE);

        location.reload();
      };

      GM_registerMenuCommand(`极速模式:${FAST_MODE ? "是" : "否"}`, func);
    }

    // 图片属性
    {
      const func = () => {
        const attr = prompt(
          `给图片添加额外的属性或样式`,
          JSON.stringify(IMG_ATTRS)
        );

        if ((attr || "").length > 0) {
          try {
            const newValue = JSON.parse(attr);

            if (Tools.getType(newValue) !== "object") {
              throw new Error();
            }

            GM_setValue(IMG_ATTRS_KEY, newValue);

            location.reload();
          } catch {
            func();
          }
        }
      };

      GM_registerMenuCommand(`图片属性`, func);
    }
  };

  // 主函数
  (async () => {
    // 注册脚本菜单
    registerMenu();

    // 处理 commonui 模块
    if (unsafeWindow.commonui) {
      handleCommonui(unsafeWindow.commonui);
      return;
    }

    Tools.interceptProperty(unsafeWindow, "commonui", {
      afterSet: (value) => {
        handleCommonui(value);
      },
    });
  })();
})();