Files
eh-downloader/userscript/eh-to-downloader.user.js
toor 2d3c322e34 feat(油猴): 行内推送按钮 + 已推送画廊标灰
需求两条:
  1) 列表页的复选框改成按钮,点一下直接推送到工具
  2) 推送过但没点进去的画廊,标题也要变灰(像浏览过的),
     这样刷新后还认得出哪些推过

一、行内推送按钮

复选框 + 底部「批量推送」两步走太啰嗦,列表页每行改成「📤 推送」
胶囊按钮,点一下直接调 /add。按钮三态:
  📤 推送  ->  ⏳ 推送中(禁用)  ->  ✓ 已推送(变绿)
底部悬浮条保留,改为「全部推送」+「重置状态」+ 本页计数。

二、标灰::visited 伪造不了,只能自己记

E-Hentai 的灰色来自自带样式
  a:visited .glink, a:active .glink { color:#8F6063 }
但 :visited 是浏览器**受保护**伪类:脚本查 matches(':visited')
永远返回 false,加 class 也触发不了站点的原生规则。

所以改成自己记一份「推送过」的清单,再照抄站点的色值手动染色,
观感与原生 :visited 一致(hover 恢复站点的 #8F4701)。

三、清单存哪:工具端为主,浏览器端为辅

新增 internal/core/pushed.go —— PushedStore 独立于 Store,
单独存 %APPDATA%\EH Downloader\pushed.json。

为什么单独一个文件:state.json 里的 tasks 是「当前要下载的东西」,
用户点「清除已完成」就删掉了;但「这个画廊我推送过」是历史事实,
删了任务不该忘掉。所以推送历史与任务列表解耦。

新增桥口 GET /pushed 返回推送历史(与 GET /tasks 语义不同:
后者是当前任务列表,会被清除)。

油猴脚本侧:
  - localStorage(ehdl_pushed_gids)先渲染,刷新后立刻可见,
    工具不在线也能用
  - 工具在线时用 /pushed 校正一次(localStorage 可能被清,
    工具才是权威),以工具为准整体覆盖
  - GET /tasks 保留给需要看当前任务列表的场合

四、附带:state.json 事故的恢复工具

tools/recoverstate —— 修复被写坏的 state.json:
  1) token 先从下载目录的 .ehdl.json 清单里找
  2) 找不到的用 E-Hentai 的 ?next=<gid> 定位法去站点抓
     (该 URL 会返回以 gid 开头的 25 条列表,正则取 token,
     offset 0..5 轮着试)
  3) 抓不到的说明画廊在站点上已被删除

-dry 预演不写盘,写盘前先备份原文件。

配套回归测试:
  - internal/core/pushed_test.go   推送历史读写、任务删除后仍保留
  - internal/core/recover_e2e_test.go  恢复出的任务能真下载(71/71 页)
  - internal/eh/recover_verify_test.go 恢复出的 token 能解析画廊
  - app_test.go                    点「清除已完成」后灰标不消失
  - internal/core/bridge_test.go   /tasks 与 /pushed 的 token 校验

后两者需要能连 e-hentai.org,设 EH_TEST_PROXY 才跑。
2026-10-25 00:16:03 +08:00

587 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 = `
<div class="ehdl-modal" role="dialog" aria-modal="true">
<div class="ehdl-modal-icon">⚠️</div>
<div class="ehdl-modal-title">EH Downloader 未运行</div>
<div class="ehdl-modal-msg">请先打开 EH Downloader 工具,然后再推送。</div>
<div class="ehdl-modal-actions">
<button class="ehdl-modal-btn primary" id="ehdl-modal-close">知道了</button>
</div>
</div>
`;
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 = '<span>📤</span><span>发送画廊地址</span>';
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 = '<span>✓</span><span>已发送</span>';
};
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 = '<span>📤</span><span>发送画廊地址</span>';
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 = `
<span class="dot" id="ehdl-dot"></span>
<span class="n">本页 <b id="ehdl-n">0</b> / ${items.length}</span>
<button id="ehdl-all">全部推送</button>
<button id="ehdl-none">重置状态</button>
<span class="msg" id="ehdl-msg"></span>
`;
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 = '<span>📤</span><span>推送</span>';
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 = '<span>⏳</span><span>推送中</span>';
btn.disabled = true;
} else if (state === 'done') {
btn.classList.remove('is-busy');
btn.classList.add('is-done');
btn.innerHTML = '<span>✓</span><span>已推送</span>';
btn.disabled = false;
} else {
btn.classList.remove('is-busy');
btn.classList.remove('is-done');
btn.innerHTML = '<span>📤</span><span>推送</span>';
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();
})();