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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
})();