// ==UserScript==
// @name ECH — Ejchan Watcher
// @namespace ech ejach
// @version 1.0.0
// @description Показывает актуальные посты треда Ejchan в плавающем окне
// @author local
// @match *://ech.bz/*
// @match *://ech.ist/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect ejchan.net
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ─── Константы ───────────────────────────────────────────────────────────
const STORAGE_KEY = 'ejchan_watcher_url';
const AUTO_REFRESH = 30; // секунд между авто-обновлениями (0 = выкл)
// ─── Стили ───────────────────────────────────────────────────────────────
GM_addStyle(`
#ejw-panel {
position: fixed;
bottom: 48px;
right: 0;
width: 360px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: #252338;
border: 1px solid #d5ae96;
border-right: none;
border-radius: 8px 0 0 8px;
box-shadow: -4px 4px 20px rgba(0,0,0,0.5);
font-family: sans-serif;
font-size: 13px;
color: #e8e1e1;
z-index: 9999;
transition: transform 0.2s ease;
}
#ejw-panel.ejw-hidden {
transform: translateX(calc(100% - 28px));
}
/* ── Таб-ручка ── */
#ejw-tab {
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 64px;
background: #252338;
border: 1px solid #d5ae96;
border-right: none;
border-radius: 6px 0 0 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
font-size: 11px;
color: #d5ae96;
letter-spacing: 1px;
user-select: none;
}
#ejw-tab:hover { background: #2e2b44; }
/* ── Заголовок ── */
#ejw-header {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px 6px;
border-bottom: 1px solid #3e3a5a;
flex-shrink: 0;
}
#ejw-title {
font-weight: bold;
color: #d5ae96;
font-size: 13px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#ejw-status {
font-size: 11px;
color: #a9a9a9;
white-space: nowrap;
}
#ejw-btn-refresh {
background: none;
border: 1px solid #3e3a5a;
border-radius: 4px;
color: #d5ae96;
cursor: pointer;
padding: 2px 7px;
font-size: 12px;
flex-shrink: 0;
}
#ejw-btn-refresh:hover { border-color: #d5ae96; background: #2e2b44; }
#ejw-btn-refresh.ejw-spinning { animation: ejw-spin 0.8s linear infinite; display: inline-block; }
@keyframes ejw-spin { to { transform: rotate(360deg); } }
/* ── Список постов ── */
#ejw-posts {
overflow-y: auto;
flex: 1;
padding: 6px 8px;
scrollbar-width: thin;
scrollbar-color: #454362 #252338;
}
#ejw-posts::-webkit-scrollbar { width: 5px; }
#ejw-posts::-webkit-scrollbar-track { background: #252338; }
#ejw-posts::-webkit-scrollbar-thumb { background: #454362; border-radius: 3px; }
.ejw-post {
background: #2e2b44;
border: 1px solid #3e3a5a;
border-radius: 6px;
padding: 6px 8px;
margin-bottom: 5px;
}
.ejw-post:last-child { margin-bottom: 0; }
.ejw-post-meta {
display: flex;
gap: 6px;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 3px;
}
.ejw-post-num {
color: #d5ae96;
font-size: 12px;
font-weight: bold;
text-decoration: none;
}
.ejw-post-num:hover { color: #e8c9b0; }
.ejw-post-name {
color: #a9a9a9;
font-size: 11px;
}
.ejw-post-time {
color: #606060;
font-size: 11px;
margin-left: auto;
}
.ejw-post-sub {
color: #d5ae96;
font-size: 12px;
font-weight: bold;
margin-bottom: 2px;
}
.ejw-post-body {
font-size: 12px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.ejw-post-body a {
color: #d5ae96 !important;
}
.ejw-post-body a:hover {
color: #e8c9b0 !important;
}
/* Greentext */
.ejw-post-body .quote {
color: #789922;
}
/* Изображение-превью */
.ejw-thumb {
float: left;
margin: 0 7px 4px 0;
border-radius: 4px;
border: 1px solid #3e3a5a;
max-width: 80px;
max-height: 80px;
object-fit: cover;
cursor: pointer;
}
.ejw-clearfix::after {
content: '';
display: block;
clear: both;
}
.ejw-msg {
text-align: center;
color: #a9a9a9;
padding: 20px 0;
font-size: 12px;
}
.ejw-new-badge {
display: inline-block;
background: #d5ae96;
color: #1a1829;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
padding: 1px 4px;
margin-left: 4px;
vertical-align: middle;
}
/* ── Подвал с инпутом ── */
#ejw-footer {
border-top: 1px solid #3e3a5a;
padding: 7px 8px;
display: flex;
gap: 6px;
flex-shrink: 0;
}
#ejw-url-input {
flex: 1;
background: #1a1829;
border: 1px solid #3e3a5a;
border-radius: 4px;
color: #e8e1e1;
padding: 4px 7px;
font-size: 12px;
min-width: 0;
}
#ejw-url-input:focus {
outline: none;
border-color: #d5ae96;
}
#ejw-url-input::placeholder {
color: #606060;
}
#ejw-btn-go {
background: #2e2b44;
border: 1px solid #d5ae96;
border-radius: 4px;
color: #d5ae96;
cursor: pointer;
padding: 4px 10px;
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
#ejw-btn-go:hover { background: #3e3a5a; }
`);
// ─── HTML панели ─────────────────────────────────────────────────────────
const panel = document.createElement('div');
panel.id = 'ejw-panel';
panel.innerHTML = `
<div id="ejw-tab" title="Показать/скрыть">ЕЖ</div>
<div id="ejw-header">
<span id="ejw-title">актуальные посты с ежача</span>
<span id="ejw-status"></span>
<button id="ejw-btn-refresh" title="Обновить">↻</button>
</div>
<div id="ejw-posts">
<div class="ejw-msg">Введи ссылку на тред ниже</div>
</div>
<div id="ejw-footer">
<input id="ejw-url-input" type="text" placeholder="https://ejchan.net/ai/res/12345.html" />
<button id="ejw-btn-go">→</button>
</div>
`;
document.body.appendChild(panel);
// ─── Ссылки на элементы ──────────────────────────────────────────────────
const elTab = document.getElementById('ejw-tab');
const elTitle = document.getElementById('ejw-title');
const elStatus = document.getElementById('ejw-status');
const elRefresh = document.getElementById('ejw-btn-refresh');
const elPosts = document.getElementById('ejw-posts');
const elUrlInput = document.getElementById('ejw-url-input');
const elGo = document.getElementById('ejw-btn-go');
// ─── Состояние ───────────────────────────────────────────────────────────
let currentJsonUrl = null;
let currentThreadUrl = null;
let seenIds = new Set();
let autoTimer = null;
let collapsed = false;
// ─── Утилиты ─────────────────────────────────────────────────────────────
/** Переводит URL треда vichan в URL JSON API */
function toJsonUrl(url) {
// https://ejchan.net/ai/res/51732.html → https://ejchan.net/ai/res/51732.json
return url.trim().replace(/\.html(\?.*)?$/, '.json');
}
/** Формат времени из unix timestamp */
function fmtTime(ts) {
const d = new Date(ts * 1000);
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Примитивная разметка vichan HTML для вставки в наш блок */
function renderBody(html) {
// html уже содержит вёрстку с <a class="quotelink"> и <span class="quote">
// просто вставляем как innerHTML
return html || '';
}
function setStatus(text, ok = true) {
elStatus.textContent = text;
elStatus.style.color = ok ? '#a9a9a9' : '#ff6b6b';
}
// ─── Свернуть/развернуть ─────────────────────────────────────────────────
elTab.addEventListener('click', () => {
collapsed = !collapsed;
panel.classList.toggle('ejw-hidden', collapsed);
});
// ─── Загрузка данных ─────────────────────────────────────────────────────
function fetchThread(jsonUrl, threadUrl, isManual = false) {
if (!jsonUrl) return;
elRefresh.classList.add('ejw-spinning');
if (isManual) setStatus('загрузка…');
GM_xmlhttpRequest({
method: 'GET',
url: jsonUrl,
headers: { 'Accept': 'application/json' },
onload(res) {
elRefresh.classList.remove('ejw-spinning');
if (res.status !== 200) {
setStatus(`ошибка ${res.status}`, false);
return;
}
let data;
try { data = JSON.parse(res.responseText); }
catch (e) {
setStatus('плохой JSON', false);
return;
}
renderPosts(data.posts || [], threadUrl);
setStatus(`обновлено ${fmtTime(Date.now()/1000)}`);
scheduleAutoRefresh();
},
onerror() {
elRefresh.classList.remove('ejw-spinning');
setStatus('сеть недоступна', false);
}
});
}
function scheduleAutoRefresh() {
clearTimeout(autoTimer);
if (AUTO_REFRESH > 0 && currentJsonUrl) {
autoTimer = setTimeout(() => {
fetchThread(currentJsonUrl, currentThreadUrl);
}, AUTO_REFRESH * 1000);
}
}
// ─── Рендер постов ───────────────────────────────────────────────────────
function renderPosts(posts, threadUrl) {
if (!posts.length) {
elPosts.innerHTML = '<div class="ejw-msg">В треде нет постов</div>';
return;
}
const base = new URL(threadUrl);
const boardBase = `${base.protocol}//${base.host}`;
// Первый запрос — отмечаем всё как виденное, не показываем NEW
const firstLoad = seenIds.size === 0;
const newIds = new Set();
posts.forEach(p => {
if (!seenIds.has(p.no)) {
newIds.add(p.no);
seenIds.add(p.no);
}
});
// Скроллимся вниз если были новые и панель уже показывала что-то
const wasAtBottom = elPosts.scrollHeight - elPosts.scrollTop <= elPosts.clientHeight + 40;
elPosts.innerHTML = '';
posts.forEach(p => {
const isNew = !firstLoad && newIds.has(p.no);
const div = document.createElement('div');
div.className = 'ejw-post';
// Ссылка на пост
const postUrl = `${threadUrl}#${p.no}`;
// Превью изображения
let thumbHtml = '';
if (p.tim && p.ext) {
// определяем борду из URL треда: /ai/res/... → /ai/thumb/
const pathParts = base.pathname.split('/');
const boardUri = pathParts[1]; // 'ai'
const thumbSrc = `${boardBase}/${boardUri}/thumb/${p.tim}s.jpg`;
const fullSrc = `${boardBase}/${boardUri}/src/${p.tim}${p.ext}`;
thumbHtml = `<a href="${fullSrc}" target="_blank"><img class="ejw-thumb" src="${thumbSrc}" loading="lazy" title="${p.filename || ''}${p.ext}" /></a>`;
}
// Имя
let nameHtml = p.name || 'Аноним';
if (p.trip) nameHtml += `<span style="color:#ff4da6">${p.trip}</span>`;
// Тема (только у ОПа)
const subHtml = p.sub ? `<div class="ejw-post-sub">${p.sub}</div>` : '';
div.innerHTML = `
<div class="ejw-post-meta">
<a class="ejw-post-num" href="${postUrl}" target="_blank">#${p.no}</a>
<span class="ejw-post-name">${nameHtml}</span>
${isNew ? '<span class="ejw-new-badge">NEW</span>' : ''}
<span class="ejw-post-time">${fmtTime(p.time)}</span>
</div>
${subHtml}
<div class="ejw-clearfix">
${thumbHtml}
<div class="ejw-post-body">${renderBody(p.com || '')}</div>
</div>
`;
// Открытие картинки по клику
const img = div.querySelector('.ejw-thumb');
if (img) {
img.addEventListener('click', e => {
e.preventDefault();
window.open(img.parentElement.href, '_blank');
});
}
elPosts.appendChild(div);
});
if (wasAtBottom || firstLoad) {
elPosts.scrollTop = elPosts.scrollHeight;
}
// Обновляем заголовок
const op = posts[0];
const boardPath = base.pathname.split('/')[1];
elTitle.textContent = `/${boardPath}/ #${op.no}${op.sub ? ' — ' + op.sub.slice(0, 30) : ''}`;
elTitle.title = postUrl;
}
// ─── Обработчики ─────────────────────────────────────────────────────────
function goToUrl(raw) {
const url = raw.trim();
if (!url) return;
// Валидация: должно быть ejchan.net и содержать /res/
if (!/ejchan\.net\/.+\/res\/\d+/.test(url)) {
setStatus('нужна ссылка на тред ежача', false);
return;
}
currentThreadUrl = url.split('#')[0].replace(/\.json$/, '.html');
currentJsonUrl = toJsonUrl(currentThreadUrl);
seenIds.clear();
clearTimeout(autoTimer);
localStorage.setItem(STORAGE_KEY, currentThreadUrl);
elPosts.innerHTML = '<div class="ejw-msg">загружаю…</div>';
// Раскрываем если было свёрнуто
if (collapsed) {
collapsed = false;
panel.classList.remove('ejw-hidden');
}
fetchThread(currentJsonUrl, currentThreadUrl, true);
}
elGo.addEventListener('click', () => goToUrl(elUrlInput.value));
elUrlInput.addEventListener('keydown', e => {
if (e.key === 'Enter') goToUrl(elUrlInput.value);
});
elRefresh.addEventListener('click', () => {
clearTimeout(autoTimer);
fetchThread(currentJsonUrl, currentThreadUrl, true);
});
// ─── Восстановление состояния из localStorage ─────────────────────────────
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
elUrlInput.value = saved;
goToUrl(saved);
}
})();