// ==UserScript== // @name EH Downloader 助手 // @namespace http://127.0.0.1:37777 // @version 2.3.0 // @description ① 画廊页:把当前画廊推送到本地 EH Downloader ② 列表页:每行一个推送按钮,点击直接推送;推送过的标题标灰 // @author jack // @match https://e-hentai.org/* // @match https://exhentai.org/* // @grant GM_xmlhttpRequest // @connect 127.0.0.1 // @connect localhost // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const PORT = 37777; const TOKEN = 'ehdl-local-9f2a4c7e'; const API = `http://127.0.0.1:${PORT}`; // ============================================================ // 公共:通讯 // ============================================================ function ping(cb) { GM_xmlhttpRequest({ method: 'GET', url: `${API}/ping`, timeout: 1500, onload: (r) => cb(r.status === 200), onerror: () => cb(false), ontimeout: () => cb(false), }); } function post(path, data, cb) { GM_xmlhttpRequest({ method: 'POST', url: `${API}${path}`, headers: { 'Content-Type': 'application/json', 'X-EHDL-Token': TOKEN }, data: JSON.stringify(data), timeout: 8000, onload: (r) => { let j = {}; try { j = JSON.parse(r.responseText); } catch (_) {} cb(r.status === 200, j); }, // 连不上 = 工具没开 onerror: () => cb(false, { error: 'offline' }), ontimeout: () => cb(false, { error: 'timeout' }), }); } function addStyle(css) { const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); } // ============================================================ // 公共:已推送记录 // // 浏览器给「访问过的画廊」上灰色用的是原生 :visited 伪类, // 这个伪类受浏览器保护,脚本没法伪造(matches(':visited') 永远 false)。 // 所以这里自己记一份「推送过」的清单,再照着 E-Hentai 自己的色值 // (a:visited .glink { color:#8F6063 } ) 把标题染成一样的灰。 // // 记录放 localStorage:同源、刷新后还在、工具不在线也能用。 // 工具在线时再用 /pushed 校正一次(localStorage 可能被清,工具才是权威)。 // // /pushed 读的是工具自己的 pushed.json —— 独立于任务列表, // 所以点「清除已完成」把任务删了,灰标依然在。 // ============================================================ const PUSHED_KEY = 'ehdl_pushed_gids'; const VISITED_COLOR = '#8F6063'; // E-Hentai 自带样式里的 :visited 色值 /** 读取本地已推送的 gid 集合 */ function loadPushed() { const set = new Set(); try { const raw = localStorage.getItem(PUSHED_KEY); if (raw) JSON.parse(raw).forEach((g) => set.add(String(g))); } catch (_) {} return set; } /** 把 gid 记进本地清单 */ function rememberPushed(gid) { const set = loadPushed(); set.add(String(gid)); try { localStorage.setItem(PUSHED_KEY, JSON.stringify([...set])); } catch (_) {} return set; } /** 从工具拉取任务清单,校正本地记录 */ function syncPushedFromTool(cb) { GM_xmlhttpRequest({ method: 'GET', url: `${API}/pushed`, headers: { 'X-EHDL-Token': TOKEN }, timeout: 8000, onload: (r) => { if (r.status !== 200) return cb(false); let j = {}; try { j = JSON.parse(r.responseText); } catch (_) { return cb(false); } if (!j.ok || !Array.isArray(j.items)) return cb(false); const set = new Set(); j.items.forEach((t) => { if (t && t.gid) set.add(String(t.gid)); }); // 工具里的为准:整体覆盖本地 try { localStorage.setItem(PUSHED_KEY, JSON.stringify([...set])); } catch (_) {} cb(true, set); }, onerror: () => cb(false), ontimeout: () => cb(false), }); } // ============================================================ // 公共:弹窗 // ============================================================ function ensureModal() { let mask = document.getElementById('ehdl-mask'); if (mask) return mask; mask = document.createElement('div'); mask.id = 'ehdl-mask'; mask.innerHTML = ` `; document.body.appendChild(mask); const close = () => { mask.classList.remove('show'); }; mask.querySelector('#ehdl-modal-close').addEventListener('click', close); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && mask.classList.contains('show')) close(); }); return mask; } function showOfflineModal() { const mask = ensureModal(); mask.classList.add('show'); } const CSS = ` #ehdl-btn { display:inline-flex; align-items:center; gap:6px; padding:5px 13px; border-radius:20px; cursor:pointer; font:13px/1.4 -apple-system,"PingFang SC",sans-serif; color:#fff; background:#4a9eff; border:1px solid #3b8ae6; transition:background .12s,opacity .12s; } #ehdl-btn:hover{background:#62acff} #ehdl-btn:active{transform:translateY(1px)} #ehdl-btn[disabled]{opacity:.45;cursor:default} #ehdl-btn.is-off{background:#6b7280;border-color:#6b7280} #ehdl-btn.is-done{background:#3fb950;border-color:#3fb950} #ehdl-status{font-size:12px;color:#8b93a3} #ehdl-status.err{color:#f85149} #ehdl-status.ok{color:#3fb950} /* 列表页:每行的推送按钮 */ .ehdl-push { display:inline-flex; align-items:center; justify-content:center; gap:4px; vertical-align:middle; margin-right:7px; padding:2px 9px; border-radius:12px; cursor:pointer; font:12px/1.6 -apple-system,"PingFang SC",sans-serif; color:#fff; background:#4a9eff; border:1px solid #3b8ae6; transition:background .12s,opacity .12s; white-space:nowrap; } .ehdl-push:hover:not([disabled]){background:#62acff} .ehdl-push:active:not([disabled]){transform:translateY(1px)} .ehdl-push[disabled]{opacity:.5;cursor:default} .ehdl-push.is-done{background:#3fb950;border-color:#3fb950} .ehdl-push.is-done:hover:not([disabled]){background:#4cc45c} .ehdl-push.is-busy{background:#6b7280;border-color:#6b7280} /* 已推送的画廊:标题染成和 E-Hentai :visited 一样的灰色 */ a.ehdl-pushed .glink{ color:#8F6063 !important } a.ehdl-pushed:hover .glink{ color:#8F4701 !important } /* 标签也跟着淡一点,跟站点观感一致 */ a.ehdl-pushed .gt{ opacity:.75 } /* 悬浮批量工具条 */ #ehdl-bar { position:fixed; right:18px; bottom:18px; z-index:2147483000; display:flex; align-items:center; gap:9px; padding:9px 13px; border-radius:11px; background:rgba(28,31,38,.96); border:1px solid #2e333d; box-shadow:0 10px 34px rgba(0,0,0,.5); font:12px/1.4 -apple-system,"PingFang SC",sans-serif; color:#e6e8ec; backdrop-filter:blur(8px); max-width:calc(100vw - 36px); flex-wrap:wrap; } #ehdl-bar .n{ font-variant-numeric:tabular-nums; color:#8b93a3 } #ehdl-bar .n b{ color:#4a9eff; font-size:14px } #ehdl-bar button { background:#23272f; color:#e6e8ec; border:1px solid #2e333d; border-radius:7px; padding:5px 11px; font-size:12px; cursor:pointer; } #ehdl-bar button:hover:not([disabled]){ background:#2c313a; border-color:#3d4450 } #ehdl-bar button[disabled]{ opacity:.4; cursor:default } #ehdl-bar button.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600 } #ehdl-bar button.primary:hover:not([disabled]){ background:#62acff } #ehdl-bar .dot{ width:8px; height:8px; border-radius:50%; background:#6b7280 } #ehdl-bar .dot.on{ background:#3fb950 } #ehdl-bar .msg{ max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } #ehdl-bar .msg.err{ color:#f85149 } #ehdl-bar .msg.ok{ color:#3fb950 } /* 弹窗 */ #ehdl-mask { position:fixed; inset:0; z-index:2147483100; display:none; align-items:center; justify-content:center; background:rgba(0,0,0,.45); backdrop-filter:blur(2px); } #ehdl-mask.show{ display:flex } #ehdl-mask .ehdl-modal { min-width:280px; max-width:360px; padding:22px 22px 16px; border-radius:14px; background:#1e2128; color:#e6e8ec; border:1px solid #333944; box-shadow:0 18px 60px rgba(0,0,0,.6); font:13px/1.6 -apple-system,"PingFang SC",sans-serif; text-align:center; animation:ehdl-pop .14s ease-out; } @keyframes ehdl-pop{ from{ transform:scale(.94); opacity:0 } to{ transform:scale(1); opacity:1 } } #ehdl-mask .ehdl-modal-icon{ font-size:32px; line-height:1.2; margin-bottom:6px } #ehdl-mask .ehdl-modal-title{ font-size:15px; font-weight:600; margin-bottom:6px } #ehdl-mask .ehdl-modal-msg{ font-size:12.5px; color:#9aa3b2; margin-bottom:16px } #ehdl-mask .ehdl-modal-actions{ display:flex; gap:9px; justify-content:center } #ehdl-mask .ehdl-modal-btn { padding:6px 16px; border-radius:8px; cursor:pointer; font-size:12.5px; border:1px solid #3d4450; background:#2a2f38; color:#e6e8ec; } #ehdl-mask .ehdl-modal-btn:hover{ background:#333944 } #ehdl-mask .ehdl-modal-btn.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600; } #ehdl-mask .ehdl-modal-btn.primary:hover{ background:#62acff } `; addStyle(CSS); // ============================================================ // 模式 A:画廊详情页 —— 发送当前画廊 // ============================================================ function runGalleryPage() { const m = location.pathname.match(/^\/g\/(\d+)\/([0-9a-f]+)/i); if (!m) return false; const gid = m[1]; const token = m[2]; const galleryUrl = `${location.origin}/g/${gid}/${token}/`; const titleEl = document.querySelector('h1#gn'); const title = (titleEl?.textContent || document.title || '') .replace(/\s*-\s*E-Hentai Galleries\s*$/i, '').trim(); // 动作区 #gd5(Report Gallery / Archive Download 那一栏) let host = document.getElementById('gd5') || document.getElementById('gd1'); if (!host) { host = document.createElement('div'); host.id = 'gd5'; const anchor = document.querySelector('#gdt') || document.body; anchor.parentNode.insertBefore(host, anchor); } const box = document.createElement('div'); box.style.cssText = 'margin:6px 0 10px;display:flex;align-items:center;gap:8px'; host.appendChild(box); const btn = document.createElement('button'); btn.id = 'ehdl-btn'; btn.type = 'button'; btn.innerHTML = '📤发送画廊地址'; const status = document.createElement('span'); status.id = 'ehdl-status'; box.append(btn, status); let online = false; const setStatus = (t, k) => { status.textContent = t; status.className = k || ''; }; const markOffline = () => { btn.classList.add('is-off'); btn.title = 'EH Downloader 没在运行,请先打开'; }; const markDone = () => { btn.classList.add('is-done'); btn.innerHTML = '✓已发送'; }; btn.addEventListener('click', () => { if (!online) { // 重新探一次:工具可能刚被打开 ping((ok) => { online = ok; if (!ok) { markOffline(); showOfflineModal(); setStatus('EH Downloader 没在运行,请先打开它', 'err'); return; } btn.classList.remove('is-off'); btn.click(); }); return; } btn.disabled = true; setStatus('发送中…', ''); post('/add', { gid, token, url: galleryUrl, title }, (ok, j) => { btn.disabled = false; if (!ok) { online = false; markOffline(); btn.innerHTML = '📤发送画廊地址'; showOfflineModal(); setStatus(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); return; } markDone(); rememberPushed(gid); setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); }); }); ping((ok) => { online = ok; if (ok) setStatus('EH Downloader 运行中', 'ok'); else { markOffline(); setStatus('EH Downloader 未运行 —— 点击会提示', ''); } }); return true; } // ============================================================ // 模式 B:列表页 —— 每行一个推送按钮,点击直接推送 // ============================================================ function runListPage() { // 收集本页所有画廊链接(按 gid/token 去重) const links = [...document.querySelectorAll('a[href*="/g/"]')]; const seen = new Set(); const items = []; // 链接里的标题在 .glink 里;直接 textContent 会把嵌套 div 的标签文字也吸进来 const readTitle = (a) => { const g = a.querySelector('.glink'); return (g?.textContent || a.textContent || '').trim().slice(0, 200); }; for (const a of links) { const href = a.getAttribute('href') || ''; const m = href.match(/\/g\/(\d+)\/([0-9a-f]+)/i); if (!m) continue; const gid = m[1]; const token = m[2]; const key = `${gid}/${token}`; if (seen.has(key)) continue; seen.add(key); items.push({ gid, token, url: `${location.origin}/g/${gid}/${token}/`, title: readTitle(a), anchor: a, btn: null, state: 'idle', // idle | busy | done }); } if (!items.length) return false; // ---- 悬浮工具条(显示状态 / 批量操作) ---- const bar = document.createElement('div'); bar.id = 'ehdl-bar'; bar.innerHTML = ` 本页 0 / ${items.length} `; document.body.appendChild(bar); const $ = (id) => bar.querySelector('#' + id); const dot = $('ehdl-dot'); const numEl = $('ehdl-n'); const msg = $('ehdl-msg'); let online = false; function say(text, kind) { msg.textContent = text; msg.className = 'msg' + (kind ? ' ' + kind : ''); } // ---- 把「已推送」的画廊标题染灰 ---- // 用 a.ehdl-pushed 选中的 .glink 染色,和站点 :visited 的观感一致 function markPushed(it) { const a = it.anchor; if (!a) return; const glink = a.querySelector('.glink'); if (!glink) return; a.classList.add('ehdl-pushed'); glink.style.color = VISITED_COLOR; glink.dataset.ehdlPushed = '1'; } function unmarkPushed(it) { const a = it.anchor; if (!a) return; const glink = a.querySelector('.glink'); a.classList.remove('ehdl-pushed'); if (glink) { glink.style.color = ''; delete glink.dataset.ehdlPushed; } } // 本地记录里已经推过的,先染灰(刷新后立刻可见,不用等工具) const localPushed = loadPushed(); items.forEach((it) => { if (localPushed.has(String(it.gid))) markPushed(it); }); // ---- 每行前面插推送按钮 ---- for (const it of items) { const a = it.anchor; if (!a || !a.parentNode) continue; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ehdl-push'; btn.innerHTML = '📤推送'; btn.title = `推送到 EH Downloader(${it.gid})`; a.parentNode.insertBefore(btn, a); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); pushOne(it, btn); }); it.btn = btn; } function setBtnState(it, btn, state) { it.state = state; if (state === 'busy') { btn.classList.add('is-busy'); btn.classList.remove('is-done'); btn.innerHTML = '⏳推送中'; btn.disabled = true; } else if (state === 'done') { btn.classList.remove('is-busy'); btn.classList.add('is-done'); btn.innerHTML = '✓已推送'; btn.disabled = false; } else { btn.classList.remove('is-busy'); btn.classList.remove('is-done'); btn.innerHTML = '📤推送'; btn.disabled = false; } } // ---- 推送单个 ---- function pushOne(it, btn) { if (!online) { // 重新探一次:工具可能刚被打开 ping((ok) => { online = ok; dot.classList.toggle('on', ok); if (!ok) { showOfflineModal(); say('EH Downloader 未运行', 'err'); return; } pushOne(it, btn); }); return; } setBtnState(it, btn, 'busy'); post('/add', { gid: it.gid, token: it.token, url: it.url, title: it.title }, (ok, j) => { if (!ok) { online = false; dot.classList.remove('on'); setBtnState(it, btn, 'idle'); showOfflineModal(); say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); return; } setBtnState(it, btn, 'done'); markPushed(it); rememberPushed(it.gid); numEl.textContent = items.filter((i) => i.state === 'done').length; say(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); }); } // ---- 批量推送本页全部 ---- $('ehdl-all').onclick = () => { if (!online) { ping((ok) => { online = ok; dot.classList.toggle('on', ok); if (!ok) { showOfflineModal(); say('EH Downloader 未运行', 'err'); return; } $('ehdl-all').onclick(); }); return; } const pending = items.filter((i) => i.btn && i.state !== 'busy'); if (!pending.length) return; pending.forEach((i) => setBtnState(i, i.btn, 'busy')); say(`推送 ${pending.length} 个…`, ''); post('/add-batch', { items: pending.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })), }, (ok, j) => { if (!ok) { online = false; dot.classList.remove('on'); pending.forEach((i) => setBtnState(i, i.btn, 'idle')); showOfflineModal(); say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); return; } const bits = []; if (j.added) bits.push(`新增 ${j.added}`); if (j.existed) bits.push(`已存在 ${j.existed}`); say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok'); pending.forEach((i) => { setBtnState(i, i.btn, 'done'); markPushed(i); rememberPushed(i.gid); }); numEl.textContent = items.filter((i) => i.state === 'done').length; }); }; $('ehdl-none').onclick = () => { items.forEach((i) => { if (!i.btn) return; setBtnState(i, i.btn, 'idle'); unmarkPushed(i); }); numEl.textContent = 0; say('已重置', ''); }; ping((ok) => { online = ok; dot.classList.toggle('on', ok); say(ok ? '工具运行中' : 'EH Downloader 未运行', ok ? 'ok' : 'err'); if (!ok) return; // 工具在线:以工具的任务清单为准,校正本地记录 + 染色 syncPushedFromTool((synced, set) => { if (!synced) return; items.forEach((it) => { const on = set.has(String(it.gid)); if (on) markPushed(it); else if (!loadPushed().has(String(it.gid))) unmarkPushed(it); // 工具里已有的,按钮也显示成「已推送」 if (on && it.state === 'idle') setBtnState(it, it.btn, 'done'); }); numEl.textContent = items.filter((i) => i.state === 'done').length; }); }); return true; } // ============================================================ // 入口 // ============================================================ if (runGalleryPage()) return; runListPage(); })();