2ch Thread → Clipboard (compact)

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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();
  }
})();