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