NGA Cache History

将帖子内容缓存 IndexedDB 里,以便在帖子被审核/删除时仍能查看

// ==UserScript==
// @name        NGA Cache History
// @name:zh-CN  NGA 帖子缓存插件
// @namespace   https://greasyfork.org/users/263018
// @version     1.2.4
// @author      snyssss
// @description 将帖子内容缓存 IndexedDB 里,以便在帖子被审核/删除时仍能查看
// @license     MIT

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

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

// @noframes
// ==/UserScript==
(async ({ commonui: ui, _LOADERREAD: loader }) => {
  // 检查是否支持 IndexedDB
  if (window.indexedDB === undefined) {
    return;
  }

  // 常量
  const VERSION = 1;
  const DB_NAME = "NGA_CACHE";
  const TABLE_NAME = "reads";
  const SHOW_DIFFRENCE_KEY = "SHOW_DIFFRENCE";
  const EXPIRE_DURATION_KEY = "EXPIRE_DURATION";
  const REFETCH_NOTIFICATION_INTERVAL_KEY = "REFETCH_NOTIFICATION_INTERVAL";

  // 显示差异
  const SHOW_DIFFRENCE = GM_getValue(SHOW_DIFFRENCE_KEY, false);

  // 缓存时长
  const EXPIRE_DURATION = GM_getValue(EXPIRE_DURATION_KEY, 7);

  // 获取提示信息间隔
  const REFETCH_NOTIFICATION_INTERVAL = GM_getValue(
    REFETCH_NOTIFICATION_INTERVAL_KEY,
    10
  );

  // 判断帖子是否正常
  const isSuccess = () => {
    return ui;
  };

  // 格式化 URL
  const formatUrl = (url) => {
    // 分割 URL
    const urlSplit = url.split("?");

    // 获取页面参数
    const params = new URLSearchParams(urlSplit[1]);

    // 如果是第一页,移除页码参数
    if (params.get("page") === "1") {
      params.delete("page");
    }

    // 移除 _ff 参数
    params.delete("_ff");

    // 返回格式化后的结果
    return `${urlSplit[0]}?${params.toString()}`;
  };

  // 获取首页 URL
  const getHeadUrl = (url) => {
    // 格式化 URL
    url = formatUrl(url);

    // 分割 URL
    const urlSplit = url.split("?");

    // 获取页面参数
    const params = new URLSearchParams(urlSplit[1]);

    // 获取 TID
    const tid = params.get("tid");

    // 返回首页 URL
    return `${urlSplit[0]}?tid=${tid}`;
  };

  // 获取数据库实例
  const db = await new Promise((resolve) => {
    // 打开 IndexedDB 数据库
    const request = window.indexedDB.open(DB_NAME, VERSION);

    // 如果数据库不存在则创建
    request.onupgradeneeded = (event) => {
      // 创建表
      const store = event.target.result.createObjectStore(TABLE_NAME, {
        keyPath: "url",
      });

      // 创建索引,用于清除过期数据
      store.createIndex("timestamp", "timestamp");
    };

    // 成功后返回实例
    request.onsuccess = (event) => {
      resolve(event.target.result);
    };
  });

  // 获取数据
  const get = (url, onsuccess, onerror = () => {}) => {
    // 格式化 URL
    url = formatUrl(url);

    // 只缓存帖子内容
    if (url.indexOf("/read.php") < 0) {
      return;
    }

    // 创建事务
    const transaction = db.transaction([TABLE_NAME], "readonly");

    // 获取对象仓库
    const store = transaction.objectStore(TABLE_NAME);

    // 获取数据
    const request = store.get(url);

    // 成功后处理数据
    request.onsuccess = (event) => {
      // 获取页面对象
      const data = event.target.result;

      // 不存在则抛出异常
      if (data === undefined) {
        onerror();
        return;
      }

      // 处理数据
      onsuccess(data);
    };

    // 失败后抛出异常
    request.onerror = () => {
      onerror();
    };
  };

  // 删除超时数据
  const expire = (offset) => {
    // 创建事务
    const transaction = db.transaction([TABLE_NAME], "readwrite");

    // 获取对象仓库
    const store = transaction.objectStore(TABLE_NAME);

    // 获取索引
    const index = store.index("timestamp");

    // 查找超时数据
    const request = index.openCursor(
      IDBKeyRange.upperBound(Date.now() - offset)
    );

    // 成功后删除数据
    request.onsuccess = (event) => {
      const cursor = event.target.result;

      if (cursor) {
        store.delete(cursor.primaryKey);

        cursor.continue();
      }
    };
  };

  // 删除数据
  const remove = (url, onsuccess = () => {}, onerror = () => {}) => {
    // 格式化 URL
    url = formatUrl(url);

    // 创建事务
    const transaction = db.transaction([TABLE_NAME], "readwrite");

    // 获取对象仓库
    const store = transaction.objectStore(TABLE_NAME);

    // 删除数据
    const request = store.delete(url);

    // 成功后回调
    request.onsuccess = () => {
      onsuccess();
    };

    // 失败后回调
    request.onerror = () => {
      onerror();
    };
  };

  // 写入数据
  const put = (url, data, onsuccess = () => {}, onerror = () => {}) => {
    // 格式化 URL
    url = formatUrl(url);

    // 创建事务
    const transaction = db.transaction([TABLE_NAME], "readwrite");

    // 获取对象仓库
    const store = transaction.objectStore(TABLE_NAME);

    // 写入数据
    const request = store.put({
      url,
      timestamp: Date.now(),
      ...data,
    });

    // 成功后回调
    request.onsuccess = () => {
      onsuccess();
    };

    // 失败后回调
    request.onerror = () => {
      onerror();
    };
  };

  // 缓存数据
  const save = (url) => {
    // 格式化 URL
    url = formatUrl(url);

    // 只缓存帖子内容
    if (url.indexOf("/read.php") < 0) {
      return;
    }

    // 重新请求原始数据用于缓存
    fetch(url)
      .then((res) => res.blob())
      .then((res) => {
        // 读取内容
        const reader = new FileReader();

        reader.onload = async () => {
          // 读取内容
          const content = reader.result;

          // 解析标题
          const parser = new DOMParser();
          const html = parser.parseFromString(content, "text/html");
          const title = (() => {
            const str = html.querySelector("title").textContent;
            const index = str.lastIndexOf(" ");

            if (index > 0) {
              return str.substring(0, index);
            }

            return str;
          })();

          // 没有楼层,说明卡审核
          if (content.indexOf("commonui.postArg.proc(") < 0) {
            return;
          }

          // 找到 ID 是 postcontainer 开头的元素
          const containers = html.querySelectorAll("[id^=postcontainer]");

          if (containers.length === 0) {
            return;
          }

          // 有锚点,但是找不到楼层,也是卡审核
          const anchor = url.match(/(#pid\d+Anchor)$/);

          if (anchor && html.querySelector(anchor[1]) === null) {
            return;
          }

          // 如果未开启浏览记录,直接写入缓存
          if (SHOW_DIFFRENCE === false) {
            put(url, {
              title,
              content,
            });
          }
          // 否则判断是否是正常的翻页,如果是则需要更新最大楼层数
          else {
            // 分割 URL
            const urlSplit = url.split("?");

            // 获取页面参数
            const params = new URLSearchParams(urlSplit[1]);

            // 移除 TID 参数
            params.delete("tid");

            // 移除页码参数
            params.delete("page");

            // 如果仍有参数,只缓存当前页,无需更新最大楼层数
            if (params.size > 0) {
              put(url, {
                title,
                content,
              });
            }
            // 否则需要更新最大楼层数
            else {
              // 获取首页 URL
              const headUrl = getHeadUrl(url);

              // 当前页不是首页,写入缓存
              if (headUrl !== url) {
                put(url, {
                  title,
                  content,
                });
              }

              // 获取当前页面的最大楼层数
              const count = parseInt(
                containers[containers.length - 1]
                  .getAttribute("id")
                  .replace("postcontainer", ""),
                10
              );

              // 获取首页缓存
              get(
                headUrl,
                (data) => {
                  // 获取缓存楼层数
                  const cache = data.rows || 0;

                  // 计算最大楼层数
                  const max = Math.max(count, cache);

                  // 当前页是首页,直接更新缓存
                  if (headUrl === url) {
                    put(url, {
                      title,
                      content,
                      rows: max,
                    });

                    loadAction();
                    return;
                  }

                  // 如果与缓存的最大楼层数相同,无需更新
                  if (max === cache) {
                    return;
                  }

                  // 更新缓存
                  put(headUrl, {
                    ...data,
                    rows: max,
                  });
                },
                () => {
                  // 当前页是首页,直接更新缓存
                  if (headUrl === url) {
                    put(url, {
                      title,
                      content,
                      rows: count,
                    });

                    loadAction();
                  }
                }
              );
            }
          }
        };

        reader.readAsText(res, "GBK");
      });
  };

  // 读取数据
  const load = (url, document) => {
    // 格式化 URL
    url = formatUrl(url);

    return get(url, (data) => {
      // 加载缓存内容
      const html = document.open("text/html", "replace");

      html.write(data.content);
      html.close();

      // 缓存时间格式
      const formatedDate = (() => {
        const date = new Date(data.timestamp);
        const year = date.getFullYear();
        const month = ("0" + (date.getMonth() + 1)).slice(-2);
        const day = ("0" + date.getDate()).slice(-2);
        const hours = ("0" + date.getHours()).slice(-2);
        const minutes = ("0" + date.getMinutes()).slice(-2);

        return `${year}-${month}-${day} ${hours}:${minutes}`;
      })();

      // 写入缓存时间
      (() => {
        const execute = () => {
          const container = document.querySelector('td[id^="postcontainer"]');

          if (container) {
            const elements = container.querySelectorAll(":scope > .clear");

            const anchor = elements[elements.length - 1];

            if (anchor) {
              anchor.insertAdjacentHTML(
                "afterend",
                `<h4 class="silver subtitle">缓存</h4><span class="block_txt block_txt_c3">${formatedDate}</span>`
              );
              return;
            }
          }

          setTimeout(execute, 160);
        };

        execute();
      })();
    });
  };

  // STYLE
  GM_addStyle(`
      .s-table-wrapper {
        height: calc((2em + 10px) * 11 + 3px);
        overflow-y: auto;
      }
      .s-table {
        margin: 0;
      }
      .s-table th,
      .s-table td {
        position: relative;
        white-space: nowrap;
      }
      .s-table th {
        position: sticky;
        top: 2px;
        z-index: 1;
      }
      .s-text-ellipsis > * {
        flex: 1;
        width: 1px;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    `);

  // UI
  const loadUI = () => {
    if (!ui) {
      return;
    }

    const content = (() => {
      const c = document.createElement("div");

      c.innerHTML = `
        <div class="s-table-wrapper" style="width: 1000px; max-width: 95vw;">
          <table class="s-table forumbox">
            <thead>
              <tr class="block_txt_c0">
                <th class="c1" width="1">时间</th>
                <th class="c2">内容</th>
                <th class="c3" width="1">操作</th>
              </tr>
            </thead>
            <tbody></tbody>
          </table>
        </div>
        <div style="display: flex; margin-top: 10px;">
          <input type="text" style="flex: 1;" placeholder="目前支持通过帖子链接或标题进行筛选,查询旧数据可能需要一些时间" />
          <button>筛选</button>
        </div>
      `;

      return c;
    })();

    let position = null;
    let hasNext = true;
    let isFetching = false;
    let keyword = "";

    const list = content.querySelector("TBODY");

    const wrapper = content.querySelector(".s-table-wrapper");

    const keywordInput = content.querySelector("INPUT");

    const filterButton = content.querySelector("BUTTON");

    const fetchData = () => {
      isFetching = true;

      // 声明查询数量
      let limit = 10;

      // 创建事务
      const transaction = db.transaction([TABLE_NAME], "readonly");

      // 获取对象仓库
      const store = transaction.objectStore(TABLE_NAME);

      // 获取索引
      const index = store.index("timestamp");

      // 查找数据
      const request = index.openCursor(
        position ? IDBKeyRange.upperBound(position) : null,
        "prev"
      );

      // 加载列表
      request.onsuccess = (event) => {
        const cursor = event.target.result;

        if (cursor) {
          const { url, title, timestamp } = cursor.value;

          position = timestamp;

          if (list.querySelector(`[data-url="${url}"]`)) {
            cursor.continue();
            return;
          }

          if (keyword) {
            if (url.indexOf(keyword) < 0 && title.indexOf(keyword) < 0) {
              cursor.continue();
              return;
            }
          }

          const item = document.createElement("TR");

          item.className = `row${(list.querySelectorAll("TR").length % 2) + 1}`;

          item.setAttribute("data-url", url);

          item.innerHTML = `
            <td class="c1">
              <span class="nobr">${ui.time2dis(timestamp / 1000)}</span>
            </td>
            <td class="c2">
              <div class="s-text-ellipsis">
                <span>
                  <a href="${url}" title="${title}" class="b nobr">${title}</a>
                </span>
              </div>
            </td>
            <td class="c3">
              <button>查看缓存版本</button>
              <button>删除</button>
            </td>
          `;

          const buttons = item.querySelectorAll("button");

          // 查看缓存版本
          buttons[0].onclick = () => {
            const iWindow = ui.createCommmonWindow();
            const iframe = document.createElement("IFRAME");

            iframe.style.width = "80vw";
            iframe.style.height = "80vh";
            iframe.style.border = "none";

            const iframeLoad = () => {
              iframe.removeEventListener("load", iframeLoad);

              load(url, iframe.contentDocument);
            };

            iframe.addEventListener("load", iframeLoad);

            iWindow._.addTitle(title);
            iWindow._.addContent(iframe);
            iWindow._.show();
          };

          // 删除缓存
          buttons[1].onclick = () => {
            remove(url, () => {
              list.removeChild(item);

              if (list.childElementCount < 10) {
                fetchData();
              }
            });
          };

          list.appendChild(item);

          if (limit > 1) {
            cursor.continue();
          } else {
            isFetching = false;
          }
        } else {
          hasNext = false;
        }

        limit -= 1;
      };
    };

    const refetch = (value = ``) => {
      list.innerHTML = ``;

      position = null;
      hasNext = true;
      isFetching = false;
      keyword = value;

      keywordInput.value = value;

      fetchData();
    };

    wrapper.onscroll = () => {
      if (isFetching || !hasNext) {
        return;
      }

      if (
        wrapper.scrollHeight - wrapper.scrollTop <=
        wrapper.clientHeight * 1.1
      ) {
        fetchData();
      }
    };

    filterButton.onclick = () => {
      refetch(keywordInput.value);
    };

    // 增加菜单项
    (() => {
      const title = "浏览记录";

      let window;

      ui.mainMenu.addItemOnTheFly(title, null, () => {
        if (window === undefined) {
          window = ui.createCommmonWindow();
        }

        refetch();

        window._.addTitle(title);
        window._.addContent(content);
        window._.show();
      });
    })();
  };

  // 加载操作按钮
  // 目前只有主楼的删除缓存
  const loadAction = () => {
    if (ui && ui.postArg) {
      const { data } = ui.postArg;

      if (data && data["0"] && data["0"]["pid"] === 0) {
        const item = data["0"];
        const pInfoC = item["pInfoC"];
        const anchor = pInfoC.querySelector(`[title="操作菜单"]`);

        const action = pInfoC.querySelector(`[title="缓存"]`);

        if (anchor && action === null) {
          const element = document.createElement("A");

          element.href = "javascript:void(0)";
          element.className = `postinfob postfavb postoptb small_colored_text_btn block_txt_c0 stxt`;
          element.title = "缓存";

          element.append(...__TXT.svg("turned_in", "", 8));

          element.onclick = () => {
            const url = window.location.href;

            // 判断是否已有缓存
            // 目前默认缓存所有页面,所以一定会有缓存
            const cached = element.classList.contains("postoptb");

            if (cached) {
              remove(url, () => {
                element.classList.remove("postoptb");
              });
            } else {
              save(url);

              element.classList.add("postoptb");
            }
          };

          anchor.parentElement.insertBefore(element, anchor);
        }
      }
    }
  };

  // 加载消息
  const loadMessage = () => {
    if (!ui) {
      return;
    }

    // 获取消息并写入缓存
    const execute = () => {
      fetch("/nuke.php?lite=js&__lib=noti&__act=get_all")
        .then((res) => res.blob())
        .then((blob) => {
          const reader = new FileReader();

          reader.onload = () => {
            const text = reader.result;
            const result = JSON.parse(
              text
                .replace("window.script_muti_get_var_store=", "")
                .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ')
            );

            if (result.data) {
              const data = result.data[0];

              const list = ["0", "1", "2"].reduce(
                (res, key) => ({
                  ...res,
                  [key]: data[key],
                }),
                {}
              );

              // 有未读消息,说明抢先获取了,需要弹出提醒
              if (data.unread) {
                for (let type in list) {
                  const group = list[type];

                  if (!group) {
                    continue;
                  }

                  for (let i = 0; i < group.length; i += 1) {
                    const item = group[i];

                    if (!item) {
                      continue;
                    }

                    if (i < group.length - 5) {
                      continue;
                    }

                    ui.notification._add(type, item);
                  }

                  if (group.length > 5) {
                    ui.notification._more.style.display = "";
                  }
                }

                ui.notification.openBox();
              }

              // 处理缓存
              // 只处理 0,也就是 _BIT_REPLY 的情况
              if (list["0"]) {
                const group = list["0"];

                for (let i = 0; i < group.length; i += 1) {
                  const item = group[i];

                  if (!item) {
                    continue;
                  }

                  // 消息的时间
                  const time = item[9] * 1000;

                  // 消息的内容,参考 js_notification.js 的 TPL
                  let str = TPL[0][item[0]];

                  if (typeof str == "function") {
                    str = str(item);
                  }

                  str = str
                    .replace(/\{(_[A-Z0-9_]+)\}/g, function ($0, $1) {
                      return TPLSUB[$1] ? TPLSUB[$1] : $0;
                    })
                    .replace(/\{(_[A-Z0-9_]+)\}/g, function ($0, $1) {
                      return item[KEY[$1]] ? item[KEY[$1]] : $0;
                    });

                  // 获取里面出现的所有页面链接
                  const urls = [
                    ...str.matchAll(/href="(\/read.php[^"]*)"/gi),
                  ].map((match) => `${window.location.origin}${match[1]}`);

                  for (let index in urls) {
                    // 链接地址
                    const url = urls[index];

                    // 创建事务
                    const transaction = db.transaction(
                      [TABLE_NAME],
                      "readonly"
                    );

                    // 获取对象仓库
                    const store = transaction.objectStore(TABLE_NAME);

                    // 获取数据
                    const request = store.get(url);

                    // 成功后处理数据
                    request.onsuccess = (event) => {
                      // 获取页面对象
                      const data = event.target.result;

                      // 存在,且缓存的时间晚于消息时间则跳过
                      if (data && data.timestamp > time) {
                        return;
                      }

                      // 写入缓存
                      save(url);
                    };
                  }
                }
              }
            }
          };

          reader.readAsText(blob, "GBK");
        });
    };

    // NGA 的消息机制是在页面加载的时候由服务端写在页面里再请求消息
    // 这会导致页面不刷新的时候,收到的提醒不能及时获知,等刷新时帖子可能已经没了
    // 所以需要定时获取最新消息,保证不刷论坛的情况下也会缓存提醒
    // 泥潭审核机制导致有消息提示但是找不到帖子的情况待解决
    const excuteInterval = () => {
      if (REFETCH_NOTIFICATION_INTERVAL > 0) {
        execute();
        setInterval(execute, REFETCH_NOTIFICATION_INTERVAL * 60 * 1000);
      }
    };

    // 启动定时器
    if (ui.notification) {
      excuteInterval();
    } else {
      ui.loadNotiScript(excuteInterval);
    }
  };

  // 绑定事件
  const hook = () => {
    // 钩子
    const hookFunction = (object, functionName, callback) => {
      ((originalFunction) => {
        object[functionName] = function () {
          const returnValue = originalFunction.apply(this, arguments);

          callback.apply(this, [returnValue, originalFunction, arguments]);

          return returnValue;
        };
      })(object[functionName]);
    };

    // 页面跳转
    if (loader) {
      hookFunction(loader, "go", (returnValue, originalFunction, arguments) => {
        if (arguments[1]) {
          const { url } = arguments[1];

          save(url);
        }
      });
    }

    // 快速翻页
    if (ui) {
      hookFunction(
        ui,
        "loadReadHidden",
        (returnValue, originalFunction, arguments) => {
          if (arguments && __PAGE) {
            const p = (() => {
              if (arguments[1] & 2) {
                return __PAGE[2] + 1;
              }

              if (arguments[1] & 4) {
                return __PAGE[2] - 1;
              }

              return arguments[0];
            })();

            if (p < 1 || (__PAGE[1] > 0 && p > __PAGE[1])) {
              return;
            }

            const urlParams = new URLSearchParams(window.location.search);

            urlParams.set("page", p);

            const url = `${window.location.origin}${
              window.location.pathname
            }?${urlParams.toString()}`;

            save(url);
          }
        }
      );
    }

    // 显示浏览记录或恢复帖子列表里异常的帖子
    if (ui && ui.topicArg) {
      const execute = () => {
        ui.topicArg.data.forEach((item) => {
          const tid = item[8];
          const postDate = item[12];

          const url = `${window.location.origin}/read.php?tid=${tid}`;

          get(url, (data) => {
            if (postDate > 0) {
              if (SHOW_DIFFRENCE) {
                const replies = parseInt(item[0].innerHTML, 10);
                const rows = data.rows === undefined ? replies : data.rows;

                const diffrence = replies - rows;

                if (diffrence > 0) {
                  const page = Math.ceil(rows / 20);

                  if (page > 1) {
                    item[0].setAttribute("href", `${url}&page=${page}`);
                  }

                  item[0].innerHTML = `${replies}<small>(+${diffrence})</small>`;
                }

                item[1].style.opacity = "0.5";
              }
              return;
            }

            item[1].innerHTML = data.title;
            item[2].innerHTML = "缓存";
            item[3].innerHTML = ui.time2dis(data.timestamp / 1000);
          });
        });
      };

      hookFunction(ui.topicArg, "loadAll", execute);
      execute();
    }
  };

  // 加载菜单项
  (() => {
    GM_registerMenuCommand(
      `浏览记录:${SHOW_DIFFRENCE ? "显示" : "关闭"}`,
      () => {
        GM_setValue(SHOW_DIFFRENCE_KEY, !SHOW_DIFFRENCE);
        location.reload();
      }
    );

    GM_registerMenuCommand(`缓存天数:${EXPIRE_DURATION} 天`, () => {
      const input = prompt("请输入缓存天数(最大1000):", EXPIRE_DURATION);

      if (input) {
        const value = parseInt(input, 10);

        if (value > 0 && value <= 1000) {
          GM_setValue(EXPIRE_DURATION_KEY, value);

          location.reload();
        }
      }
    });

    GM_registerMenuCommand(
      `消息刷新间隔:${REFETCH_NOTIFICATION_INTERVAL} 分钟`,
      () => {
        const input = prompt(
          "请输入消息刷新间隔(单位:分钟,设置为 0 的时候不启用):",
          REFETCH_NOTIFICATION_INTERVAL
        );

        if (input) {
          const value = parseInt(input, 10);

          if (value <= 1440) {
            GM_setValue(REFETCH_NOTIFICATION_INTERVAL_KEY, value);

            location.reload();
          }
        }
      }
    );
  })();

  // 执行脚本
  (() => {
    // 绑定事件
    hook();

    // 删除超时数据
    expire(EXPIRE_DURATION * 24 * 60 * 60 * 1000);

    // 加载UI
    loadUI();

    // 加载消息
    loadMessage();

    // 当前链接地址
    const url = window.location.href;

    // 帖子正常的情况下缓存数据,否则尝试从缓存中读取
    if (isSuccess()) {
      save(url);
    } else {
      load(url, document);
    }
  })();
})(unsafeWindow);