Files
eh-downloader/userscript/eh-to-downloader.user.js
T
toor 0f927147cd feat: EH Downloader 用 Go + Wails v2 重写
原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化),
整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。

- internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载
- internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口
- app.go: Wails 绑定层,替代 Electron IPC
- frontend/js/api.js: 适配层,renderer.js 一行未改
- 37 个测试,含 httptest 模拟 EH 的端到端链路验证

相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。

顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。
2026-10-08 23:28:16 +08:00

327 lines
11 KiB
JavaScript
Raw 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.0.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);
}
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-cb {
display:inline-block; vertical-align:middle; margin-right:6px;
width:15px; height:15px; cursor:pointer; accent-color:#4a9eff;
position:relative; top:-1px;
}
.ehdl-cb-row.off a{ opacity:.5 }
/* 悬浮批量工具条 */
#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 }
`;
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) {
markOffline();
setStatus('EH Downloader 没在运行,请先打开它', 'err');
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>';
setStatus(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err');
return;
}
markDone();
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 去重)
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,
cb: null,
});
}
if (!items.length) return false;
// 每行前面插复选框
for (const it of items) {
const a = it.anchor;
if (!a || !a.parentNode) continue;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'ehdl-cb';
cb.title = `勾选 ${it.gid}`;
a.parentNode.insertBefore(cb, a);
cb.addEventListener('change', refresh);
it.cb = cb;
}
// ---- 悬浮工具条 ----
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-inv">反选</button>
<button id="ehdl-none">清空</button>
<button id="ehdl-send" class="primary" disabled>推送到工具</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 sendBtn = $('ehdl-send');
const msg = $('ehdl-msg');
let online = false;
const boxes = items.map((i) => i.cb).filter(Boolean);
function selected() {
return boxes.filter((c) => c.checked).length;
}
function refresh() {
const n = selected();
numEl.textContent = n;
sendBtn.disabled = n === 0 || !online;
sendBtn.textContent = online ? '推送到工具' : '工具未运行';
if (!online) { sendBtn.classList.remove('primary'); }
else { sendBtn.classList.add('primary'); }
}
function say(text, kind) {
msg.textContent = text;
msg.className = 'msg' + (kind ? ' ' + kind : '');
}
$('ehdl-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); };
$('ehdl-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); };
$('ehdl-none').onclick = () => { boxes.forEach((c) => (c.checked = false)); refresh(); };
sendBtn.onclick = () => {
const picked = items.filter((i) => i.cb && i.cb.checked);
if (!picked.length) return;
sendBtn.disabled = true;
say(`推送 ${picked.length} 个…`, '');
post('/add-batch', {
items: picked.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })),
}, (ok, j) => {
sendBtn.disabled = false;
if (!ok) {
online = false;
dot.classList.remove('on');
say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err');
refresh();
return;
}
const bits = [];
if (j.added) bits.push(`新增 ${j.added}`);
if (j.existed) bits.push(`已存在 ${j.existed}`);
say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok');
picked.forEach((i) => { if (i.cb) i.cb.checked = false; });
refresh();
});
};
ping((ok) => {
online = ok;
dot.classList.toggle('on', ok);
say(ok ? '工具运行中' : 'EH Downloader 未运行', ok ? 'ok' : 'err');
refresh();
});
refresh();
return true;
}
// ============================================================
// 入口
// ============================================================
if (runGalleryPage()) return;
runListPage();
})();