// ==UserScript==
// @name VGMdb Album Downloader
// @namespace https://vgmdb.net/
// @version 2.9.2
// @description 支持 单张下载 & zip 打包 & 实时进度显示 & 图片重命名
// @match https://vgmdb.net/album/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect vgmdb.net
// @connect media.vgm.io
// @license MIT
// ==/UserScript==
(function () {
'use strict';
let btnZipRef = null;
let logTimeout = null;
window.addEventListener('load', () => {
setTimeout(() => {
const btnSingle = createBtn('📥 单张下载', 20);
const btnZip = createBtn('📦 打包下载', 60);
btnZipRef = btnZip;
btnSingle.addEventListener('click', () => {
showLogArea();
btnSingle.disabled = true;
btnSingle.textContent = '⏳ 正在下载...';
extractAndDownload(false).then(() => {
btnSingle.textContent = '✅ 下载完成';
hideLogAreaAfterDelay();
});
});
btnZip.addEventListener('click', () => {
showLogArea();
btnZip.disabled = true;
btnZip.textContent = '⏳ 正在打包...';
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("❌ 没有找到图片页面链接!");
return;
}
log(`共找到 ${coverLinks.length} 个图片页面链接。`);
const zipFiles = [];
for (let i = 0; i < coverLinks.length; i++) {
const url = coverLinks[i];
const niceName = coverNames[i].replace(/[/\\:*?"<>|]/g, '');
log(`📄 正在读取第 ${i + 1} 个页面...`);
try {
const html = await fetch(url).then(r => r.text());
const match = html.match(/<img[^>]+id=["']scrollpic["'][^>]+src=["']([^"']+)["']/);
if (!match || !match[1]) {
log(`⚠️ 未找到图片于: ${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(`✅ 已添加:${filename}`);
} else {
GM_download({
url: imageUrl,
name: filename,
saveAs: false,
onload: () => {
log(`✅ 下载完成:${filename}`);
if (i === coverLinks.length - 1) {
btnSingle.textContent = '✅ 下载完成';
hideLogAreaAfterDelay();
}
},
onerror: err => {
console.error("❌ 单张下载失败:", err);
log(`❌ 单张下载失败:${filename}`);
}
});
}
} catch (e) {
log(`❌ 页面处理失败:${url}`);
console.error(e);
}
}
if (asZip && zipFiles.length > 0) {
log('📦 开始生成压缩包...');
await downloadZipBuiltIn(zipFiles);
if (btnZipRef) btnZipRef.textContent = '✅ 打包完成';
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('✅ 压缩包已生成并开始下载!');
} catch (err) {
alert("❌ 打包失败:" + err);
console.error(err);
log(`❌ 打包失败:${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 };
}
})();