You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
806 lines
29 KiB
806 lines
29 KiB
// ==UserScript==
|
|
// @name ehentai-go pre-download helper
|
|
// @namespace https://localhost/ehentai-go
|
|
// @version 5.1.0
|
|
// @description e-hentai 画廊页一键「Send & Push」: 解析画廊元信息 + 单页 URL 列表 → POST 给本机 ehentai-go 服务, 然后并发把缺失图片二进制推到后端。所有抓图都在浏览器完成, 绕开 Go 端被 e-hentai TLS fingerprint 拦截的问题。支持刷新/跳转前的放弃确认、按 title 续传、固定状态面板、失败自动 retry。
|
|
// @match https://e-hentai.org/g/*
|
|
// @match https://exhentai.org/g/*
|
|
// @grant GM_addStyle
|
|
// @grant GM_xmlhttpRequest
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API_BASE = 'http://localhost:8990';
|
|
const PANEL_ID = 'ehg-panel';
|
|
const BTN_ID = 'ehg-btn';
|
|
const CONCURRENCY = 5;
|
|
const PAGE_URL_TIMEOUT_MS = 10000;
|
|
const IMAGE_FETCH_TIMEOUT_MS = 60000;
|
|
|
|
GM_addStyle(`
|
|
#${PANEL_ID} {
|
|
position: fixed;
|
|
left: 16px;
|
|
bottom: 16px;
|
|
z-index: 99999;
|
|
width: 440px;
|
|
max-width: calc(100vw - 32px);
|
|
background: #1f2937;
|
|
color: #e5e7eb;
|
|
border: 1px solid #374151;
|
|
border-radius: 10px;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
#${PANEL_ID} .ehg-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 14px;
|
|
background: #111827;
|
|
border-bottom: 1px solid #374151;
|
|
cursor: move;
|
|
}
|
|
#${PANEL_ID} .ehg-title {
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
letter-spacing: .04em;
|
|
text-transform: uppercase;
|
|
color: #9ca3af;
|
|
}
|
|
#${PANEL_ID} .ehg-stage {
|
|
font-size: 12px;
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
background: #374151;
|
|
color: #d1d5db;
|
|
}
|
|
#${PANEL_ID} .ehg-stage.idle { background: #374151; color: #d1d5db; }
|
|
#${PANEL_ID} .ehg-stage.run { background: #1d4ed8; color: #fff; }
|
|
#${PANEL_ID} .ehg-stage.ok { background: #15803d; color: #fff; }
|
|
#${PANEL_ID} .ehg-stage.warn { background: #b45309; color: #fff; }
|
|
#${PANEL_ID} .ehg-stage.err { background: #b91c1c; color: #fff; }
|
|
#${PANEL_ID} .ehg-body { padding: 12px 14px 14px; }
|
|
#${PANEL_ID} .ehg-status {
|
|
font-size: 13px;
|
|
color: #e5e7eb;
|
|
margin-bottom: 8px;
|
|
word-break: break-all;
|
|
}
|
|
#${PANEL_ID} .ehg-progress {
|
|
height: 7px;
|
|
background: #374151;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-bottom: 10px;
|
|
}
|
|
#${PANEL_ID} .ehg-progress > span {
|
|
display: block;
|
|
height: 100%;
|
|
width: 0;
|
|
background: linear-gradient(90deg, #4f46e5, #22c55e);
|
|
transition: width .25s ease;
|
|
}
|
|
#${PANEL_ID} .ehg-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
margin-bottom: 12px;
|
|
}
|
|
#${PANEL_ID} .ehg-stats b {
|
|
display: block;
|
|
color: #f9fafb;
|
|
font-size: 16px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
#${BTN_ID} {
|
|
width: 100%;
|
|
padding: 11px 14px;
|
|
color: #fff;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
font: 600 14px/1 inherit;
|
|
cursor: pointer;
|
|
background: #4f46e5;
|
|
transition: background .15s ease;
|
|
}
|
|
#${BTN_ID}:hover { background: #4338ca; }
|
|
#${BTN_ID}:disabled { background: #4b5563; cursor: wait; opacity: .8; }
|
|
#${BTN_ID}.ok { background: #16a34a; }
|
|
#${BTN_ID}.err { background: #dc2626; }
|
|
#${BTN_ID}.warn { background: #d97706; }
|
|
#${PANEL_ID}.ehg-collapsed .ehg-body { display: none; }
|
|
#${PANEL_ID} .ehg-toggle {
|
|
background: transparent;
|
|
border: 0;
|
|
color: #9ca3af;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
padding: 0 4px;
|
|
margin-left: 8px;
|
|
}
|
|
`);
|
|
|
|
// ---- 工具 ----
|
|
function sanitize(name) {
|
|
let s = name.replace(/[:"*?|<>/\\\n]/g, '-');
|
|
s = s.replace(/-+/g, '-');
|
|
s = s.trim().replace(/^\.+|\.+$/g, '').trim();
|
|
return s || 'untitled';
|
|
}
|
|
|
|
function extractSinglePageURLs(html, baseURL) {
|
|
const startIdx = html.indexOf('<div id="gdt"');
|
|
if (startIdx < 0) return [];
|
|
const tail = html.slice(startIdx);
|
|
const endIdx = tail.indexOf('<div class="gtb"');
|
|
const body = endIdx < 0 ? tail : tail.slice(0, endIdx);
|
|
const re = /<a href="([^"]+)"/g;
|
|
const out = [];
|
|
let m;
|
|
while ((m = re.exec(body)) !== null) {
|
|
const href = m[1];
|
|
if (!href.includes('/s/')) continue;
|
|
out.push(new URL(href, baseURL).href);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function extractPagesLength(html) {
|
|
const i = html.indexOf('<table class="ptt"');
|
|
if (i < 0) throw new Error('未找到 .ptt 表格');
|
|
const end = html.indexOf('</table>', i);
|
|
if (end < 0) throw new Error('.ptt 表格未闭合');
|
|
const seg = html.slice(i, end);
|
|
const reTD = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
|
let max = 0;
|
|
let m;
|
|
while ((m = reTD.exec(seg)) !== null) {
|
|
const text = m[1].replace(/<[^>]+>/g, '').trim();
|
|
const n = parseInt(text, 10);
|
|
if (!Number.isNaN(n) && n > max) max = n;
|
|
}
|
|
if (max === 0) throw new Error('.ptt 表格里没找到数字页码');
|
|
return max;
|
|
}
|
|
|
|
function extractRawTitle(html) {
|
|
let m = /<h1 id="gj">([^<]+)<\/h1>/.exec(html);
|
|
if (m) return m[1].trim();
|
|
m = /<h1 id="gn">([^<]+)<\/h1>/.exec(html);
|
|
if (m) return m[1].trim();
|
|
return '';
|
|
}
|
|
|
|
function extractImageURL(html) {
|
|
// fullimg 通常是原图链接;id="img" 有时只是当前页面预览图。
|
|
const patterns = [
|
|
/<a href="(\S+?\/fullimg(?:\.php\?|\/)\S+?)"/,
|
|
/<img id="img" src="(\S+?)"/,
|
|
/<\/(?:script|iframe)><a[\s\S]+?><img src="(\S+?)"/,
|
|
];
|
|
for (const p of patterns) {
|
|
const m = p.exec(html);
|
|
if (m) return decodeEntities(m[1]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeEntities(s) {
|
|
return s
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function resolveURL(base, value) {
|
|
try {
|
|
const url = new URL(value, base);
|
|
return (url.protocol === 'http:' || url.protocol === 'https:') ? url.href : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function gmFetch(url, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const requestOpts = {
|
|
method: opts.method || 'GET',
|
|
url,
|
|
// 默认按浏览器请求发送 cookie;需要匿名请求时显式传 anonymous: true。
|
|
anonymous: opts.anonymous === true,
|
|
headers: opts.headers || {},
|
|
data: opts.body,
|
|
responseType: opts.responseType || 'text',
|
|
};
|
|
if (opts.timeout !== undefined) requestOpts.timeout = opts.timeout;
|
|
|
|
GM_xmlhttpRequest({
|
|
...requestOpts,
|
|
onload: (r) => {
|
|
if (r.status >= 200 && r.status < 300) {
|
|
resolve({
|
|
status: r.status,
|
|
text: r.responseText,
|
|
blob: r.response,
|
|
headers: r.responseHeaders || '',
|
|
});
|
|
} else {
|
|
const err = new Error(`HTTP ${r.status} for ${url}`);
|
|
err.status = r.status;
|
|
reject(err);
|
|
}
|
|
},
|
|
onerror: (e) => reject(new Error(`network error: ${e.error || 'unknown'} for ${url}`)),
|
|
ontimeout: () => reject(new Error(`timeout for ${url}`)),
|
|
});
|
|
});
|
|
}
|
|
|
|
function joinPageURL(base, page) {
|
|
const u = new URL(base);
|
|
u.searchParams.set('p', String(page));
|
|
return u.href;
|
|
}
|
|
|
|
function guessExt(mime, url) {
|
|
if (mime === 'image/jpeg' || mime === 'image/jpg') return '.jpg';
|
|
if (mime === 'image/png') return '.png';
|
|
if (mime === 'image/webp') return '.webp';
|
|
if (mime === 'image/gif') return '.gif';
|
|
if (mime === 'image/bmp') return '.bmp';
|
|
if (mime === 'image/avif') return '.avif';
|
|
const noQuery = url.split('?')[0];
|
|
const i = noQuery.lastIndexOf('.');
|
|
if (i > noQuery.length - 7) return noQuery.slice(i).toLowerCase();
|
|
return '.bin';
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
}
|
|
|
|
function formatETA(seconds) {
|
|
if (!Number.isFinite(seconds) || seconds < 0) return '--';
|
|
seconds = Math.round(seconds);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60}s`;
|
|
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`;
|
|
}
|
|
|
|
// ---- 面板 / 状态 ----
|
|
// 单一状态对象,所有 UI 更新都通过 setState 走,避免散落 DOM 写。
|
|
const state = {
|
|
title: '',
|
|
stage: 'idle', // idle | run | ok | warn | err
|
|
statusText: '就绪',
|
|
total: 0,
|
|
done: 0,
|
|
failed: 0,
|
|
active: 0,
|
|
startedAt: 0,
|
|
bytesDone: 0,
|
|
currentKey: '',
|
|
};
|
|
|
|
function renderPanel() {
|
|
const stageEl = document.getElementById('ehg-stage');
|
|
if (stageEl) {
|
|
stageEl.className = 'ehg-stage ' + state.stage;
|
|
stageEl.textContent = state.stage;
|
|
}
|
|
const setText = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
|
|
setText('ehg-status', state.statusText);
|
|
setText('ehg-stat-total', String(state.total));
|
|
setText('ehg-stat-done', String(state.done));
|
|
setText('ehg-stat-fail', String(state.failed));
|
|
setText('ehg-stat-active', String(state.active));
|
|
const pct = state.total > 0 ? Math.min(100, ((state.done + state.failed) / state.total) * 100) : 0;
|
|
const bar = document.getElementById('ehg-progress-bar');
|
|
if (bar) bar.style.width = pct.toFixed(1) + '%';
|
|
}
|
|
|
|
function setStatus(text) {
|
|
state.statusText = text;
|
|
renderPanel();
|
|
}
|
|
|
|
function setStage(stage, text) {
|
|
state.stage = stage;
|
|
if (text) state.statusText = text;
|
|
renderPanel();
|
|
}
|
|
|
|
function mountPanel() {
|
|
if (document.getElementById(PANEL_ID)) return;
|
|
const panel = document.createElement('div');
|
|
panel.id = PANEL_ID;
|
|
panel.innerHTML = `
|
|
<div class="ehg-head">
|
|
<span class="ehg-title">ehentai-go</span>
|
|
<span style="display:flex;align-items:center;">
|
|
<span id="ehg-stage" class="ehg-stage idle">idle</span>
|
|
<button class="ehg-toggle" type="button" title="折叠/展开">_</button>
|
|
</span>
|
|
</div>
|
|
<div class="ehg-body">
|
|
<div id="ehg-status" class="ehg-status">就绪</div>
|
|
<div class="ehg-progress"><span id="ehg-progress-bar"></span></div>
|
|
<div class="ehg-stats">
|
|
<div>Total<b id="ehg-stat-total">0</b></div>
|
|
<div>Done<b id="ehg-stat-done">0</b></div>
|
|
<div>Active<b id="ehg-stat-active">0</b></div>
|
|
<div>Fail<b id="ehg-stat-fail">0</b></div>
|
|
</div>
|
|
<button id="${BTN_ID}" type="button">Send & Push</button>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(panel);
|
|
|
|
const toggle = panel.querySelector('.ehg-toggle');
|
|
toggle.addEventListener('click', () => panel.classList.toggle('ehg-collapsed'));
|
|
|
|
let dragDX = 0, dragDY = 0, dragging = false;
|
|
const head = panel.querySelector('.ehg-head');
|
|
head.addEventListener('mousedown', (e) => {
|
|
// 点击 toggle 不进入拖拽
|
|
if (e.target.closest('.ehg-toggle')) return;
|
|
dragging = true;
|
|
const rect = panel.getBoundingClientRect();
|
|
panel.style.left = rect.left + 'px';
|
|
panel.style.top = rect.top + 'px';
|
|
panel.style.right = 'auto';
|
|
panel.style.bottom = 'auto';
|
|
dragDX = e.clientX - rect.left;
|
|
dragDY = e.clientY - rect.top;
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!dragging) return;
|
|
const x = Math.max(0, Math.min(window.innerWidth - 100, e.clientX - dragDX));
|
|
const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dragDY));
|
|
panel.style.left = x + 'px';
|
|
panel.style.top = y + 'px';
|
|
});
|
|
document.addEventListener('mouseup', () => { dragging = false; });
|
|
|
|
renderPanel();
|
|
}
|
|
|
|
// ---- 离开页面保护:下载中刷新/关 tab/点链,提示用户 ----
|
|
let busyGuard = null;
|
|
function installLeaveGuard() {
|
|
// beforeunload:刷新/关 tab 时浏览器会显示原生确认框。
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (!busyGuard) return;
|
|
e.preventDefault();
|
|
e.returnValue = busyGuard;
|
|
return busyGuard;
|
|
});
|
|
// 点页内链接跳转时,拦下来 confirm。
|
|
document.addEventListener('click', (e) => {
|
|
if (!busyGuard) return;
|
|
const a = e.target.closest && e.target.closest('a[href]');
|
|
if (!a) return;
|
|
const href = a.getAttribute('href');
|
|
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
if (a.target === '_blank') return;
|
|
// 油猴菜单 / 自身按钮不拦
|
|
if (a.id === BTN_ID || a.closest(`#${PANEL_ID}`)) return;
|
|
if (!window.confirm(busyGuard)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}, true);
|
|
}
|
|
|
|
function setBusy(message) {
|
|
busyGuard = message || null;
|
|
}
|
|
|
|
// ---- 阶段 A:解析首页 + 翻页收集所有单URL----
|
|
async function collectSinglePageURLs(galleryURL, setStatus) {
|
|
const firstURL = joinPageURL(galleryURL, 0);
|
|
setStatus('拉取首页...');
|
|
const firstHTML = (await gmFetch(firstURL)).text;
|
|
const pagesTotal = extractPagesLength(firstHTML);
|
|
const rawTitle = extractRawTitle(firstHTML);
|
|
if (!rawTitle) throw new Error('未找到画廊标题 (h1#gj / h1#gn)');
|
|
const title = sanitize(rawTitle);
|
|
|
|
setStatus(`解析单页 0/?…`);
|
|
const singlePageURLs = new Set(extractSinglePageURLs(firstHTML, firstURL));
|
|
for (let p = 1; p < pagesTotal; p++) {
|
|
try {
|
|
const pageListURL = joinPageURL(galleryURL, p);
|
|
const body = (await gmFetch(pageListURL)).text;
|
|
for (const u of extractSinglePageURLs(body, pageListURL)) {
|
|
singlePageURLs.add(u);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`?p=${p} 失败:`, e.message);
|
|
}
|
|
setStatus(`解析单页 ${singlePageURLs.size}/?…`);
|
|
}
|
|
if (singlePageURLs.size === 0) throw new Error('未收集到任何单页 URL');
|
|
return { title, rawTitle, orderedURLs: Array.from(singlePageURLs) };
|
|
}
|
|
|
|
// ---- 阶段 B:续传判断 + 保存元信----
|
|
// 核心:先拿画廊标题问后端「这JSON 在不/ 还有几张没下
|
|
// 已存在就跳过翻页解析,直接进入 push
|
|
async function prepareGallery(galleryURL, setStatus) {
|
|
// 只用首页就能拿到 title,所以不翻页也能判断
|
|
setStatus('解析首页拿到标题...');
|
|
const firstHTML = (await gmFetch(joinPageURL(galleryURL, 0))).text;
|
|
const rawTitle = extractRawTitle(firstHTML);
|
|
if (!rawTitle) throw new Error('未找到画廊标题 (h1#gj / h1#gn)');
|
|
const title = sanitize(rawTitle);
|
|
|
|
const checkData = JSON.parse((await gmFetch(`${API_BASE}/api/galleries/check`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
})).text);
|
|
|
|
if (checkData.exists) {
|
|
// 续传:不再解析单页 URL 列表,直接读后端的 missing 列表。
|
|
// 后端 check 接口会一并返回已有的 pages 映射(0001->单页URL …),
|
|
// 我们把它转成 orderedURLs,这样 pageURLByKey 会被填上,
|
|
// worker 后续在 pushMissingImages 里才能用单页URL 重新解析图片直链去重试。
|
|
// 否则 pageURLByKey 是空的,worker 拿到 key 查不到 URL 就直接跳过,
|
|
// 导致 missing 的图永远补不上。
|
|
const pageMap = checkData.pages || {};
|
|
const orderedURLs = Object.keys(pageMap)
|
|
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10))
|
|
.map((k) => pageMap[k]);
|
|
setStatus(`发现已存在JSON (共 ${orderedURLs.length} 张),跳过翻页解析,直接续传...`);
|
|
return { title, rawTitle, orderedURLs, resumed: true };
|
|
}
|
|
|
|
// 全新:解析全部单页 URL,再保存
|
|
const collected = await collectSinglePageURLs(galleryURL, setStatus);
|
|
setStatus('保存元信息...');
|
|
const pages = {};
|
|
collected.orderedURLs.forEach((u, i) => {
|
|
pages[String(i + 1).padStart(4, '0')] = u;
|
|
});
|
|
await gmFetch(`${API_BASE}/api/galleries/save`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
gallery_name: collected.title,
|
|
raw_title: collected.rawTitle,
|
|
source_url: galleryURL,
|
|
total_pages: collected.orderedURLs.length,
|
|
fetched_at: new Date().toISOString(),
|
|
pages,
|
|
}),
|
|
});
|
|
return { title: collected.title, rawTitle, orderedURLs: collected.orderedURLs, resumed: false };
|
|
}
|
|
|
|
// 单张图片重试 3 次
|
|
async function pushOneWithRetry(title, key, pageURL, workerId) {
|
|
const MAX_ATTEMPTS = 3;
|
|
let lastErr = null;
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
const r = await pushOne(title, key, pageURL, attempt, MAX_ATTEMPTS);
|
|
return { ok: true, bytes: r.bytes };
|
|
} catch (e) {
|
|
lastErr = e;
|
|
console.warn(
|
|
`[${key}] attempt ${attempt}/${MAX_ATTEMPTS} failed; pageURL (上一级)=${pageURL}; ${e.message}`
|
|
);
|
|
if (attempt < MAX_ATTEMPTS) {
|
|
const wait = 1000 << (attempt - 1);
|
|
setStatus(`retry ${attempt}/${MAX_ATTEMPTS - 1} ${key} (w${workerId})…`);
|
|
await new Promise((r) => setTimeout(r, wait));
|
|
}
|
|
}
|
|
}
|
|
return { ok: false, err: lastErr };
|
|
}
|
|
|
|
async function pushOne(title, key, pageURL, attempt = 1, maxAttempts = 1) {
|
|
// 页面和原图请求要带浏览器 cookie;匿名请求拿到的页面/直链可能与浏览器不同。
|
|
const pageResp = await gmFetch(pageURL, {
|
|
timeout: PAGE_URL_TIMEOUT_MS,
|
|
anonymous: false,
|
|
headers: {
|
|
Referer: location.href,
|
|
'X-Alt-Referer': location.href,
|
|
},
|
|
});
|
|
const rawImgURL = extractImageURL(pageResp.text);
|
|
if (!rawImgURL) throw new Error('单页未匹配到图片直链正则');
|
|
const imgURL = resolveURL(pageURL, rawImgURL);
|
|
|
|
if (attempt > 1) {
|
|
console.warn(
|
|
`[${key}] retry ${attempt}/${maxAttempts}; pageURL (上一级)=${pageURL}; directURL=${imgURL}`
|
|
);
|
|
}
|
|
|
|
const imgResp = await gmFetch(imgURL, {
|
|
responseType: 'blob',
|
|
timeout: IMAGE_FETCH_TIMEOUT_MS,
|
|
anonymous: false,
|
|
headers: {
|
|
Referer: pageURL,
|
|
'X-Alt-Referer': pageURL,
|
|
'Cache-Control': 'no-cache',
|
|
},
|
|
});
|
|
const blob = imgResp.blob;
|
|
if (!blob || blob.size < 1024) {
|
|
throw new Error(`图片太小 (${blob ? blob.size : 0}B),疑似限流占位`);
|
|
}
|
|
|
|
const ext = guessExt(blob.type, imgURL);
|
|
const fd = new FormData();
|
|
fd.append('file', blob, key + ext);
|
|
|
|
const resp = JSON.parse((await gmFetch(
|
|
`${API_BASE}/api/galleries/${encodeURIComponent(title)}/images/${key}`,
|
|
{ method: 'POST', body: fd }
|
|
)).text);
|
|
return { bytes: resp.bytes || blob.size };
|
|
}
|
|
|
|
// ---- 阶段 C:并发 push 缺失图片,失败自动循环直到全完 ----
|
|
// 流程:每轮重新问后端缺图清单 → 并发 5 跑(单张内 3 次重试) → 若还有 missing,
|
|
// sleep RETRY_DELAY 秒 → 重新查缺图 → 再跑。无限循环,直到 missing=0 或达到
|
|
// 全局最大轮数(防止某些图永远下不下来时把脚本卡死)。
|
|
async function pushMissingImages(title, pageURLByKey, totalPages, setStatus, stopRequested) {
|
|
const RETRY_DELAY_MS = 5000; // 失败后等多久再重试整轮
|
|
const MAX_ROUNDS = 999; // 安全上限:重试到 999 还没好就自动停,大概 999 * 5s ≈ 83 分钟
|
|
const totalSessionPages = Math.max(totalPages, 0);
|
|
|
|
let totalDone = 0;
|
|
let totalFailedKeys = new Set();
|
|
let round = 0;
|
|
|
|
while (true) {
|
|
if (stopRequested && stopRequested()) {
|
|
return { done: totalDone, failed: totalFailedKeys.size, stopped: true };
|
|
}
|
|
round++;
|
|
if (round > MAX_ROUNDS) {
|
|
// 兜底:跑了 MAX_ROUNDS 轮还missing,放弃,告诉用户
|
|
return {
|
|
done: totalDone,
|
|
failed: totalFailedKeys.size,
|
|
failedKeys: Array.from(totalFailedKeys),
|
|
skipped: false,
|
|
gaveUp: true,
|
|
rounds: round - 1,
|
|
};
|
|
}
|
|
setStatus(`retry ${round}: 查询后端缺图清单…`);
|
|
const missingResp = (await gmFetch(
|
|
`${API_BASE}/api/galleries/${encodeURIComponent(title)}/missing-images`
|
|
)).text;
|
|
const missingObj = JSON.parse(missingResp);
|
|
const missing = missingObj.missing || [];
|
|
const serverTotal = missingObj.total | 0;
|
|
// total 用后端返回的画廊真实总页只在首轮确定一
|
|
// 后续轮次哪missing 减少也不这样分母始终是「整本画廊不会算重
|
|
if (state.total === 0) {
|
|
state.total = serverTotal || totalSessionPages || missing.length;
|
|
}
|
|
// 续传场景修复:用户刷新页面 / 中断后重新跑时,state.done 必须从
|
|
// 「后端已经存了 N 张」起算,不能从 0 起算。后端「已下载数量」
|
|
// = total - missing.length。在第一轮初始化一次,后续轮别动它。
|
|
if (round === 1 && totalDone === 0) {
|
|
const alreadyDone = Math.max(0, state.total - missing.length);
|
|
if (alreadyDone > 0) {
|
|
totalDone = alreadyDone;
|
|
state.done = totalDone;
|
|
setStatus(`retry ${round}: 后端已有 ${alreadyDone} 张,继续补 ${missing.length} 张…`);
|
|
}
|
|
}
|
|
if (missing.length === 0) {
|
|
return {
|
|
done: totalDone,
|
|
failed: 0,
|
|
failedKeys: [],
|
|
skipped: totalDone === 0 && totalSessionPages === 0,
|
|
};
|
|
}
|
|
|
|
const queue = missing.slice();
|
|
const total = missing.length;
|
|
let done = 0;
|
|
let failed = 0;
|
|
const failedKeys = [];
|
|
|
|
async function worker(workerId) {
|
|
while (queue.length > 0) {
|
|
const key = queue.shift();
|
|
if (!key) return;
|
|
const localPageURL = pageURLByKey[key];
|
|
state.active++;
|
|
state.currentKey = key;
|
|
renderPanel();
|
|
const t0 = performance.now();
|
|
if (!localPageURL) {
|
|
failed++;
|
|
failedKeys.push(key);
|
|
state.failed = totalFailedKeys.size + failed;
|
|
state.active--;
|
|
setStatus(`retry ${round} ${key} 无 URL,跳过 (${done + failed}/${total})`);
|
|
continue;
|
|
}
|
|
setStatus(`retry ${round} ${key} (w${workerId}) ${done + failed + 1}/${total})`);
|
|
const r = await pushOneWithRetry(title, key, localPageURL, workerId);
|
|
const dt = (performance.now() - t0) / 1000;
|
|
if (r.ok) {
|
|
done++;
|
|
totalDone++;
|
|
state.done = totalDone;
|
|
state.bytesDone += r.bytes || 0;
|
|
setStatus(`retry ${round} ${done}/${total} (${failed} fail, w${workerId}, ${formatBytes(r.bytes)}, ${dt.toFixed(1)}s)`);
|
|
} else {
|
|
failed++;
|
|
failedKeys.push(key);
|
|
state.failed = totalFailedKeys.size + failed;
|
|
setStatus(`retry ${round} ${key} 失败: ${r.err && r.err.message} (${done + failed}/${total})`);
|
|
}
|
|
state.active--;
|
|
renderPanel();
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
Array.from({ length: CONCURRENCY }, (_, i) => worker(i + 1))
|
|
);
|
|
failedKeys.forEach((k) => totalFailedKeys.add(k));
|
|
state.failed = totalFailedKeys.size;
|
|
|
|
// 每轮结束后显式 GET 后端做完整性确认 —— 单纯靠 "worker 报告全成功" 判断 done
|
|
// 不够稳:可能后端响应 200 但实际没存(磁盘满 / race / 响应阶段被前端断开),此时
|
|
// 后端 missing 仍 > 0,如果不验证就 break 会误报 done。后端才是真相来源。
|
|
setStatus(`retry ${round} 结束,问后端确认完整性…`);
|
|
const verifyResp = (await gmFetch(
|
|
`${API_BASE}/api/galleries/${encodeURIComponent(title)}/missing-images`
|
|
)).text;
|
|
const verifyObj = JSON.parse(verifyResp);
|
|
const stillMissing = (verifyObj.missing || []).length;
|
|
if (stillMissing === 0) {
|
|
// 后端确认该画廊图片齐全了 → 真的 done
|
|
return {
|
|
done: totalDone,
|
|
failed: 0,
|
|
failedKeys: [],
|
|
skipped: false,
|
|
};
|
|
}
|
|
|
|
// 还有 missing。等 5 秒再下一轮(下一轮开头会重新 GET 最新 missing 列表)。
|
|
if (failed === 0) {
|
|
setStatus(`retry ${round} worker 全成功,但后端仍缺 ${stillMissing} 张,${RETRY_DELAY_MS / 1000}s 后重试…`);
|
|
} else {
|
|
setStatus(`retry ${round} 失败 ${failed} 张,后端仍缺 ${stillMissing} 张,${RETRY_DELAY_MS / 1000}s 后重试…`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
}
|
|
|
|
return {
|
|
done: totalDone,
|
|
failed: totalFailedKeys.size,
|
|
failedKeys: Array.from(totalFailedKeys),
|
|
skipped: false,
|
|
};
|
|
}
|
|
|
|
// ---- 入口 ----
|
|
// runState 跟踪当前任务的生命周期: idle | running | stopping。
|
|
// 跑中按按钮会请求 stop,下一轮 sleep 时退出循环。
|
|
let runState = 'idle';
|
|
|
|
async function onOneClick(btn) {
|
|
if (runState === 'running') {
|
|
// 已经在跑,这次点击当作停止请求。
|
|
runState = 'stopping';
|
|
setStage('warn', '收到停止请求,本轮结束后退出');
|
|
btn.textContent = 'Stopping';
|
|
btn.disabled = true;
|
|
return;
|
|
}
|
|
runState = 'running';
|
|
const galleryURL = location.href;
|
|
btn.disabled = true;
|
|
btn.classList.remove('ok', 'err', 'warn');
|
|
state.total = 0; state.done = 0; state.failed = 0; state.active = 0;
|
|
state.bytesDone = 0; state.startedAt = Date.now();
|
|
state.currentKey = '';
|
|
setStage('run', '准备');
|
|
btn.textContent = 'Stop';
|
|
setBusy('ehentai-go 正在下载图片,放弃当前进度?');
|
|
|
|
try {
|
|
// 阶段 A + B:解析/续传 + 保存元信息
|
|
const { title, orderedURLs, resumed } = await prepareGallery(galleryURL, setStatus);
|
|
state.title = title;
|
|
|
|
// 续传模式下 pageURLByKey 留空,单张失败时再回查单页 URL。
|
|
// 但更稳妥的做法是续传时也解析一次首页拿到所有单页 URL 供失败重试用。
|
|
// 这里采取折中:续传时也跑一次轻量解析(只取首页 + ?p=1 等尽量少的页),
|
|
// 但 missing 为 0 时直接结束,不会走完整解析。
|
|
let pageURLByKey = {};
|
|
if (orderedURLs) {
|
|
orderedURLs.forEach((u, i) => {
|
|
pageURLByKey[String(i + 1).padStart(4, '0')] = u;
|
|
});
|
|
}
|
|
|
|
// 阶段 C:并发 push 图片(失败自动循环,无需点重试)
|
|
const result = await pushMissingImages(
|
|
title,
|
|
pageURLByKey,
|
|
orderedURLs ? orderedURLs.length : 0,
|
|
setStatus,
|
|
() => runState === 'stopping'
|
|
);
|
|
|
|
setBusy(null);
|
|
runState = 'idle';
|
|
const elapsed = (Date.now() - state.startedAt) / 1000;
|
|
const summary = `用时 ${formatETA(elapsed)} 共 ${formatBytes(state.bytesDone)}`;
|
|
if (runState === 'stopping' || (result.stopped)) {
|
|
setStage('warn', `[STOP] ${title} ${result.done} ok / ${result.failed} 未完成 (${summary})`);
|
|
setButtonState(btn, `Start`, null);
|
|
} else if (result.skipped) {
|
|
setStage('ok', `[OK] ${title} 已完整 (${summary})`);
|
|
setButtonState(btn, `Start`, 'ok');
|
|
} else if (result.failed === 0) {
|
|
setStage('ok', `[OK] ${title} +${result.done} (${summary})`);
|
|
setButtonState(btn, `Start`, 'ok');
|
|
} else if (result.gaveUp) {
|
|
setStage('err', `[GIVE UP] ${title} ${result.failed} 张始终失败(>${result.rounds} 轮) (${summary})`);
|
|
setButtonState(btn, `Start`, 'err');
|
|
} else {
|
|
// 理论上跑不到这里(自动循环直到 0 missing 或 gaveUp),留个兜底文案。
|
|
setStage('warn', `${title} ${result.done} ok / ${result.failed} 未完成 (${summary})`);
|
|
setButtonState(btn, `Start`, 'warn');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
setBusy(null);
|
|
runState = 'idle';
|
|
setStage('err', `[ERR] ${e.message}`);
|
|
setButtonState(btn, `Start`, 'err');
|
|
}
|
|
}
|
|
|
|
function setButtonState(btn, text, cls) {
|
|
btn.textContent = text;
|
|
btn.classList.remove('ok', 'err', 'warn');
|
|
if (cls) btn.classList.add(cls);
|
|
btn.disabled = false;
|
|
btn.onclick = null;
|
|
if (btn.__handler) btn.addEventListener('click', () => btn.__handler(btn));
|
|
}
|
|
|
|
mountPanel();
|
|
installLeaveGuard();
|
|
const btn = document.getElementById(BTN_ID);
|
|
btn.__handler = onOneClick;
|
|
btn.addEventListener('click', () => onOneClick(btn));
|
|
})();
|
|
|