2ch Thread → Clipboard (compact)

Одна кнопка: собирает весь открытый тред 2ch.org в компактный текст и кладёт в буфер обмена. Экономит токены при скармливании LLM. Фетчит .json-версию треда, парсит теми же регексами, что .omp/skills/dvach/parse.py, сжимает шапки/даты/файлы.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         2ch Thread → Clipboard (compact)
// @namespace    dvach_parser
// @version      1.0.0
// @description  Одна кнопка: собирает весь открытый тред 2ch.org в компактный текст и кладёт в буфер обмена. Экономит токены при скармливании LLM. Фетчит .json-версию треда, парсит теми же регексами, что .omp/skills/dvach/parse.py, сжимает шапки/даты/файлы.
// @match        *://2ch.org/*/res/*.html*
// @match        *://2ch.org/*/res/*
// @match        *://2ch.hk/*/res/*.html*
// @run-at       document-idle
// @grant        GM_setClipboard
// ==/UserScript==

(function () {
  'use strict';

  const HOST = 'https://2ch.org';

  function decodeHtml(s) {
    const t = document.createElement('textarea');
    t.innerHTML = s;
    return t.value;
  }

  function plain(s) {
    if (!s) return '';
    s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
    s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
    s = s.replace(/<br\s*\/?>/gi, '\n');
    s = s.replace(/<\/p>/gi, '\n\n');
    s = s.replace(/<p\b[^>]*>/gi, '');
    s = s.replace(/<a\s+class="post-reply-link"[^>]*?data-num="(\d+)"[^>]*>[\s\S]*?<\/a>/gi, '>>$1');
    s = s.replace(/<a\s+data-num="(\d+)"[^>]*?class="post-reply-link"[^>]*>[\s\S]*?<\/a>/gi, '>>$1');
    s = s.replace(/<a[^>]*>(>>>\/[^<]+)<\/a>/gi, '$1');
    s = s.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
    s = s.replace(/<[^>]+>/g, '');
    s = decodeHtml(s);
    s = s.replace(/\n{3,}/g, '\n\n').replace(/^[ \t]+|[ \t]+$/gm, '').trim();
    return s;
  }

  function extractReplies(s) {
    const nums = s ? s.match(/data-num="(\d+)"/g) : null;
    if (!nums) return [];
    const seen = new Set();
    const out = [];
    for (const m of nums) {
      const n = m.match(/\d+/)[0];
      if (!seen.has(n)) { seen.add(n); out.push('>>' + n); }
    }
    return out;
  }

  function shortenDate(d) {
    if (!d) return '';
    return d
      .replace(/ [А-Яа-яЁёA-Za-z]{2,4} /, ' ')
      .replace(/(\d+:\d+):\d+/, '$1')
      .trim();
  }

  function fileUrl(path) {
    if (!path) return '';
    return path.startsWith('http') ? path : HOST + path;
  }

  function formatPost(p, opNum) {
    const num = p.num;
    const rawName = p.name || '';
    const name = (rawName && rawName !== 'Аноним' && rawName !== 'Anonymous') ? rawName : '';
    const date = shortenDate(p.date);
    const isOp = p.op === 1 || num === opNum;
    let head = '@' + num;
    if (date) head += ' ' + date;
    if (name) head += ' ' + name;
    if (isOp && p.subject) head += ' — ' + p.subject;
    const comment = plain(p.comment || '');
    const reps = extractReplies(p.comment || '');
    const files = (p.files || []).map(f => fileUrl(f.path)).filter(Boolean);
    const lines = [head];
    if (comment) lines.push(comment);
    if (files.length) lines.push('files: ' + files.join(' '));
    if (reps.length) lines.push('re: ' + reps.join(' '));
    return lines.join('\n');
  }

  function formatThread(data) {
    const threads = data.threads || [];
    if (!threads.length) return '(тред пуст)';
    const posts = threads[0].posts || [];
    if (!posts.length) return '(нет постов)';
    const op = posts[0];
    let out = '';
    if (op.subject) out += '# ' + op.subject + '\n\n';
    out += posts.map(p => formatPost(p, op.num)).join('\n\n');
    return out;
  }

  function threadJsonUrl() {
    const u = new URL(location.href);
    let path = u.pathname;
    if (path.endsWith('.html')) {
      path = path.replace(/\.html$/, '.json');
    } else if (!path.endsWith('.json')) {
      path = path.replace(/\/?$/, '') + '.json';
    }
    return u.origin + path;
  }

  async function copyText(text) {
    if (typeof GM_setClipboard === 'function') {
      GM_setClipboard(text);
      return;
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(text);
      return;
    }
    // Legacy fallback
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
  }

  let btnEl, statusEl;

  function buildUI() {
    btnEl = document.createElement('button');
    btnEl.textContent = '📋 Тред';
    Object.assign(btnEl.style, {
      position: 'fixed',
      right: '16px',
      bottom: '16px',
      zIndex: '2147483647',
      padding: '8px 14px',
      background: '#ff6600',
      color: '#fff',
      border: 'none',
      borderRadius: '6px',
      fontFamily: 'sans-serif',
      fontSize: '13px',
      fontWeight: '600',
      cursor: 'pointer',
      boxShadow: '0 2px 8px rgba(0,0,0,.3)',
    });

    statusEl = document.createElement('div');
    statusEl.style.cssText =
      'position:fixed;right:16px;bottom:54px;z-index:2147483647;' +
      'font:12px sans-serif;background:#222;color:#9f9;padding:6px 10px;' +
      'border-radius:6px;display:none;max-width:360px;box-shadow:0 2px 8px rgba(0,0,0,.3);';

    document.body.appendChild(statusEl);
    document.body.appendChild(btnEl);
    btnEl.addEventListener('click', onClick);
  }

  function showStatus(text, ms) {
    statusEl.textContent = text;
    statusEl.style.display = 'block';
    if (ms) setTimeout(() => { statusEl.style.display = 'none'; }, ms);
  }

  async function onClick() {
    btnEl.disabled = true;
    const origText = btnEl.textContent;
    btnEl.textContent = '⏳ грузим…';
    try {
      const url = threadJsonUrl();
      const res = await fetch(url, {
        headers: { 'Accept': 'application/json' },
        credentials: 'omit',
      });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const data = await res.json();
      const text = formatThread(data);
      await copyText(text);
      const posts = (data.threads && data.threads[0] && data.threads[0].posts) || [];
      const bytes = new TextEncoder().encode(text).length;
      showStatus(
        '✓ ' + posts.length + ' постов, ' + bytes + ' байт в буфере',
        4000
      );
    } catch (e) {
      console.error('[2ch-copier]', e);
      showStatus('✗ ' + (e && e.message ? e.message : e), 6000);
    } finally {
      btnEl.disabled = false;
      btnEl.textContent = origText;
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildUI);
  } else {
    buildUI();
  }
})();