// ==UserScript== // @name Neocities CYOA Downloader // @namespace http://tampermonkey.net/ // @version 1.0 // @description Downloads CYOA project.json and images from Neocities sites as a ZIP with a progress bar // @author Grok // @license MIT // @match *://*.neocities.org/* // @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 // @downloadURL none // ==/UserScript== (function() { 'use strict'; // Check if on a Neocities site if (!window.location.hostname.endsWith('.neocities.org')) { return; } // Create progress bar UI const progressContainer = document.createElement('div'); progressContainer.style.position = 'fixed'; progressContainer.style.top = '10px'; progressContainer.style.right = '10px'; progressContainer.style.zIndex = '10000'; progressContainer.style.backgroundColor = '#fff'; progressContainer.style.padding = '10px'; progressContainer.style.border = '1px solid #000'; progressContainer.style.borderRadius = '5px'; progressContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)'; const progressLabel = document.createElement('div'); progressLabel.textContent = 'Preparing to download CYOA...'; progressLabel.style.marginBottom = '5px'; const progressBar = document.createElement('div'); progressBar.style.width = '200px'; progressBar.style.height = '20px'; progressBar.style.backgroundColor = '#e0e0e0'; progressBar.style.borderRadius = '3px'; progressBar.style.overflow = 'hidden'; const progressFill = document.createElement('div'); progressFill.style.width = '0%'; progressFill.style.height = '100%'; progressFill.style.backgroundColor = '#4caf50'; progressFill.style.transition = 'width 0.3s'; progressBar.appendChild(progressFill); progressContainer.appendChild(progressLabel); progressContainer.appendChild(progressBar); document.body.appendChild(progressContainer); // Utility functions function extractProjectName(url) { try { const hostname = new URL(url).hostname; if (hostname.endsWith('.neocities.org')) { return hostname.replace('.neocities.org', ''); } return hostname; } catch (e) { return 'project'; } } function updateProgress(value, max, label) { const percentage = (value / max) * 100; progressFill.style.width = `${percentage}%`; progressLabel.textContent = label; } async function findImages(obj, baseUrl, imageUrls) { if (typeof obj === 'object' && obj !== null) { if (obj.image && typeof obj.image === 'string' && !obj.image.includes('base64,')) { try { const url = new URL(obj.image, baseUrl).href; imageUrls.add(url); } catch (e) { console.warn(`Invalid image URL: ${obj.image}`); } } for (const key in obj) { await findImages(obj[key], baseUrl, imageUrls); } } else if (Array.isArray(obj)) { for (const item of obj) { await findImages(item, baseUrl, imageUrls); } } } async function downloadCYOA() { const baseUrl = window.location.href.endsWith('/') ? window.location.href : window.location.href + '/'; const projectJsonUrl = new URL('project.json', baseUrl).href; const projectName = extractProjectName(baseUrl); const zip = new JSZip(); const imagesFolder = zip.folder('images'); const externalImages = []; try { // Download project.json updateProgress(0, 100, 'Downloading project.json...'); const response = await fetch(projectJsonUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const projectData = await response.json(); // Save project.json zip.file(`${projectName}.json`, JSON.stringify(projectData, null, 2)); updateProgress(10, 100, 'Scanning for images...'); // Extract image URLs const imageUrls = new Set(); await findImages(projectData, baseUrl, imageUrls); const imageUrlArray = Array.from(imageUrls); // Download images for (let i = 0; i < imageUrlArray.length; i++) { const url = imageUrlArray[i]; try { updateProgress(10 + (i / imageUrlArray.length) * 80, 100, `Downloading image ${i + 1}/${imageUrlArray.length}...`); const response = await fetch(url); if (!response.ok) { externalImages.push(url); continue; } const blob = await response.blob(); const filename = url.split('/').pop(); imagesFolder.file(filename, blob); } catch (e) { console.warn(`Failed to download image ${url}: ${e}`); externalImages.push(url); } } // Generate ZIP updateProgress(90, 100, 'Creating ZIP file...'); const content = await zip.generateAsync({ type: 'blob' }); saveAs(content, `${projectName}.zip`); updateProgress(100, 100, 'Download complete!'); setTimeout(() => progressContainer.remove(), 2000); // Log external images if (externalImages.length > 0) { console.warn('Some images could not be downloaded (external/CORS issues):'); externalImages.forEach(url => console.log(url)); } } catch (e) { console.error(`Error: ${e}`); progressLabel.textContent = 'Error occurred. Check console.'; progressFill.style.backgroundColor = '#f44336'; setTimeout(() => progressContainer.remove(), 5000); } } // Add download button const downloadButton = document.createElement('button'); downloadButton.textContent = 'Download CYOA'; downloadButton.style.marginTop = '10px'; downloadButton.style.padding = '5px 10px'; downloadButton.style.cursor = 'pointer'; downloadButton.onclick = downloadCYOA; progressContainer.appendChild(downloadButton); })();