2ch Thread → Clipboard (compact)

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например 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();
  }
})();