|
|
|
|
@ -18,11 +18,13 @@ |
|
|
|
|
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; |
|
|
|
|
right: 16px; |
|
|
|
|
left: 16px; |
|
|
|
|
bottom: 16px; |
|
|
|
|
z-index: 99999; |
|
|
|
|
width: 440px; |
|
|
|
|
@ -179,9 +181,10 @@ |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractImageURL(html) { |
|
|
|
|
// fullimg 通常是原图链接;id="img" 有时只是当前页面预览图。
|
|
|
|
|
const patterns = [ |
|
|
|
|
/<img id="img" src="(\S+?)"/, |
|
|
|
|
/<a href="(\S+?\/fullimg(?:\.php\?|\/)\S+?)"/, |
|
|
|
|
/<img id="img" src="(\S+?)"/, |
|
|
|
|
/<\/(?:script|iframe)><a[\s\S]+?><img src="(\S+?)"/, |
|
|
|
|
]; |
|
|
|
|
for (const p of patterns) { |
|
|
|
|
@ -200,15 +203,30 @@ |
|
|
|
|
.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) => { |
|
|
|
|
GM_xmlhttpRequest({ |
|
|
|
|
const requestOpts = { |
|
|
|
|
method: opts.method || 'GET', |
|
|
|
|
url, |
|
|
|
|
anonymous: opts.anonymous !== false, |
|
|
|
|
// 默认按浏览器请求发送 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({ |
|
|
|
|
@ -440,9 +458,18 @@ |
|
|
|
|
})).text); |
|
|
|
|
|
|
|
|
|
if (checkData.exists) { |
|
|
|
|
// 续传:不再解析单页 URL 列表,直接读后端的 missing 列表
|
|
|
|
|
setStatus('发现已存在JSON,跳过翻页解析,直接续传...'); |
|
|
|
|
return { title, rawTitle, orderedURLs: null, resumed: true }; |
|
|
|
|
// 续传:不再解析单页 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,再保存
|
|
|
|
|
@ -473,11 +500,13 @@ |
|
|
|
|
let lastErr = null; |
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { |
|
|
|
|
try { |
|
|
|
|
const r = await pushOne(title, key, pageURL); |
|
|
|
|
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:`, e.message); |
|
|
|
|
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})…`); |
|
|
|
|
@ -488,12 +517,36 @@ |
|
|
|
|
return { ok: false, err: lastErr }; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async function pushOne(title, key, pageURL) { |
|
|
|
|
const pageResp = await gmFetch(pageURL); |
|
|
|
|
const imgURL = extractImageURL(pageResp.text); |
|
|
|
|
if (!imgURL) throw new Error('单页未匹配到图片直链正则'); |
|
|
|
|
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' }); |
|
|
|
|
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),疑似限流占位`); |
|
|
|
|
@ -551,6 +604,17 @@ |
|
|
|
|
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, |
|
|
|
|
@ -609,11 +673,31 @@ |
|
|
|
|
failedKeys.forEach((k) => totalFailedKeys.add(k)); |
|
|
|
|
state.failed = totalFailedKeys.size; |
|
|
|
|
|
|
|
|
|
if (failed === 0 && missing.length === 0) break; |
|
|
|
|
if (failed === 0) break; // 这一轮没失败 → 后端也已经没 missing → 提前结束
|
|
|
|
|
// 每轮结束后显式 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, |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 还有失败的。等 5 秒再问后端,再下一轮。
|
|
|
|
|
setStatus(`retry ${round} 失败 ${failed} 张,${RETRY_DELAY_MS / 1000}s 后自动重试…`); |
|
|
|
|
// 还有 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)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|