// ==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 = `
⚠️
EH Downloader 未运行
请先打开 EH Downloader 工具,然后再推送。
`;
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();
})();