ui: 默认将下载面板放到左下角,并将参考脚本本地化为中文

- get-pre-download-infos.js: 面板 CSS 默认 right:16px 改为 left:16px
- main.go: handleGalleryCheck 在 exists 时把已有 json 的 pages/total_pages
  一并返回,续传场景下 pageURLByKey 不再为空,missing 图片可以正确进入
  重试流程(否则 missing 永远补不上)
- reference/E-Hentai Downloader-1.36.2.js: 把页面上可见的英文文案翻译成
  中文(下载框、设置面板、各类 alert/confirm/pushDialog、状态消息、
  控制台日志等),同时修复若干在翻译过程中被误改的变量/函数名
main
aionui 3 weeks ago
parent 9ca5ff3eaf
commit ed05b14ce7
  1. 120
      get-pre-download-infos.js
  2. 21
      main.go
  3. 596
      reference/E-Hentai Downloader-1.36.2.js

@ -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(/&#39;/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);
const imgResp = await gmFetch(imgURL, { responseType: 'blob' });
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),疑似限流占位`);
@ -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));
}

@ -160,7 +160,9 @@ type galleryCheckReq struct {
// handleGalleryCheck POST /api/galleries/check — 检查 title 对应的 json 是否已存在。
// 用于前端 JS 弹框「画廊已存在,是否覆盖 / 跳过」之类。
// 返回 200: {exists: bool, name: <sanitized>}
// 返回 200: {exists: bool, name: <sanitized>, pages?: map<key,单页URL>, total_pages?: int}
// 当 exists=true 时会一并把已有 json 的 pages/total_pages 返回,让续传场景下前端不必再翻页解析单页 URL,
// 直接用 pageURLByKey 就能精准重试 missing 图片(否则 pageURLByKey 是空的,missing 永远补不上)。
func handleGalleryCheck(w http.ResponseWriter, r *http.Request) {
var req galleryCheckReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -183,10 +185,23 @@ func handleGalleryCheck(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"exists": exists,
"name": name,
})
}
if exists {
// 续传场景:把已有的 pages + total_pages 一起返回,前端就不用再翻页解析画廊列表页了。
// 这样 pageURLByKey 能被正确填上,missing 的图才能真正进入重试流程。
g, loadErr := store.LoadGallery(name)
if loadErr != nil {
// JSON 文件存在却读不出来(损坏/权限问题),不要静默继续,直接报错让前端看见。
writeErr(w, loadErr)
return
}
resp["pages"] = g.Pages
resp["total_pages"] = g.TotalPages
}
writeJSON(w, http.StatusOK, resp)
}
// gallerySaveReq POST /api/galleries/save 请求体。

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save