原 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(永远返回第一个端口,从不等连接结果)。
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
// ==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();
|
||
})(); |