5ch.net donguri Hit Response Getter

Fetches and filters hit responses from donguri 5ch boards

目前為 2024-06-09 提交的版本,檢視 最新版本

// ==UserScript==
// @name        5ch.net donguri Hit Response Getter
// @namespace   https://gf.qytechs.cn/users/1310758
// @description Fetches and filters hit responses from donguri 5ch boards
// @match       *://donguri.5ch.net/cannonlogs
// @match       *://*.5ch.net/test/read.cgi/*/*
// @connect     5ch.net
// @license     MIT License
// @author      pachimonta
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @version     2024-06-10_002
// ==/UserScript==
(function() {
  'use strict';

  const manual = `<ul>外部リンク:
  <li><a href="https://nanjya.net/donguri/" target="_blank">5chどんぐりシステムの備忘録</a> どんぐりシステムに関する詳細。</li>
  <li><a href="http://dongurirank.starfree.jp/" target="_blank">どんぐりランキング置き場</a> 大砲ログの過去ログ検索やログ統計など。</li>
</ul>
<ul><b>UserScriptの説明:</b>
  <li>以下の入力欄に<code>bbs=poverty</code> のように入力すると、該当するログだけを表示します。</li>
  <li>カンマ(<code>,</code>) で区切ることで複数の条件が指定できます。</li>
  <li>URLのハッシュ(<code>#</code>)より後に条件を指定することでも機能します。</li>
  <li>大砲ログの<em>各セルをダブルクリック</em>して、その内容の<em>条件を追加</em>できます。</li>
  <li><em>列ヘッダーをクリック</em>で<em>ソートします。</li>
</ul>`;

  const DONGURI_LOG_CSS = `
    body {
      margin: 0;
      padding: 12px;
      display: block;
    }
    table { white-space: nowrap; }
    thead {
      position: sticky;
      top: 0;
      z-index: 1;
    }
    th:not([colspan]):hover, td:hover { background: #ccc; }
    th:not([colspan]):active, td:active { background: #ff9; }
    th, td { font-size: 15px; }
    th:not([colspan]) { position: relative; }
    th.sortOrder1::after { content: "▲"; }
    th.sortOrder-1::after { content: "▼"; }
    th[class^=sortOrder]::after {
      font-size: 0.5em;
      opacity: 0.5;
      vertical-align: super;
      position: absolute;
      top: 0;
      left: 50%;
      transform: translateX(-50%);
    }
    td a:visited { color: #808; }
    td a:hover { color: #000; }
    .toggleDisplay {
      position: fixed;
      bottom: 10px;
      right: 30px;
      opacity: 0.7;
    }
  `;

  const READ_CGI_CSS = `
    .dongurihit:target {
      background: #fff;
      color: #000;
    }
    .dongurihit:target * {
      background: #fff;
      color: #000;
    }
  `;

  // Helper functions
  const $ = (selector, context = document) => context.querySelector(selector);
  const $$ = (selector, context = document) => [...context.querySelectorAll(selector)];

  // Scroll and highlight the relevant post in read.cgi
  const readCgiJump = () => {
    GM_addStyle(READ_CGI_CSS);
    const waitForTabToBecomeActive = () => {
      return new Promise((resolve) => {
        if (document.visibilityState === 'visible') {
          resolve();
        } else {
          const handleVisibilityChange = () => {
            if (document.visibilityState === 'visible') {
              document.removeEventListener('visibilitychange', handleVisibilityChange);
              resolve();
            }
          };
          document.addEventListener('visibilitychange', handleVisibilityChange);
        }
      });
    };
    const scrollActive = () => {
      const hashIsNumber = location.hash.match(/^#(\d+)$/) ? location.hash.substring(1) : null;
      const [dateymd, datehms] = (location.hash.match(/(?:&|#)date=([^&=]{10})([^&=]+)/) || [null, null, null]).slice(1);

      if (!hashIsNumber && !dateymd) {
        return;
      }

      $$('.date').some(dateElement => {
        const post = dateElement.closest('.post');
        if (!post) {
          return false;
        }

        const isMatchingPost = post.id === hashIsNumber || (dateymd && dateElement.textContent.includes(dateymd) && dateElement.textContent.includes(datehms));
        if (!isMatchingPost) {
          return false;
        }

        post.classList.add('dongurihit');
        if (post.id && location.hash !== `#${post.id}`) {
          location.hash = `#${post.id}`;
          history.pushState({
            scrollY: window.scrollY
          }, '');
          history.go(-1);
          return true;
        }

        const observer = new IntersectionObserver(entries => {
          waitForTabToBecomeActive().then(() => {
            entries.forEach(entry => {
              if (entry.isIntersecting) {
                setTimeout(() => post.classList.remove('dongurihit'), 1500);
              }
            });
          });
        });
        observer.observe(post);
        return true;
      });
    };

    if (!window.donguriInitialized) {
      window.addEventListener('hashchange', scrollActive);
      window.donguriInitialized = true;
    }
    const scrollToElementWhenActive = () => {
      waitForTabToBecomeActive().then(() => {
        scrollActive();
      });
    };
    scrollToElementWhenActive();
    return;
  };

  // Filter Acorn Cannon Logs
  const donguriFilter = () => {
    GM_addStyle(DONGURI_LOG_CSS);

    // Create a checkbox to toggle the display between the original table and the UserScript-generated table
    const toggleDisplayCheckbox = Object.assign(document.createElement('input'), {
      type: 'checkbox',
      checked: 'checked',
      id: 'toggleDisplay'
    });
    const toggleDisplayLabel = Object.assign(document.createElement('label'), {
      htmlFor: 'toggleDisplay',
      textContent: 'Toggle Table'
    });
    const toggleDisplayContainer = Object.assign(document.createElement('div'), {
      className: 'toggleDisplay'
    });
    toggleDisplayContainer.append(toggleDisplayCheckbox, toggleDisplayLabel);
    $('body').append(toggleDisplayContainer);

    // Storage for bbs list and post titles list
    const bbsOriginList = {};
    const bbsNameList = {};
    // post titles
    const subjectList = {};
    // Index list of tbody tr selectors for each BBS
    const donguriLogBbsRows = {};
    // Number of attempted requests to lastmodify.txt
    const attemptedXhrBBS = new Set();
    const completedXhrBBS = new Set();
    const columnSelector = {};
    const columns = {
      "order":"順",
      "term":"期",
      "date":"date(投稿時刻)",
      "bbs":"bbs",
      "bbsname":"bbs名",
      "key":"key",
      "id":"ハンターID",
      "hunter":"ハンター名",
      "target":"ターゲット",
      "subject":"subject"
    };
    Object.keys(columns).forEach((key, i) => {
      columnSelector[key] = `td:nth-of-type(${i + 1})`;
    });
    const columnKeys = Object.keys(columns);
    const columnValues = Object.values(columns);
    const originalTermSelector = 'td:nth-of-type(1)';
    const originalLogSelector = 'td:nth-of-type(2)';
    let completedRows = 0;

    const table = $('table');
    if (!table) {
      return false;
    }
    const thead = $('thead', table);
    const tbody = $('tbody', table);
    const originalTable = Object.assign(table.cloneNode(true), {
      className: 'originalLog'
    });

    // Switch between original and UserScript display depending on checkbox state
    toggleDisplayCheckbox.addEventListener('change', (event) => {
      if (event.target.checked) {
        // Change display to UserScript
        $('table.originalLog').setAttribute('hidden', 'hidden');
        table.removeAttribute('hidden');
      } else {
        // Change to original display
        table.setAttribute('hidden', 'hidden');
        if (!$('table.originalLog')) {
          table.insertAdjacentElement('afterend', originalTable);
        }
        $('table.originalLog').removeAttribute('hidden');
      }
    });

    const addWeekdayToDatetime = (datetimeStr) => {
      const firstColonIndex = datetimeStr.indexOf(':');
      const splitIndex = firstColonIndex - 2;
      const datePart = datetimeStr.slice(0, splitIndex);
      const timePart = datetimeStr.slice(splitIndex);
      const [year, month, day] = datePart.split('/').map(Number);
      const date = new Date(year, month - 1, day);
      const weekdays = ['日', '月', '火', '水', '木', '金', '土'];
      const weekday = weekdays[date.getDay()];
      return `${datePart}(${weekday}) ${timePart}`;
    };

    const appendCell = (tr, txt = null, id = null) => {
      const e = tr.appendChild(document.createElement(tr.parentElement.tagName === 'THEAD' ? 'th' : 'td'));
      if (txt !== null) {
        e.textContent = txt;
      }
      if (id !== null) {
        e.id = id;
      }
      return e;
    };

    if (!$('tr th:nth-of-type(1)', thead)) {
      return false;
    }
    // 順,期,date(投稿時刻),bbs,bbs名,key,ハンターID,ハンター名,ターゲット,subject
    // order,term,date,bbs,bbsname,key,id,hunter,target,subject
    const tr = $('tr:nth-of-type(1)', thead);
    columnValues.slice(0, 2).forEach((txt, i) => {
      const th = $(`th:nth-of-type(${i + 1})`, tr);
      th.textContent = txt;
      th.removeAttribute('style');
    });
    columnValues.slice(2).forEach(txt => appendCell(tr, txt));

    table.insertAdjacentHTML('beforebegin', manual);
    const headers = $$('th', thead);
    const rows = $$('tr', tbody);

    let sortOrder = 1; // 1: 自然順, -1: 逆順
    let lastIndex = null;
    let lastSortOrder = null;
    const rsortKeys = ['term', 'date', 'key'];
    // 各列ヘッダーにクリックイベントを設定
    headers.forEach((header, index) => {
      header.addEventListener('click', (e) => {
        if (headers[lastIndex] && headers[lastIndex].classList) {
          headers[lastIndex].classList.remove(`sortOrder${lastSortOrder}`);
        }
        if (lastIndex !== index) {
          lastIndex = index;
          sortOrder = rsortKeys.indexOf(columnKeys[index]) === -1 ? 1 : -1;
        }

        lastSortOrder = sortOrder;
        // クリックされた列のインデックスに基づいてソート
        rows.sort((rowA, rowB) => {
          const cellA = rowA.cells[index].textContent;
          const cellB = rowB.cells[index].textContent;

          // テキストで自然順ソート
          return cellA.localeCompare(cellB, 'ja', {
            numeric: true
          }) * sortOrder;
        });
        e.target.classList.add(`sortOrder${sortOrder}`);

        // ソート順を反転
        sortOrder *= -1;

        // ソート済みの行をtbodyに再配置
        rows.forEach(row => tbody.appendChild(row));
      });
    });

    const rloRegex = /[\x00-\x1F\x7F\u200E\u200F\u202A\u202B\u202C\u202D\u202E]/g;

    const sanitizeText = (content) => {
      return content.replace(rloRegex, match => `[U+${match.codePointAt(0).toString(16).toUpperCase()}]`);
    };
    // Regular expression to detect and replace unwanted characters
    const replaceTextRecursively = (element) => {
      element.childNodes.forEach(node => {
        if (node.nodeType === Node.TEXT_NODE) {
          node.textContent = sanitizeText(node.textContent);
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          replaceTextRecursively(node);
        }
      });
    };

    const initialRows = $$('tr', tbody);
    // Number of 'tbody tr' selectors
    const rowCount = initialRows.length;
    const userLogRegex = /^(.*)?さん\[([a-f0-9]{8})\]は(.*(?:\[[a-f0-9]{4}\*\*\*\*\])?)?さんを(?:[撃打]っ|外し)た$/u;
    // Expand each cell in the tbody
    initialRows.forEach((row, i) => {
      replaceTextRecursively(row);
      const log = $(originalLogSelector, row).textContent.trim();
      const verticalPos = log.lastIndexOf('|');
      const [bbs, key, date] = log.slice(verticalPos + 2).split(' ', 3);
      if (Object.hasOwn(donguriLogBbsRows, bbs) === false) {
        donguriLogBbsRows[bbs] = [{index:i,key:key}];
      } else {
        donguriLogBbsRows[bbs].push({index:i,key:key});
      }
      row.dataset.order = i + 1;
      row.dataset.term = $(originalTermSelector, row).textContent.trim().slice(1, -1);
      Object.assign(row.dataset, {
        date,
        bbs,
        key,
        log
      });
      [row.dataset.hunter, row.dataset.id, row.dataset.target] = log.slice(0, verticalPos - 1).match(userLogRegex).slice(1, 4);

      // columns
      $(columnSelector.term, row).textContent = $(originalTermSelector, row).textContent;
      $(columnSelector.order, row).textContent = row.dataset.order;
      appendCell(row, addWeekdayToDatetime(date));
      appendCell(row, bbs);
      appendCell(row);
      appendCell(row, key);
      appendCell(row, row.dataset.id);
      appendCell(row, row.dataset.hunter);
      appendCell(row, row.dataset.target);
      appendCell(row);
    });

    // Sanitize user input
    const sanitize = (value) => value.replace(/[^a-zA-Z0-9_:/.\-]/g, '');

    const filterSplitRegex = /\s*,\s*/;
    const noSanitizeKeys = ['log','bbsname','hunter','target','subject'];
    const equalValueKeys = ['term','bbs'];
    const includesValueKeys = ['log','bbsname','subject','date'];

    // Update elements visibility based on filtering criteria
    const filterRows = (val) => {
      let count = 0;
      const rows = $$('tr', tbody);
      const total = rows.length;
      const value = val.trim();

      if (!value) {
        rows.forEach(row => row.removeAttribute('hidden'));
        $('#myfilterResult').textContent = `${total} 件 / ${total} 件中`;
        return;
      }

      const criteria = value.split(filterSplitRegex).map(item => item.split('=')).reduce((acc, [key, val]) => {
        if (key && val) {
          acc[key.trim()] = noSanitizeKeys.indexOf(key) > -1 ? val.trim() : sanitize(val.trim());
        }
        return acc;
      }, {});

      rows.forEach(row => {
        const isVisible = Object.entries(criteria).every(([key, val]) => {
          if (key === 'ita') {
            key = 'bbs';
          }
          if (key === 'dat') {
            key = 'key';
          }
          if (!row.hasAttribute(`data-${key}`)) {
            return false;
          }

          if (equalValueKeys.indexOf(key) > -1) {
            return row.getAttribute(`data-${key}`) === val;
          } else if (includesValueKeys.indexOf(key) > -1) {
            return row.getAttribute(`data-${key}`).includes(val);
          } else {
            return row.getAttribute(`data-${key}`).indexOf(val) === 0;
          }
        });

        if (isVisible) {
          count++;
          row.removeAttribute('hidden');
        } else {
          row.setAttribute('hidden', 'hidden');
        }
      });

      $('#myfilterResult').textContent = `${count} 件 / ${total} 件中`;
    };

    // Insert the data of each BBS thread list
    const insertCells = (bbs) => {
      for (let obj of donguriLogBbsRows[bbs]) {
        ++completedRows;
        const { index, key } = obj;
        const row = initialRows[index];
        if (Object.hasOwn(row.dataset, 'subject') === true && row.dataset.subject.length) {
          continue;
        }
        const { date, origin } = row.dataset;
        const subject = subjectList[bbs][key] || "???";
        Object.assign(row.dataset, { subject });
        const anchor = Object.assign(document.createElement('a'), {
          href: `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`,
          target: '_blank',
          textContent: subject
        });
        $(columnSelector.subject, row).insertAdjacentElement('beforeend', anchor);
      }
      // After inserting all cells
      if (completedRows === rowCount) {
        filterRows($('#myfilter').value);
      }
    };

    const insertCellsNotCount = (bbs) => {
      for (let obj of donguriLogBbsRows[bbs]) {
        const { index, key } = obj;
        if (Object.hasOwn(subjectList[bbs], key) === false) {
          continue;
        }
        const row = initialRows[index];
        const { date, origin } = row.dataset;
        const subject = subjectList[bbs][key];
        Object.assign(row.dataset, { subject });
        const anchor = Object.assign(document.createElement('a'), {
          href: `${origin}/test/read.cgi/${bbs}/${key}/?v=pc#date=${date}`,
          target: '_blank',
          textContent: subject
        });
        $(columnSelector.subject, row).insertAdjacentElement('beforeend', anchor);
      }
    };

    const insertBbsnameCells = (bbs) => {
      for (let obj of donguriLogBbsRows[bbs]) {
        const { index } = obj;
        const row = initialRows[index];
        const origin = bbsOriginList[bbs] || "https://origin";
        const bbsName = bbsNameList[bbs] || "???";
        Object.assign(row.dataset, {
          origin,
          bbsname: bbsName
        });
        $(columnSelector.bbsname, row).textContent = bbsName;
      }
    };

    // Initialize the filter input and its functionalities
    const createFilterInput = () => {
      const input = Object.assign(document.createElement('input'), {
        type: 'text',
        id: 'myfilter',
        placeholder: 'Filter (e.g., bbs=av, key=1711038453, date=06/01(土) 01:55, id=ac351e30, log=abesoriさん[97a65812])',
        style: 'width: 100%; padding: 5px; margin-bottom: 10px;'
      });

      input.addEventListener('input', () => {
        location.hash = `#${input.value}`;
        return;
      });
      const tr = document.createElement('tr');
      const th = document.createElement('th');
      th.setAttribute('colspan',columnKeys.length);
      th.appendChild(input);
      th.insertAdjacentHTML('afterbegin', '<b id=myfilterResult></b><br>');
      tr.appendChild(th);
      thead.insertAdjacentElement('afterbegin', tr);

      if (location.hash) {
        input.value = decodeURIComponent(location.hash.substring(1));
      }

      window.addEventListener('hashchange', () => {
        input.value = decodeURIComponent(location.hash.substring(1));
        filterRows(input.value);
      });
    };

    // GM_xmlhttpRequest wrapper to handle HTTP Get requests
    const xhrGetDat = (url, loadFunc, mime = 'text/plain; charset=shift_jis') => {
      console.time(url);
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        timeout: 3600 * 1000,
        overrideMimeType: mime,
        onload: response => loadFunc(response),
        onerror: error => console.error('An error occurred during the request:', error)
      });
    };

    const arrayContainsArray = (superset, subset) => {
      return subset.every(value => superset.includes(value));
    };
    const arrayDifference = (array1, array2) => {
      return array1.filter(value => !array2.includes(value));
    };

    const parser = new DOMParser();
    const htmlEntityRegex = /&#?[a-zA-Z0-9]+;?/;
    const crlfRegex = /[\r\n]+/;
    const logSplitRegex = /\s*<>\s*/;

    // Process post titles line to update subjectList and modify the table-cells
    const addBbsPastInfo = (response) => {
      console.timeEnd(response.finalUrl);
      if (response.status !== 200) {
        console.error('Failed to load data. Status code:', response.status);
        return false;
      }

      const url = response.finalUrl;
      const pathname = new URL(url).pathname;
      const slashIndex = pathname.indexOf('/');
      const secondSlashIndex = pathname.indexOf('/', slashIndex+1);
      const bbs = pathname.substring(slashIndex+1,secondSlashIndex);
      completedXhrBBS.add(bbs);
      const html = parser.parseFromString(response.responseText, 'text/html').documentElement;
      $$('[class="main_odd"],[class="main_even"]', html).forEach(p => {
        let [key, subject] = [ $('.filename', p).textContent, $('.title', p).textContent ];
        if (key.includes('.')) { key = key.substring(0, key.lastIndexOf('.')); }
        if (Object.hasOwn(subjectList[bbs], key) === true) {
          return;
        }
        subjectList[bbs][key] = subject;
      });
      if (arrayContainsArray(Object.keys(subjectList[bbs]), [...new Set(donguriLogBbsRows[bbs].map(item => item.key))]) === false) {
        console.info("Subject not found. bbs: %s, key: %s", bbs, arrayDifference([...new Set(donguriLogBbsRows[bbs].map(item => item.key))], Object.keys(subjectList[bbs])));
      }
      insertCells(bbs);
    };

    // Process post titles line to update subjectList and modify the table-cells
    const addBbsInfo = (response) => {
      console.timeEnd(response.finalUrl);
      if (response.status !== 200) {
        console.error('Failed to load data. Status code:', response.status);
        return false;
      }

      const url = response.finalUrl;
      const lastSlashIndex = url.lastIndexOf('/');
      const secondLastSlashIndex = url.lastIndexOf('/', lastSlashIndex - 1);
      const bbs = url.substring(secondLastSlashIndex + 1, lastSlashIndex);
      completedXhrBBS.add(bbs);
      const lastmodify = response.responseText;
      subjectList[bbs] = {};
      lastmodify.split(crlfRegex).forEach(line => {
        let [key, subject] = line.split(logSplitRegex, 2);
        if (key.includes('.')) { key = key.substring(0, key.lastIndexOf('.')); }
        if (htmlEntityRegex.test(subject)) {
          subject = parser.parseFromString(subject, 'text/html').documentElement.textContent;
        }
        subjectList[bbs][key] = subject;
      });
      // All subjects corresponding to the keys in the cell were confirmed
      if (arrayContainsArray(Object.keys(subjectList[bbs]), [...new Set(donguriLogBbsRows[bbs].map(item => item.key))])) {
        insertCells(bbs);
      } else {
        insertCellsNotCount(bbs);
        // Check past log
        xhrGetDat(new URL("./kako/", url), addBbsPastInfo, 'text/plain; charset=utf-8');
      }
    };

    // Function to process post titles by XHRing lastmodify.txt from the BBS list in the donguri log table
    const xhrBbsInfoFromDonguriRows = () => {
      for (let bbs of Object.keys(donguriLogBbsRows)) {
        const url = `${bbsOriginList[bbs]}/${bbs}/lastmodify.txt`;
        attemptedXhrBBS.add(bbs);
        xhrGetDat(url, addBbsInfo);
      }
      table.addEventListener('dblclick', function(event) {
        event.preventDefault();
        if (!$('#myfilter')) {
          return;
        }
        const target = event.target;
        if (target.tagName === 'TD') {
          const index = Array.prototype.indexOf.call(target.parentNode.children, target);
          const txt = `${columnKeys[index]}=${target.textContent}`;
          location.hash += location.hash.length > 1 ? `,${txt}` : txt;
        }
      });
    };

    const bbsLinkRegex = /\.5ch\.net\/([a-zA-Z0-9_-]+)\/$/;

    // Function to process the bbsmenu response
    const bbsmenuFunc = (response) => {
      console.timeEnd(response.finalUrl);
      if (response.status !== 200) {
        console.error('Failed to fetch bbsmenu. Status code:', response.status);
        return false;
      }
      const html = parser.parseFromString(response.responseText, 'text/html').documentElement;
      for (let bbsLink of $$('a[href*=".5ch.net/"]', html)) {
        const match = bbsLink.href.match(bbsLinkRegex);
        if (match) {
          const bbs = match[1];
          if (Object.hasOwn(donguriLogBbsRows, bbs) === false) {
            continue;
          }
          bbsOriginList[bbs] = new URL(bbsLink.href).origin;
          bbsNameList[bbs] = bbsLink.textContent.trim();
        }
      }
      if (Object.keys(bbsOriginList).length === 0) {
        console.error('No boards found.');
        return;
      }
      for (let bbs of Object.keys(donguriLogBbsRows)) {
        insertBbsnameCells(bbs);
      }
      xhrBbsInfoFromDonguriRows();
    };
    createFilterInput();
    // Initial data fetch from bbsmenu
    xhrGetDat('https://menu.5ch.net/bbsmenu.html', bbsmenuFunc);
  };

  const processMap = {
    donguriLog: {
      regex: /^https?:\/\/donguri\.5ch\.net\/cannonlogs$/,
      handler: donguriFilter
    },
    readCgi: {
      regex: /^https?:\/\/[a-z0-9]+\.5ch\.net\/test\/read\.cgi\/\w+\/\d+.*$/,
      handler: readCgiJump
    }
  };
  const processBasedOnUrl = (url) => {
    for (const key in processMap) {
      if (processMap[key].regex.test(url)) {
        processMap[key].handler();
        break;
      }
    }
  };
  processBasedOnUrl(`${location.origin}${location.pathname}`);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址