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

  1. // ==UserScript==
  2. // @name Neocities CYOA Downloader (Any JSON Enhanced)
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1
  5. // @description Downloads CYOA JSON and images from Neocities sites as a ZIP with a progress bar, searching for any JSON file
  6. // @author Grok
  7. // @license MIT
  8. // @match *://*.neocities.org/*
  9. // @grant none
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Check if on a Neocities site
  18. if (!window.location.hostname.endsWith('.neocities.org')) {
  19. return;
  20. }
  21.  
  22. // Create progress bar UI
  23. const progressContainer = document.createElement('div');
  24. progressContainer.style.position = 'fixed';
  25. progressContainer.style.top = '10px';
  26. progressContainer.style.right = '10px';
  27. progressContainer.style.zIndex = '10000';
  28. progressContainer.style.backgroundColor = '#fff';
  29. progressContainer.style.padding = '10px';
  30. progressContainer.style.border = '1px solid #000';
  31. progressContainer.style.borderRadius = '5px';
  32. progressContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
  33.  
  34. const progressLabel = document.createElement('div');
  35. progressLabel.textContent = 'Preparing to download CYOA...';
  36. progressLabel.style.marginBottom = '5px';
  37.  
  38. const progressBar = document.createElement('div');
  39. progressBar.style.width = '200px';
  40. progressBar.style.height = '20px';
  41. progressBar.style.backgroundColor = '#e0e0e0';
  42. progressBar.style.borderRadius = '3px';
  43. progressBar.style.overflow = 'hidden';
  44.  
  45. const progressFill = document.createElement('div');
  46. progressFill.style.width = '0%';
  47. progressFill.style.height = '100%';
  48. progressFill.style.backgroundColor = '#4caf50';
  49. progressFill.style.transition = 'width 0.3s';
  50.  
  51. progressBar.appendChild(progressFill);
  52. progressContainer.appendChild(progressLabel);
  53. progressContainer.appendChild(progressBar);
  54. document.body.appendChild(progressContainer);
  55.  
  56. // Utility functions
  57. function extractProjectName(url) {
  58. try {
  59. const hostname = new URL(url).hostname;
  60. if (hostname.endsWith('.neocities.org')) {
  61. return hostname.replace('.neocities.org', '');
  62. }
  63. return hostname;
  64. } catch (e) {
  65. return 'project';
  66. }
  67. }
  68.  
  69. function updateProgress(value, max, label) {
  70. const percentage = (value / max) * 100;
  71. progressFill.style.width = `${percentage}%`;
  72. progressLabel.textContent = label;
  73. }
  74.  
  75. async function findImages(obj, baseUrl, imageUrls) {
  76. if (typeof obj === 'object' && obj !== null) {
  77. if (obj.image && typeof obj.image === 'string' && !obj.image.includes('base64,')) {
  78. try {
  79. const url = new URL(obj.image, baseUrl).href;
  80. imageUrls.add(url);
  81. } catch (e) {
  82. console.warn(`Invalid image URL: ${obj.image}`);
  83. }
  84. }
  85. for (const key in obj) {
  86. await findImages(obj[key], baseUrl, imageUrls);
  87. }
  88. } else if (Array.isArray(obj)) {
  89. for (const item of obj) {
  90. await findImages(item, baseUrl, imageUrls);
  91. }
  92. }
  93. }
  94.  
  95. async function findJsonFile(baseUrl) {
  96. // Step 1: Check all HTML elements for .json references
  97. const elements = document.querySelectorAll('script[src], link[href], a[href]');
  98. for (const el of elements) {
  99. const url = el.src || el.href;
  100. if (url && url.endsWith('.json')) {
  101. return new URL(url, baseUrl).href;
  102. }
  103. }
  104.  
  105. // Step 2: Check inline scripts for JSON references
  106. const inlineScripts = document.querySelectorAll('script:not([src])');
  107. for (const script of inlineScripts) {
  108. const matches = script.textContent.match(/"[^"]*\.json"/g);
  109. if (matches) {
  110. for (const match of matches) {
  111. const jsonFile = match.replace(/"/g, '');
  112. try {
  113. return new URL(jsonFile, baseUrl).href;
  114. } catch (e) {
  115. continue;
  116. }
  117. }
  118. }
  119. }
  120.  
  121. // Step 3: Try an expanded list of common JSON file names
  122. const commonNames = [
  123. 'project.json', 'data.json', 'cyoa.json', 'config.json',
  124. 'game.json', 'settings.json', 'content.json', 'main.json',
  125. 'story.json', 'options.json', 'assets.json', 'tokhaar.json'
  126. ];
  127. for (const name of commonNames) {
  128. const url = new URL(name, baseUrl).href;
  129. try {
  130. const response = await fetch(url, { method: 'HEAD' });
  131. if (response.ok) {
  132. return url;
  133. }
  134. } catch (e) {
  135. continue;
  136. }
  137. }
  138.  
  139. // Step 4: Prompt user with detailed instructions
  140. const userInput = prompt(
  141. 'Could not find JSON file. Please enter the JSON file name (e.g., data.json).\n' +
  142. 'To find the correct file:\n' +
  143. '1. Open DevTools (F12 or right-click -> Inspect).\n' +
  144. '2. Go to the Network tab.\n' +
  145. '3. Refresh the page (F5).\n' +
  146. '4. Look for a .json file in the list (e.g., data.json).\n' +
  147. 'Enter the file name or leave blank to cancel.'
  148. );
  149. if (userInput && userInput.trim().endsWith('.json')) {
  150. return new URL(userInput.trim(), baseUrl).href;
  151. }
  152.  
  153. throw new Error('No JSON file found and no valid user input provided.');
  154. }
  155.  
  156. async function downloadCYOA() {
  157. const baseUrl = window.location.href.endsWith('/') ? window.location.href : window.location.href + '/';
  158. const projectName = extractProjectName(baseUrl);
  159. const zip = new JSZip();
  160. const imagesFolder = zip.folder('images');
  161. const externalImages = [];
  162.  
  163. try {
  164. // Find and download JSON file
  165. updateProgress(0, 100, 'Searching for JSON file...');
  166. const projectJsonUrl = await findJsonFile(baseUrl);
  167. updateProgress(5, 100, 'Downloading JSON file...');
  168. const response = await fetch(projectJsonUrl);
  169. if (!response.ok) {
  170. throw new Error(`HTTP ${response.status} for ${projectJsonUrl}`);
  171. }
  172. const projectData = await response.json();
  173.  
  174. // Save JSON file with project name
  175. const jsonFileName = projectJsonUrl.split('/').pop();
  176. zip.file(`${projectName}.json`, JSON.stringify(projectData, null, 2));
  177. updateProgress(10, 100, 'Scanning for images...');
  178.  
  179. // Extract image URLs
  180. const imageUrls = new Set();
  181. await findImages(projectData, baseUrl, imageUrls);
  182. const imageUrlArray = Array.from(imageUrls);
  183.  
  184. // Download images
  185. for (let i = 0; i < imageUrlArray.length; i++) {
  186. const url = imageUrlArray[i];
  187. try {
  188. updateProgress(10 + (i / imageUrlArray.length) * 80, 100, `Downloading image ${i + 1}/${imageUrlArray.length}...`);
  189. const response = await fetch(url);
  190. if (!response.ok) {
  191. externalImages.push(url);
  192. continue;
  193. }
  194. const blob = await response.blob();
  195. const filename = url.split('/').pop();
  196. imagesFolder.file(filename, blob);
  197. } catch (e) {
  198. console.warn(`Failed to download image ${url}: ${e}`);
  199. externalImages.push(url);
  200. }
  201. }
  202.  
  203. // Generate ZIP
  204. updateProgress(90, 100, 'Creating ZIP file...');
  205. const content = await zip.generateAsync({ type: 'blob' });
  206. saveAs(content, `${projectName}.zip`);
  207.  
  208. updateProgress(100, 100, 'Download complete!');
  209. setTimeout(() => progressContainer.remove(), 2000);
  210.  
  211. // Log external images
  212. if (externalImages.length > 0) {
  213. console.warn('Some images could not be downloaded (external/CORS issues):');
  214. externalImages.forEach(url => console.log(url));
  215. }
  216. } catch (e) {
  217. console.error(`Error: ${e}`);
  218. progressLabel.textContent = `Error: ${e.message}. Check console.`;
  219. progressFill.style.backgroundColor = '#f44336';
  220. setTimeout(() => progressContainer.remove(), 5000);
  221. }
  222. }
  223.  
  224. // Add download button
  225. const downloadButton = document.createElement('button');
  226. downloadButton.textContent = 'Download CYOA';
  227. downloadButton.style.marginTop = '10px';
  228. downloadButton.style.padding = '5px 10px';
  229. downloadButton.style.cursor = 'pointer';
  230. downloadButton.onclick = downloadCYOA;
  231. progressContainer.appendChild(downloadButton);
  232. })();