Одна кнопка: собирает весь открытый тред 2ch.org в компактный текст и кладёт в буфер обмена. Экономит токены при скармливании LLM. Фетчит .json-версию треда, парсит теми же регексами, что .omp/skills/dvach/parse.py, сжимает шапки/даты/файлы.
// ==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();
}
})();