Greasy Fork is available in English.

futaba reverse res search

被引用レスをポップアップ表示・自分の書き込みへのレスを通知しちゃう

// ==UserScript==
// @name         futaba reverse res search
// @namespace    https://github.com/himuro-majika
// @version      1.0.2
// @description  被引用レスをポップアップ表示・自分の書き込みへのレスを通知しちゃう
// @author       himuro_majika
// @license      MIT
// @match        *://*.2chan.net/*/res/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=2chan.net
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_notification
// ==/UserScript==

(() => {
  'use strict';

  // ====オプション====
  const USE_NOTIFICATION = true; //自分の書き込みに新着レスが有ったときに通知を表示する(true/false)
  const NOTIFICATION_TIMEOUT = 5000; //通知の表示時間(ms)
  const MAX_COMMENT_HISTORY_THREAD = 100; //最大レス履歴保存数(スレ)
  const MARKER_CHAR = "★"; //自分の書き込みに付けるマーカーの文字(任意:絵文字も可)
  // ================

  const script_title = "GM_FRRS";
  let qtlist = [];
  let popupOpenTimer;
  let popupCloseTimer;
  let url;
  let isPosted = false;
  let commentHistoryList;
  const startTime = new Date().getTime(); //count parsing time
  init();

  function init() {
    url = getUrl();
    checkLoading();
    makeSelfCommentPicker();
    observeInserted();
    setOnSubmitEvent();
  }

  function initParse() {
    searchSelfComment();
    searchQuotedRes();
    addCounter();
    console.log(script_title + ' - Parsing: ' + ((new Date()).getTime() - startTime) + 'msec'); //log parsing time
  }

  function getUrl() {
    return location.href.match(/^.+:\/\/(.+)/)[1];
  }

  function checkLoading() {
    let loadingTimer = setInterval(() => {
      if (!document.getElementById("futakuro-loading")) {
        initParse();
        clearInterval(loadingTimer);
      }      
    }, 100)
  }

  function getThreImgSrc() {
    let threimg = document.querySelector("#master img");
    if (!threimg) threimg = document.querySelector(".thre > a > img");
    return threimg.src
  }

  function getQuotedRes() {
    return document.querySelectorAll(".thre td blockquote font[color='#789922']");
  }

  function searchQuotedRes(quote) {
    if (arguments.length == 0) {
      quote = getQuotedRes();
    }
    let bqs = document.querySelectorAll(".thre blockquote");
    quote.forEach(item => {
      let qtnum = getResNoFromTdChild(item.parentNode);
      let qtsrcnum = _searchQtSrc(item, qtnum, bqs);
      let qtitemindex = qtlist.findIndex(item => item.qtsrcnum == qtsrcnum);

      if (qtitemindex !== -1 && qtlist[qtitemindex].qtres.findIndex(item => item == qtnum) !== -1) return

      if (qtitemindex !== -1) {
        qtlist[qtitemindex].qtres.push(qtnum);
      } else {
        let qtres = [];
        qtres.push(qtnum);
        let objqt = {
          "qtsrcnum": qtsrcnum,
          "qtres": qtres
        }
        qtlist.push(objqt)
      }
      if (commentHistoryList && commentHistoryList.length > 0) {
        commentHistoryList.forEach(com => {
          if (com.resno == qtsrcnum) {
            highlightResponse(item);
            if (arguments.length > 0) {
              let text = "";
              item.parentNode.childNodes.forEach(node => {
                if (node.nodeName == "#text") {
                  text += node.textContent + "\n";
                }
              });
              showNotification(text);
            }
          }
        })
      }
    });
  }

  function _searchQtSrc(qt, qtnum, bqs) {
    let qtText = qt.innerText.substr(1).trim();
    let qtsrcnum = "0";

    // レスナンバー(No.)
    // /^ *(No)?\.?[0-9]+ *$/
    let matchResNo = qtText.match(/^\s*No\.(\d+)\s*$/);
    if (matchResNo && document.getElementById("delcheck" + matchResNo[1])) {
      qtsrcnum = matchResNo[1];
      return qtsrcnum;
    }

    // 画像ファイル名
    // /^[0-9]+\.(jpg|png|gif|webm|mp4|webp)$/
    let matchFileName = qtText.match(/^\s*(\d+\.(jpg|png|gif|webm|mp4|webp))\s*$/);
    if (matchFileName) {
      let matchFileEle = document.querySelector('.thre a[href$="' + matchFileName[1] + '"]');
      if (matchFileEle) {
        qtsrcnum = getResNoFromTdChild(matchFileEle);
        return qtsrcnum;
      }
    }

    // レス本文
    if (qtText.substr(0,1) == ">") return qtsrcnum;
    const qtresnum = parseInt(qt.parentNode.parentNode.querySelector(".rsc").textContent);
    for (let i = qtresnum - 1; i > 0; i--) {
      let t = "";
      bqs[i].childNodes.forEach(node => {
        if (node.nodeName == "#text") {
          t += node.textContent;
        }
      });
      if (t.indexOf(qtText) >= 0) {
        qtsrcnum = getResNoFromTdChild(bqs[i].parentNode);
        if (qtsrcnum == qtnum) qtsrcnum = "0";
        break;
      }
    }

    return qtsrcnum;
  }

  // 被引用数の表示
  function addCounter() {
    let existedCounter = document.querySelectorAll("." + script_title + "_Counter");
    existedCounter.forEach(item => {
      item.remove();
    })
    let cno = document.querySelectorAll(".cno");
    cno.forEach(no => {
      let num = no.textContent.match(/\d+$/);
      let qtindex = qtlist.findIndex(item => item.qtsrcnum == num);
      if (qtindex == -1) return;
      let qtcount = qtlist[qtindex].qtres.length;
      const counter = document.createElement("a");

      counter.innerText = qtcount + "レス";
      counter.classList.add(script_title + "_Counter");
      counter.style.color = "#117743";
      counter.style.marginLeft = "0.5em";
      counter.setAttribute(script_title + "_num", num);


      counter.addEventListener("mouseenter", popupQuoteRes);
      counter.addEventListener("mouseleave", removePopup);
      no.parentNode.insertBefore(counter, no);
    });
  }

  // 被引用レスのポップアップ
  function popupQuoteRes(e) {
    if (!this.closest("." + script_title + "_popup")) {
      removePopupAll();
    }
    clearTimeout(popupCloseTimer);
    clearTimeout(popupOpenTimer);
    popupOpenTimer = setTimeout(() => {
      let srcnum = this.getAttribute(script_title + "_num");
      let qtitem = qtlist.find(item => item.qtsrcnum == srcnum);
      let qtres = qtitem.qtres;
      let resListContainer = makePopupContainer();

      let xpos = this.getBoundingClientRect().left + window.scrollX - 20;
      let ypos = this.getBoundingClientRect().bottom + window.scrollY;
      resListContainer.style.top = ypos.toString() + "px";
      if (xpos + 500 > window.innerWidth) {
        resListContainer.style.right = "20px";
      } else {
        resListContainer.style.left = xpos.toString() + "px";
      }

      let resListTable = setPopupContent(qtres);

      resListContainer.appendChild(resListTable);
        document.querySelector("div.thre").appendChild(resListContainer);
    }, 200);
  }

  function setPopupContent(resnolist) {
    if (resnolist.length == 0) {
      let noEle = document.createElement("div");
      noEle.textContent = "該当レスがありません。";
      return noEle;
    }
    let resListTable = document.createElement("table");
    let resListTbody = document.createElement("tbody");

    resnolist.forEach(res => {
      let td = document.getElementById("delcheck" + res).parentNode.cloneNode(true);
      let rsc = td.querySelector(".rsc");
      rsc.classList.add("qtjmp");
      let jumpid = rsc.id;
      rsc.removeAttribute("id");
      rsc.addEventListener("click", () => {
        let jumptarget = document.getElementById(jumpid).parentNode;
        window.scroll(0, jumptarget.getBoundingClientRect().top + window.pageYOffset);
        removePopup();
      });

      const counter = td.querySelector("." + script_title + "_Counter");
      if (counter) {
        counter.addEventListener("mouseenter", popupQuoteRes);
        counter.addEventListener("mouseleave", removePopup);
      }

      const futakuro_resno = td.querySelector(".res_no");
      if (futakuro_resno) futakuro_resno.style.display = "none";

      if (checkAkahukuEnabled()) {
        // resListContainer.setAttribute("__akahuku_reply_popup_index", resno);
        let gtdiv = document.createElement("div");
        gtdiv.classList.add("akahuku_popup_content_blockquote");
        let bq = td.querySelector("blockquote");
        gtdiv.innerHTML = bq.innerHTML;
        bq.remove();
        td.appendChild(gtdiv);
      }
      let resListTr = document.createElement("tr");
      resListTr.appendChild(td);
      resListTbody.appendChild(resListTr);
    })
    resListTable.appendChild(resListTbody);

    return resListTable;
  }

  function makePopupContainer() {
    let container = document.createElement("div");
    container.classList.add(script_title + "_popup");
    if (checkAkahukuEnabled()) {
      container.classList.add("akahuku_reply_popup");
    } else {
      container.style.backgroundColor = "#F0E0D6";
      container.style.boxShadow = "1px 1px 3px 1px #777";
      container.style.borderRadius = "5px";
      container.style.fontSize = "0.85em";
    }
    container.style.position = "absolute";
    container.style.zIndex = 302;
    container.addEventListener("mouseenter", () => {
      clearTimeout(popupCloseTimer);
    });
    container.addEventListener("mouseleave", removePopup);
    return container;
  }

  function removePopup(souceEl) {
    clearTimeout(popupCloseTimer);
    clearTimeout(popupOpenTimer);
    if (!souceEl) return;
    if (!souceEl.relatedTarget) return;
    if (souceEl.srcElement.className == "GM_FRRS_Counter" && 
      souceEl.relatedTarget.closest(".GM_FRRS_popup")) return;
    if (souceEl.srcElement.classList.contains("GM_FRRS_popup") && 
      souceEl.relatedTarget.closest(".GM_FRRS_popup")) {
      removeForwardPopupSibling(souceEl.relatedTarget.closest(".GM_FRRS_popup"));
      return;
    }
    popupCloseTimer = setTimeout(() => {
      removePopupAll();
    }, 500);
  }

  function removePopupAll() {
    let popup = document.querySelectorAll("." + script_title + "_popup");
    if (!popup) return;
    popup.forEach(p => {
      p.remove();
    })
  }

  function removeForwardPopupSibling(ele) {
    if (!ele.nextElementSibling) return;
    let nextsibling = ele.nextElementSibling;
    if (nextsibling.classList.contains("GM_FRRS_popup")) {
      nextsibling.remove();
    } else {
      return;
    }
    removeForwardPopupSibling(ele);
  }

  // 続きを読むで挿入される要素を監視
  function observeInserted() {
    let target = document.querySelector(".thre");
    if (!target) return;
    let observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (!mutation.addedNodes.length) return;
        if (mutation.addedNodes[0].className == script_title + "_popup") return;
        if (mutation.addedNodes[0].querySelector(".rtd")) {
          refreshCounter(mutation.addedNodes);
        }
      });
      if (isPosted) {
        isPosted = false;
        selfComment(mutations);
      }
    });
    observer.observe(target, {
      childList: true
    });
  }

  function refreshCounter(nodes) {
    let qt = nodes[0].querySelectorAll(".thre td blockquote font[color='#789922']");
    if (!qt.length) return;
    searchQuotedRes(qt);
    addCounter();
  }

  // レス投稿時のイベント設定
  function setOnSubmitEvent() {
    let formEle = document.getElementById("fm");

    formEle.addEventListener("submit", () => {
      onCommentSend();
    });
    let button = formEle.querySelector("input[type='submit'");
    if (button) {
      button.addEventListener("click", () => {
        onCommentSend();
      });
    }
  }

  function onCommentSend() {
    if (isPosted) return;
    isPosted = true;
    let textbody = document.getElementById("ftxa").value.trim();
    storeCommentHistory(textbody);
  }

  // 書き込み履歴の保存
  function storeCommentHistory(commentText) {
    if (typeof(commentText) !== "string") return;

    commentHistoryList = getCommentHistory();

    let comment = {
      "comment": commentText,
      "resno": ""
    }
    if (!commentHistoryList) {
      commentHistoryList = [];
    }
    commentHistoryList.push(comment);
    
    setCommentHistory(commentHistoryList);
    console.log(commentHistoryList);
    setTimeout(() => {
      expireCommentHistory();
    }, 5000);
  }

  function getCommentHistory() {
    let commentHistory = getValue(url);
    return commentHistory;
  }

  function setCommentHistory(commenthistory) {
    setValue(url, commenthistory);
    return;
  }

  function searchSelfComment() {
    let listUpdatedFlag = false;
    commentHistoryList = getCommentHistory();
    if (!commentHistoryList) return;
    commentHistoryList.forEach(element => {
      let elresno = element.resno;
      if (elresno !== "") {
        let sd = document.getElementById("sd" + elresno);
        if (!sd) return;
        highlightOwnRes(sd);
      }
      let comment = element.comment;
      if (elresno == "" && comment) {
        console.log(comment);
        let bq = document.querySelectorAll(".thre .rtd blockquote");
        bq.forEach(item => {
          let bqtext = item.innerText;
          if (comment == bqtext) {
            let bqresno = getResNoFromTdChild(item);
            let itemsd = document.getElementById("sd" + bqresno);
            if (!itemsd) return;
            highlightOwnRes(itemsd);
            element.resno = bqresno;
            listUpdatedFlag = true;
            // console.log(bqresno);
          }
        })
      }
    });
    if (listUpdatedFlag) {
      setCommentHistory(commentHistoryList);
    }
    // console.log(qtlist);
  }

  function selfComment(mutations) {
    commentHistoryList = getCommentHistory();
    if (!commentHistoryList) return;
    if (commentHistoryList[commentHistoryList.length - 1].resno !== "") return;
    let latestStoredComment = commentHistoryList[commentHistoryList.length - 1].comment;
    let hitres;
    mutations.forEach(mutation => {
      if (mutation.addedNodes.length && mutation.addedNodes[0].querySelector(".rtd")) {
        let table = mutation.addedNodes[0];
        let bq = table.querySelector("blockquote");
        if (!bq) return;
        let bqtext = bq.innerText;
        if (latestStoredComment === "") {
          if (bqtext !== "キタ━━━(゚∀゚)━━━!!" &&
              bqtext !== "キタ━━━━━━(゚∀゚)━━━━━━ !!!!!" &&
              bqtext !== "本文無し") {
            return;
          }
        } else if (bqtext !== latestStoredComment) {
          return;
        }
        commentHistoryList[commentHistoryList.length - 1].resno = getResNoFromTdChild(bq);
        hitres = table;
      }
    });
    if (!hitres) return;
    // console.log(hitres);
    setCommentHistory(commentHistoryList);
    let sd = hitres.querySelector(".sod");
    highlightOwnRes(sd);
    // console.log(commentHistoryList);
  }

  function highlightOwnRes(node) {
    if (node.parentNode.querySelector("." + script_title + "_own_res")) return;
    let marker = document.createElement("span");

    marker.innerText = MARKER_CHAR;
    marker.classList.add(script_title + "_own_res");
    // marker.style.fontWeight = "bold";
    marker.style.color = "#117743";
    marker.style.cursor = "pointer";
    marker.addEventListener("click", () => {
      let selfResList = document.querySelectorAll("." + script_title + "_own_res");
      popupSelfCommentList(selfResList);
    });
    let rsc = node.parentNode.querySelector(".rsc");
    let futakuroResNo = node.parentNode.querySelector(".res_no");
    if (futakuroResNo) {
      rsc = futakuroResNo;
    }
    rsc.style.color = "#1b54ff";
    rsc.style.fontWeight = "bold";
    rsc.style.fontSize = "smaller";
    rsc.style.cursor = "pointer";
    rsc.addEventListener("click", () => {
      let selfResList = document.querySelectorAll("." + script_title + "_own_res");
      popupSelfCommentList(selfResList);
    });
    node.parentNode.insertBefore(marker, node.previousSibling);
  }

  // 自分の書き込みへのレスをハイライト
  function highlightResponse(bq) {
    bq.parentNode.classList.add(script_title + "_response");
    let rsc = bq.parentNode.parentNode.querySelector(".rsc");
    let futakuroResNo = bq.parentNode.parentNode.querySelector(".res_no");
    if (futakuroResNo) {
      rsc = futakuroResNo;
    }
    rsc.style.color = "#ff0078";
    rsc.style.fontWeight = "bold";
    rsc.style.fontSize = "smaller";
    rsc.style.cursor = "pointer";
    rsc.addEventListener("click", () => {
      let selfResList = document.querySelectorAll("." + script_title + "_response");
      document.getElementById(script_title + "_new_comment").style.color = "#0040ee";
      popupSelfCommentList(selfResList);
    });
  }

  // 自分の書き込み一覧ポップアップ
  function makeSelfCommentPicker() {
    let commentPickerContainer = document.createElement("div");
    commentPickerContainer.id = script_title + "_comment_picker_container";
    commentPickerContainer.style.fontSize = "9pt";

    let commentPicker = document.createElement("a");
    commentPicker.id = script_title + "_self_comment_picker";
    commentPicker.textContent = "📑[自分の書き込み]";
    commentPicker.style.color = "#0040ee";
    commentPicker.style.cursor = "pointer";
    commentPicker.addEventListener("click", () => {
      let selfResList = document.querySelectorAll("." + script_title + "_own_res");
      popupSelfCommentList(selfResList);
    });
    let newComment = document.createElement("a");
    newComment.id = script_title + "_new_comment";
    newComment.textContent = "[書き込みへのレス]";
    newComment.style.cursor = "pointer";
    newComment.style.color = "#0040ee";
    newComment.addEventListener("click", () => {
      let selfResList = document.querySelectorAll("." + script_title + "_response");
      document.getElementById(script_title + "_new_comment").style.color = "#0040ee";
      popupSelfCommentList(selfResList);
    });
    commentPickerContainer.appendChild(commentPicker);
    commentPickerContainer.appendChild(newComment);

    let pwd = document.getElementById("usercounter");
    pwd.parentNode.insertBefore(commentPickerContainer, pwd);
  }

  function popupSelfCommentList(selfResList) {
    let popup = document.getElementById(script_title + "_own_res_popup");
    if (popup) {
      popup.remove();
      return;
    }
    let container = makeSelfCommentListContainer();
    let selfResNoList = [];
    selfResList.forEach(res => {
      selfResNoList.push(getResNoFromTdChild(res));
    });
    let qttable = setPopupContent(selfResNoList);
    container.appendChild(qttable);
    document.querySelector("html body").appendChild(container);
  }

  function makeSelfCommentListContainer() {
    let container = document.createElement("div");
    container.id = script_title + "_own_res_popup";
    if (checkAkahukuEnabled()) {
      container.classList.add("akahuku_reply_popup");
    } else {
      container.style.boxShadow = "1px 1px 3px 1px #777";
      container.style.borderRadius = "5px";
      container.style.fontSize = "0.85em";
    }
    container.style.position = "fixed";
    container.style.right = "10px";
    container.style.bottom = "400px";
    container.style.zIndex = "301";
    container.style.overflowY = "scroll";
    container.style.maxHeight = "65vh";
    container.style.maxWidth = "65em";

    return container;
  }

  function showNotification(text) {
    const newRes = document.getElementById(script_title + "_new_comment");
    newRes.style.display = "";
    newRes.style.color = "#F00";

    if (!USE_NOTIFICATION) return;

    GM_notification({
      title: "書き込みに新しいレスがあります",
      image: getThreImgSrc(),
      text: text,
      timeout: NOTIFICATION_TIMEOUT,
      onclick: () =>{
        // console.log("notification clicked");
      }
    });
  }

  function getResNoFromTdChild(ele) {
    let cno = ele.parentNode.querySelector(".cno");
    let resno = cno.textContent.replace("No.", "");
    return resno;
  }

  function checkAkahukuEnabled() {
    return document.getElementById("akahuku_postform") != null
  }

  function checkFutakuroEnabled() {
    return document.getElementById("postform") != null;
  }

  function expireCommentHistory() {
    const historyList = getListValues();
    // console.log(historyList);
    if (historyList.length > MAX_COMMENT_HISTORY_THREAD) {
      for (let i = 0; i < historyList.length - MAX_COMMENT_HISTORY_THREAD; i++) {
        deleteValue(historyList[i]);
        console.log(script_title + " expire comment history: " + historyList[i]);
      }
    }
  }

  function getValue(name) {
    if (!name) return;
    try {
      let val = GM_getValue(name);
      if (!val) return 0;
      // console.log(val);
      return val;
    } catch(e) {
      console.log(e);
      return 0;
    }
  }

  function setValue(name, val) {
    if (!name || !val) return;
    try {
      GM_setValue(name, val);
    } catch(e) {
      console.log(e);
      return;
    }
  }

  function getListValues() {
    try {
      const gmlistvalues = GM_listValues();
      if (!gmlistvalues) return;
      return gmlistvalues;
    } catch (e) {
      console.log(e);
      return;
    }
  }

  function deleteValue(name) {
    if (!name) return;
    try {
      GM_deleteValue(name);
      return;
    } catch (e) {
      console.log(e);
      return 0;
    }
  }
})();