Neocities CYOA Downloader (Any JSON Enhanced)

Downloads CYOA JSON and images from Neocities sites as a ZIP with a progress bar, searching for any JSON file

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Neocities CYOA Downloader (Any JSON Enhanced)
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Downloads CYOA JSON and images from Neocities sites as a ZIP with a progress bar, searching for any JSON file
// @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
// ==/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 findJsonFile(baseUrl) {
        // Step 1: Check all HTML elements for .json references
        const elements = document.querySelectorAll('script[src], link[href], a[href]');
        for (const el of elements) {
            const url = el.src || el.href;
            if (url && url.endsWith('.json')) {
                return new URL(url, baseUrl).href;
            }
        }

        // Step 2: Check inline scripts for JSON references
        const inlineScripts = document.querySelectorAll('script:not([src])');
        for (const script of inlineScripts) {
            const matches = script.textContent.match(/"[^"]*\.json"/g);
            if (matches) {
                for (const match of matches) {
                    const jsonFile = match.replace(/"/g, '');
                    try {
                        return new URL(jsonFile, baseUrl).href;
                    } catch (e) {
                        continue;
                    }
                }
            }
        }

        // Step 3: Try an expanded list of common JSON file names
        const commonNames = [
            'project.json', 'data.json', 'cyoa.json', 'config.json',
            'game.json', 'settings.json', 'content.json', 'main.json',
            'story.json', 'options.json', 'assets.json', 'tokhaar.json'
        ];
        for (const name of commonNames) {
            const url = new URL(name, baseUrl).href;
            try {
                const response = await fetch(url, { method: 'HEAD' });
                if (response.ok) {
                    return url;
                }
            } catch (e) {
                continue;
            }
        }

        // Step 4: Prompt user with detailed instructions
        const userInput = prompt(
            'Could not find JSON file. Please enter the JSON file name (e.g., data.json).\n' +
            'To find the correct file:\n' +
            '1. Open DevTools (F12 or right-click -> Inspect).\n' +
            '2. Go to the Network tab.\n' +
            '3. Refresh the page (F5).\n' +
            '4. Look for a .json file in the list (e.g., data.json).\n' +
            'Enter the file name or leave blank to cancel.'
        );
        if (userInput && userInput.trim().endsWith('.json')) {
            return new URL(userInput.trim(), baseUrl).href;
        }

        throw new Error('No JSON file found and no valid user input provided.');
    }

    async function downloadCYOA() {
        const baseUrl = window.location.href.endsWith('/') ? window.location.href : window.location.href + '/';
        const projectName = extractProjectName(baseUrl);
        const zip = new JSZip();
        const imagesFolder = zip.folder('images');
        const externalImages = [];

        try {
            // Find and download JSON file
            updateProgress(0, 100, 'Searching for JSON file...');
            const projectJsonUrl = await findJsonFile(baseUrl);
            updateProgress(5, 100, 'Downloading JSON file...');
            const response = await fetch(projectJsonUrl);
            if (!response.ok) {
                throw new Error(`HTTP ${response.status} for ${projectJsonUrl}`);
            }
            const projectData = await response.json();

            // Save JSON file with project name
            const jsonFileName = projectJsonUrl.split('/').pop();
            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: ${e.message}. 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);
})();