您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Collect data:image/webp;base64 images. First -> cover.webp. Other images -> Chapter.zip (inner). Outer zip named from site title (fallback nameless). Option to download individually instead of zipping.
// ==UserScript== // @name Manga Colorizer Download Helper // @namespace http://tampermonkey.net/ // @version 1.0 // @license MIT // @description Collect data:image/webp;base64 images. First -> cover.webp. Other images -> Chapter.zip (inner). Outer zip named from site title (fallback nameless). Option to download individually instead of zipping. // @match *://*/* // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // ==/UserScript== (function () { 'use strict'; const DEFAULT_PREFIX = 'image'; const DEFAULT_PADDING = 3; const INNER_ZIP_NAME = 'Chapter.zip'; // required inner zip name const ZIP_COMPRESSION = 'STORE'; // store for speed & reliability /* ---------- Helpers ---------- */ function createEl(tag, attrs = {}, css = {}) { const el = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); Object.assign(el.style, css); return el; } function padNumber(n, width) { const s = String(n); return s.length >= width ? s : '0'.repeat(width - s.length) + s; } function dataUrlToUint8Array(dataUrl) { const base64 = dataUrl.split(',')[1] || ''; const binary = atob(base64); const arr = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i); return arr; } function sanitizeFilename(name, fallback = 'nameless') { if (!name || typeof name !== 'string') return fallback; let s = name.trim().replace(/\s+/g, ' '); s = s.replace(/[\/\\?%*:|"<>]/g, '-'); if (s.length > 160) s = s.slice(0, 160).trim(); if (!s) return fallback; return s; } async function blobToArrayBuffer(blob) { return await blob.arrayBuffer(); } /* ---------- Title search ---------- */ function findSiteTitle() { try { const selOrder = [ () => document.querySelector('div#info > h1.title')?.textContent, () => document.querySelector('meta[property="og:title"]')?.getAttribute('content'), () => document.querySelector('meta[name="twitter:title"]')?.getAttribute('content'), () => document.querySelector('.title')?.textContent, () => document.querySelector('h1')?.textContent, () => document.title, ]; for (const fn of selOrder) { const t = fn(); if (t && typeof t === 'string' && t.trim()) return sanitizeFilename(t.trim()); } } catch (e) { /* ignore */ } return null; } /* ---------- Detect only webp data URLs ---------- */ function extractWebpFromImgs() { const found = []; Array.from(document.images || []).forEach(img => { const src = (img.currentSrc || img.src || '').trim(); if (src.toLowerCase().startsWith('data:image/webp;base64,')) found.push(src); }); return found; } function extractWebpFromBackgrounds() { const set = new Set(); document.querySelectorAll('*').forEach(el => { try { const style = window.getComputedStyle(el); if (!style) return; const bg = style.getPropertyValue('background-image'); if (bg && bg !== 'none') { const re = /url\(["']?(.*?)["']?\)/g; let m; while ((m = re.exec(bg)) !== null) { const raw = (m[1] || '').trim(); if (raw.toLowerCase().startsWith('data:image/webp;base64,')) set.add(raw); } } } catch (e) {} }); return Array.from(set); } function pageIsSingleWebpDataImg() { const res = []; try { if (document.body && document.body.childElementCount === 1) { const only = document.body.firstElementChild; if (only && only.tagName === 'IMG' && only.src) { const s = only.src.trim(); if (s.toLowerCase().startsWith('data:image/webp;base64,')) res.push(s); } } if (typeof location.href === 'string' && location.href.toLowerCase().startsWith('data:image/webp;base64,')) { res.push(location.href.trim()); } } catch (e) {} return res; } function collectOnlyWebpDataUrls() { const s = new Set(); extractWebpFromImgs().forEach(u => s.add(u)); extractWebpFromBackgrounds().forEach(u => s.add(u)); pageIsSingleWebpDataImg().forEach(u => s.add(u)); return Array.from(s); } /* ---------- UI ---------- */ const root = createEl('div', { id: 'nested-zip-root' }, { position: 'fixed', right: '18px', bottom: '18px', zIndex: 2147483647, fontFamily: 'Inter, Roboto, Arial, sans-serif' }); const btn = createEl('div', { title: 'Nested Zip / Download Individually' }, { width: '56px', height: '56px', borderRadius: '12px', background: '#0b6ad1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', cursor: 'pointer', boxShadow: '0 6px 18px rgba(0,0,0,0.18)' }); btn.innerHTML = `<svg viewBox="0 0 24 24" width="26" height="26" fill="white" aria-hidden="true"><path d="M5 20h14v-2H5v2zM11 4h2v8h3l-4 4-4-4h3V4z"/></svg>`; const panel = createEl('div', { id: 'nested-zip-panel' }, { position: 'absolute', right: '70px', bottom: '0px', width: '520px', maxWidth: 'calc(100vw - 120px)', borderRadius: '10px', background: '#fff', color: '#222', boxShadow: '0 8px 36px rgba(0,0,0,0.18)', padding: '12px', display: 'none', zIndex: 2147483648, fontSize: '13px' }); panel.innerHTML = ` <div style="font-weight:700;margin-bottom:8px">WebP Collector — Nested Zip or Download</div> <div id="nested-zip-info" style="margin-bottom:8px;color:#444">Ready</div> <div style="display:flex;gap:8px;margin-bottom:8px"> <label style="display:flex;align-items:center;gap:6px;"><input type="radio" name="we-mode" value="nested" checked/> Nested Zip</label> <label style="display:flex;align-items:center;gap:6px;"><input type="radio" name="we-mode" value="individual"/> Download Individually</label> </div> <div style="display:flex;gap:8px"> <button id="nested-zip-go" style="flex:1;padding:8px;border-radius:8px;border:none;cursor:pointer;background:#0b6ad1;color:#fff">Start (Zip / Download)</button> <button id="nested-zip-scan" style="padding:8px;border-radius:8px;border:1px solid #ddd;background:#f9f9f9;cursor:pointer">Scan</button> </div> <div style="margin-top:10px;font-size:12px;color:#666"> Prefix for other images: <input id="nested-zip-prefix" value="${DEFAULT_PREFIX}" style="width:120px;padding:4px;border-radius:4px;border:1px solid #ddd;margin-left:6px"/> Padding: <input id="nested-zip-pad" value="${DEFAULT_PADDING}" style="width:48px;padding:4px;border-radius:4px;border:1px solid #ddd;margin-left:6px"/> </div> <div id="nested-zip-list" style="margin-top:10px;max-height:240px;overflow:auto;font-size:12px;color:#333"></div> <div style="margin-top:8px;font-size:12px;color:#555"> Notes: First detected data:image/webp;base64 is saved as <strong>cover.webp</strong>. Inner zip will be <strong>${INNER_ZIP_NAME}</strong>. Outer zip name is taken from site (or "nameless"). </div> `; root.appendChild(btn); root.appendChild(panel); document.body.appendChild(root); // Toggle panel when clicking button btn.addEventListener('click', (e) => { e.stopPropagation(); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; renderScan(); }); // Collapse panel on outside click document.addEventListener('click', (e) => { const target = e.target; if (!root.contains(target)) { panel.style.display = 'none'; } }); // Prevent clicks inside panel from closing panel.addEventListener('click', (e) => { e.stopPropagation(); }); const infoEl = panel.querySelector('#nested-zip-info'); const listEl = panel.querySelector('#nested-zip-list'); const scanBtn = panel.querySelector('#nested-zip-scan'); const goBtn = panel.querySelector('#nested-zip-go'); const prefixInput = panel.querySelector('#nested-zip-prefix'); const padInput = panel.querySelector('#nested-zip-pad'); let lastFound = []; function renderScan() { const arr = collectOnlyWebpDataUrls(); lastFound = arr; infoEl.textContent = `${arr.length} webp data URL(s) found (first will be cover.webp)`; listEl.innerHTML = arr.map((u, i) => { const short = u.length > 140 ? u.slice(0, 120) + '... (truncated)' : u; return `<div style="padding:6px;border-bottom:1px dashed #eee;font-family:monospace;font-size:12px;"><strong>#${i+1}</strong> ${short}</div>`; }).join('') || '<div style="color:#666">No matching webp data URLs found</div>'; } scanBtn.addEventListener('click', (e) => { e.stopPropagation(); renderScan(); }); /* ---------- Flows ---------- */ // 1) Nested zip: make inner Chapter.zip with others, outer zip containing cover.webp + Chapter.zip, named by site. async function createNestedZip(urls, prefix, padding) { if (!urls || !urls.length) { alert('No data:image/webp;base64 images detected.'); return; } goBtn.disabled = true; scanBtn.disabled = true; infoEl.textContent = `Preparing ${urls.length} files...`; // Build inner zip (Chapter.zip) with all except first const innerZip = new JSZip(); if (urls.length > 1) { let counter = 1; for (let i = 1; i < urls.length; i++) { infoEl.textContent = `Adding to inner (${i}/${urls.length-1})...`; try { const arr = dataUrlToUint8Array(urls[i]); const filename = `${prefix}_${padNumber(counter, padding)}.webp`; innerZip.file(filename, arr, { binary: true }); counter++; } catch (e) { console.warn('Failed to add to inner zip', i, e); } await new Promise(r => setTimeout(r, 20)); } } else { infoEl.textContent = 'No other images — inner zip will be empty (0 files).'; } infoEl.textContent = `Generating inner "${INNER_ZIP_NAME}"...`; let innerBlob; try { innerBlob = await innerZip.generateAsync({ type: 'blob', compression: ZIP_COMPRESSION }, (meta) => { infoEl.textContent = `Inner zip ${meta.percent.toFixed(0)}%`; }); } catch (e) { console.error('Failed to generate inner zip', e); alert('Failed to create inner zip: ' + (e && e.message ? e.message : e)); goBtn.disabled = false; scanBtn.disabled = false; return; } await new Promise(r => setTimeout(r, 60)); // Outer zip: cover.webp + Chapter.zip const outerZip = new JSZip(); infoEl.textContent = 'Adding cover.webp to outer zip...'; try { const arrCover = dataUrlToUint8Array(urls[0]); outerZip.file('cover.webp', arrCover, { binary: true }); } catch (e) { console.warn('Failed to add cover', e); alert('Failed to add cover image into outer zip: ' + (e && e.message ? e.message : e)); } infoEl.textContent = `Adding ${INNER_ZIP_NAME} to outer zip...`; try { const innerArrayBuf = await blobToArrayBuffer(innerBlob); outerZip.file(INNER_ZIP_NAME, innerArrayBuf, { binary: true }); } catch (e) { console.error('Failed to add inner zip to outer zip', e); alert('Failed to include inner zip in outer zip: ' + (e && e.message ? e.message : e)); goBtn.disabled = false; scanBtn.disabled = false; return; } // Find outer filename from site, fallback to nameless let titleName = findSiteTitle(); if (!titleName) titleName = 'nameless'; const outerFilename = `${titleName}.zip`; infoEl.textContent = `Generating outer zip "${outerFilename}"...`; try { const outerBlob = await outerZip.generateAsync({ type: 'blob', compression: ZIP_COMPRESSION }, (meta) => { infoEl.textContent = `Outer zip ${meta.percent.toFixed(0)}%`; }); saveAs(outerBlob, outerFilename); infoEl.textContent = `Saved ${outerFilename} (cover + ${INNER_ZIP_NAME})`; setTimeout(() => alert(`Saved ${outerFilename}`), 200); } catch (e) { console.error('Failed to generate outer zip', e); alert('Failed to create outer zip: ' + (e && e.message ? e.message : e)); } finally { goBtn.disabled = false; scanBtn.disabled = false; } } // 2) Download individually: cover.webp then prefix_001.webp etc. async function downloadIndividually(urls, prefix, padding) { if (!urls || !urls.length) { alert('No data:image/webp;base64 images detected.'); return; } goBtn.disabled = true; scanBtn.disabled = true; infoEl.textContent = `Preparing ${urls.length} files for download...`; for (let i = 0; i < urls.length; i++) { const idx = i + 1; try { const dataUrl = urls[i]; const arr = dataUrlToUint8Array(dataUrl); const blob = new Blob([arr], { type: 'image/webp' }); const filename = i === 0 ? 'cover.webp' : `${prefix}_${padNumber(i, padding)}.webp`; infoEl.textContent = `Downloading ${filename} (${idx}/${urls.length})`; // download via temporary anchor const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 6000); await new Promise(r => setTimeout(r, 350)); // allow browser to process } catch (e) { console.warn('Failed to download', i, e); } } infoEl.textContent = `Downloaded ${urls.length} files individually.`; setTimeout(() => alert(`Downloaded ${urls.length} images individually.`), 200); goBtn.disabled = false; scanBtn.disabled = false; } // Start button handler: collapse UI immediately, then run chosen flow goBtn.addEventListener('click', async (e) => { e.stopPropagation(); // collapse panel immediately panel.style.display = 'none'; lastFound = collectOnlyWebpDataUrls(); if (!lastFound.length) { alert('No data:image/webp;base64 images found on this page.'); return; } const prefix = (prefixInput.value || DEFAULT_PREFIX).trim() || DEFAULT_PREFIX; let padding = parseInt(padInput.value, 10); if (!Number.isFinite(padding) || padding < 1) padding = DEFAULT_PADDING; const mode = panel.querySelector('input[name="we-mode"]:checked')?.value || 'nested'; if (mode === 'nested') { const proceed = confirm(`Nested mode: First will be cover.webp, others will be placed into "${INNER_ZIP_NAME}" inside outer zip named from the site (or "nameless"). Continue?`); if (!proceed) return; await createNestedZip(lastFound, prefix, padding); } else { const proceed = confirm(`Download individually: First will be cover.webp, others will be named ${prefix}_###.webp. Continue?`); if (!proceed) return; await downloadIndividually(lastFound, prefix, padding); } }); // initial scan renderScan(); })();