From 0fd888d17c5b5b6d292929dbd2a24c9135bde68e Mon Sep 17 00:00:00 2001 From: Stardream Date: Sat, 21 Mar 2026 10:25:20 +1000 Subject: [PATCH] feat: add VGMdb Album Downloader userscript v2.9.3 Tampermonkey userscript for vgmdb.net that enables: - Single image download with GM_download - Batch download as ZIP package (JSZip) - Real-time progress display - Auto image renaming - Bilingual UI (zh/en) Co-Authored-By: Claude Sonnet 4.6 --- js/VGMdb_Album_Downloader.js | 330 +++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 js/VGMdb_Album_Downloader.js diff --git a/js/VGMdb_Album_Downloader.js b/js/VGMdb_Album_Downloader.js new file mode 100644 index 0000000..bc14ccc --- /dev/null +++ b/js/VGMdb_Album_Downloader.js @@ -0,0 +1,330 @@ +// ==UserScript== +// @name VGMdb Album Downloader +// @namespace https://vgmdb.net/ +// @version 2.9.3 +// @description 支持 单张下载 & zip 打包 & 实时进度显示 & 图片重命名 +// @match https://vgmdb.net/album/* +// @grant GM_xmlhttpRequest +// @grant GM_download +// @connect vgmdb.net +// @connect media.vgm.io +// @license MIT +// @downloadURL https://update.greasyfork.org/scripts/530686/VGMdb%20Album%20Downloader.user.js +// @updateURL https://update.greasyfork.org/scripts/530686/VGMdb%20Album%20Downloader.meta.js +// ==/UserScript== + +(function () { + 'use strict'; + + // ── 语言检测 & 翻译 ────────────────────────────────────────────── + const lang = (navigator.language || navigator.userLanguage || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en'; + + const i18n = { + zh: { + btnSingle: '📥 单张下载', + btnZip: '📦 打包下载', + btnSingleLoading: '⏳ 正在下载...', + btnSingleDone: '✅ 下载完成', + btnZipLoading: '⏳ 正在打包...', + btnZipDone: '✅ 打包完成', + noLinks: '❌ 没有找到图片页面链接!', + foundLinks: n => `共找到 ${n} 个图片页面链接。`, + readingPage: i => `📄 正在读取第 ${i} 个页面...`, + noImage: url => `⚠️ 未找到图片于: ${url}`, + added: f => `✅ 已添加:${f}`, + downloaded: f => `✅ 下载完成:${f}`, + dlFailed: f => `❌ 单张下载失败:${f}`, + pageFailed: url => `❌ 页面处理失败:${url}`, + zipping: '📦 开始生成压缩包...', + zipDone: '✅ 压缩包已生成并开始下载!', + zipFailed: e => `❌ 打包失败:${e}`, + }, + en: { + btnSingle: '📥 Download', + btnZip: '📦 Download ZIP', + btnSingleLoading: '⏳ Downloading...', + btnSingleDone: '✅ Done', + btnZipLoading: '⏳ Packaging...', + btnZipDone: '✅ Packed', + noLinks: '❌ No image page links found!', + foundLinks: n => `Found ${n} image page link(s).`, + readingPage: i => `📄 Reading page ${i}...`, + noImage: url => `⚠️ No image found at: ${url}`, + added: f => `✅ Added: ${f}`, + downloaded: f => `✅ Downloaded: ${f}`, + dlFailed: f => `❌ Download failed: ${f}`, + pageFailed: url => `❌ Page error: ${url}`, + zipping: '📦 Building ZIP...', + zipDone: '✅ ZIP created and download started!', + zipFailed: e => `❌ ZIP failed: ${e}`, + } + }; + + const T = i18n[lang]; + // ──────────────────────────────────────────────────────────────── + + let btnZipRef = null; + let logTimeout = null; + + window.addEventListener('load', () => { + setTimeout(() => { + const btnSingle = createBtn(T.btnSingle, 20); + const btnZip = createBtn(T.btnZip, 60); + btnZipRef = btnZip; + + btnSingle.addEventListener('click', () => { + showLogArea(); + btnSingle.disabled = true; + btnSingle.textContent = T.btnSingleLoading; + extractAndDownload(false).then(() => { + btnSingle.textContent = T.btnSingleDone; + hideLogAreaAfterDelay(); + }); + }); + + btnZip.addEventListener('click', () => { + showLogArea(); + btnZip.disabled = true; + btnZip.textContent = T.btnZipLoading; + extractAndDownload(true); + }); + + document.body.appendChild(btnSingle); + document.body.appendChild(btnZip); + }, 500); + }); + + function createBtn(text, offsetY) { + const btn = document.createElement('button'); + btn.textContent = text; + Object.assign(btn.style, { + position: 'fixed', + bottom: offsetY + 'px', + right: '20px', + zIndex: 9999, + padding: '10px 16px', + backgroundColor: '#4CAF50', + color: 'white', + border: 'none', + borderRadius: '8px', + fontSize: '14px', + cursor: 'pointer', + boxShadow: '0 2px 6px rgba(0,0,0,0.3)' + }); + return btn; + } + + const logArea = document.createElement('div'); + Object.assign(logArea.style, { + position: 'fixed', + bottom: '110px', + right: '20px', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + color: 'white', + padding: '10px', + borderRadius: '5px', + fontSize: '12px', + maxHeight: '300px', + overflowY: 'auto', + zIndex: 9999, + width: '300px', + display: 'none' + }); + document.body.appendChild(logArea); + + function showLogArea() { + logArea.style.display = 'block'; + clearTimeout(logTimeout); + } + + function hideLogAreaAfterDelay() { + logTimeout = setTimeout(() => { + logArea.style.display = 'none'; + logArea.innerHTML = ''; + }, 5000); + } + + function log(msg) { + const p = document.createElement('div'); + p.textContent = msg; + logArea.appendChild(p); + logArea.scrollTop = logArea.scrollHeight; + } + + async function extractAndDownload(asZip = false) { + const anchors = Array.from(document.querySelectorAll("div#cover_list a[href*='covers.php?do=view&cover=']")); + const coverLinks = anchors.map(a => a.href); + const coverNames = anchors.map(a => a.textContent.trim()); + + if (coverLinks.length === 0) { + alert(T.noLinks); + return; + } + + log(T.foundLinks(coverLinks.length)); + + const zipFiles = []; + for (let i = 0; i < coverLinks.length; i++) { + const url = coverLinks[i]; + const niceName = coverNames[i].replace(/[/\\:*?"<>|]/g, ''); + + log(T.readingPage(i + 1)); + try { + const html = await fetch(url).then(r => r.text()); + const match = html.match(/]+id=["']scrollpic["'][^>]+src=["']([^"']+)["']/); + if (!match || !match[1]) { + log(T.noImage(url)); + continue; + } + const imageUrl = match[1].startsWith("http") ? match[1] : "https://vgmdb.net" + match[1]; + const extension = imageUrl.split('.').pop().split('?')[0].toLowerCase(); + const filename = `${niceName}.${extension}`; + + if (asZip) { + const blob = await new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'GET', + url: imageUrl, + responseType: 'blob', + onload: res => resolve(res.response), + onerror: err => reject(err) + }); + }); + await new Promise(res => setTimeout(res, 400)); + zipFiles.push({ name: filename, lastModified: new Date(), input: blob }); + log(T.added(filename)); + } else { + GM_download({ + url: imageUrl, + name: filename, + saveAs: false, + onload: () => { + log(T.downloaded(filename)); + if (i === coverLinks.length - 1) { + btnSingle.textContent = T.btnSingleDone; + hideLogAreaAfterDelay(); + } + }, + onerror: err => { + console.error(T.dlFailed(filename), err); + log(T.dlFailed(filename)); + } + }); + } + } catch (e) { + log(T.pageFailed(url)); + console.error(e); + } + } + + if (asZip && zipFiles.length > 0) { + log(T.zipping); + await downloadZipBuiltIn(zipFiles); + if (btnZipRef) btnZipRef.textContent = T.btnZipDone; + hideLogAreaAfterDelay(); + } + } + + async function downloadZipBuiltIn(files) { + try { + const zipBlob = await zip(files); + const title = document.querySelector("h1")?.innerText || 'vgmdb_album'; + const a = document.createElement('a'); + a.href = URL.createObjectURL(zipBlob); + a.download = `${title}.zip`; + a.click(); + URL.revokeObjectURL(a.href); + log(T.zipDone); + } catch (err) { + alert(T.zipFailed(err)); + console.error(err); + log(T.zipFailed(err)); + } + } + + function zip(files) { + return new Response(new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const fileRecords = []; + let centralDirSize = 0; + let offset = 0; + + for (let file of files) { + const filenameBytes = encoder.encode(file.name); + const modTime = getDosTime(file.lastModified || new Date()); + + const localHeader = new Uint8Array(30 + filenameBytes.length); + const view = new DataView(localHeader.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0, true); + view.setUint16(8, 0, true); + view.setUint16(10, modTime.time, true); + view.setUint16(12, modTime.date, true); + view.setUint32(14, 0, true); + view.setUint32(18, file.input.size, true); + view.setUint32(22, file.input.size, true); + view.setUint16(26, filenameBytes.length, true); + view.setUint16(28, 0, true); + localHeader.set(filenameBytes, 30); + + controller.enqueue(localHeader); + const blobBuf = new Uint8Array(await file.input.arrayBuffer()); + controller.enqueue(blobBuf); + + const central = new Uint8Array(46 + filenameBytes.length); + const cv = new DataView(central.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(4, 20, true); + cv.setUint16(6, 20, true); + cv.setUint16(8, 0, true); + cv.setUint16(10, 0, true); + cv.setUint16(12, modTime.time, true); + cv.setUint16(14, modTime.date, true); + cv.setUint32(16, 0, true); + cv.setUint32(20, file.input.size, true); + cv.setUint32(24, file.input.size, true); + cv.setUint16(28, filenameBytes.length, true); + cv.setUint16(30, 0, true); + cv.setUint16(32, 0, true); + cv.setUint16(34, 0, true); + cv.setUint16(36, 0, true); + cv.setUint32(38, 0, true); + cv.setUint32(42, offset, true); + central.set(filenameBytes, 46); + + fileRecords.push(central); + offset += localHeader.length + blobBuf.length; + centralDirSize += central.length; + } + + const startOfCentral = offset; + for (let record of fileRecords) controller.enqueue(record); + + const end = new Uint8Array(22); + const dv = new DataView(end.buffer); + dv.setUint32(0, 0x06054b50, true); + dv.setUint16(8, fileRecords.length, true); + dv.setUint16(10, fileRecords.length, true); + dv.setUint32(12, centralDirSize, true); + dv.setUint32(16, startOfCentral, true); + controller.enqueue(end); + controller.close(); + } + })).blob(); + } + + function getDosTime(date) { + const d = new Date(date); + const time = + (d.getHours() << 11) | + (d.getMinutes() << 5) | + (d.getSeconds() / 2); + const day = + ((d.getFullYear() - 1980) << 9) | + ((d.getMonth() + 1) << 5) | + d.getDate(); + return { time: time & 0xffff, date: day & 0xffff }; + } +})();