2ch Thread → Clipboard (compact)

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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