SingleFile - 单文件保存网页

保存当前页面的全部可见内容到一个.html文件中,包含了所有文字、排版、图像

2021-05-12 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         SingleFile - 单文件保存网页
// @namespace    SingleFile
// @version      1.0.2
// @description  保存当前页面的全部可见内容到一个.html文件中,包含了所有文字、排版、图像
// @author       PY-DNG
// @include      *
// @connect      *
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_info
// ==/UserScript==

// /*-pass*/ 标明待开发内容

(function () {
	'use strict';

	// Developer Mode
	const developer = true;

	// Inner consts
	const NUMBER_MAX_XHR = 20;
	const TEXT_SAVEPAGE = '保存此网页';
	const TEXT_SAVING = '保存中...';
	const TEXT_ABOUT = '<!-- Web Page Saved By {SCNM} Ver.{VRSN}, Author {ATNM} -->\n<!-- Page URL: {LINK} -->'
		.replaceAll('{SCNM}', GM_info.script.name)
		.replaceAll('{VRSN}', GM_info.script.version)
		.replaceAll('{ATNM}', GM_info.script.author)
		.replaceAll('{LINK}', location.href);

	// variants
	let i, j;

	let LogLevel = {
		None: 0,
		Error: 1,
		Success: 2,
		Warning: 3,
		Info: 4,
		Elements: 5,
	};
	let g_logCount = 0;
	let g_logLevel = LogLevel.Info;

	function DoLog(level = LogLevel.Info, msgOrElement, isElement = false) {
		if (level <= g_logLevel) {
			let prefix = '%c';
			let param = '';

			if (level == LogLevel.Error) {
				prefix += '[Error]';
				param = 'color:#ff0000';
			} else if (level == LogLevel.Success) {
				prefix += '[Success]';
				param = 'color:#00aa00';
			} else if (level == LogLevel.Warning) {
				prefix += '[Warning]';
				param = 'color:#ffa500';
			} else if (level == LogLevel.Info) {
				prefix += '[Info]';
				param = 'color:#888888';
			} else if (level == LogLevel.Elements) {
				prefix += 'Elements';
				param = 'color:#000000';
			}

			if (level != LogLevel.Elements && !isElement) {
				console.log(prefix + msgOrElement, param);
			} else {
				console.log(msgOrElement);
			}

			if (++g_logCount > 512) {
				console.clear();
				g_logCount = 0;
			}
		}
	}

	// XHRHOOK
	GMXHRHook(NUMBER_MAX_XHR);

	// Task list
	const taskList = [getDom, removeScripts, dealStyles, dealElements, output];
	let taskNow = null;

	let Dom;
	let saving = false, cmdID;

	GUI();

	function GUI() {
		cmdID = GM_registerMenuCommand(TEXT_SAVEPAGE, saveOnclick);
	}

	function saveOnclick() {
		if (saving) {return false;};
		switchStatus();
		DoLog(LogLevel.Success, 'SingleFile started.');
		nextTask();
	}

	function switchStatus() {
		saving = !saving;
		if (cmdID) {GM_unregisterMenuCommand(cmdID);};
		cmdID = GM_registerMenuCommand(saving ? TEXT_SAVING : TEXT_SAVEPAGE, saveOnclick);
	}

	function getDom() {
		DoLog(LogLevel.Info, 'Getting document...');
		const HTML_ORGINAL = document.querySelector('html').outerHTML;
		Dom = new DOMParser().parseFromString(HTML_ORGINAL, 'text/html');
		DoLog(LogLevel.Info, Dom, true);
		nextTask();
	}

	function removeScripts() {
		DoLog(LogLevel.Info, 'Removing scripts...');
		const scripts = Dom.querySelectorAll('script');
		for (i = 0; i < scripts.length; i++) {
			scripts[i].parentElement.removeChild(scripts[i]);
		}
		DoLog(Dom, true)
		DoLog(scripts, true);

		nextTask();
	}

	function dealStyles() {
		DoLog(LogLevel.Info, 'Dealing styles...');
		const CSSLinks = Dom.querySelectorAll('link[rel="stylesheet"]');
		let style = '', rest = CSSLinks.length;

		for (const cLink of CSSLinks) {
			if (!cLink.href) {continue;};
			DoLog(LogLevel.Info, 'Requesting style from ' + cLink.href);
			requestText(cLink.href, addToStyleText);
		}

		function addToStyleText(styleText) {
			style += styleText;
			rest--;
			DoLog(LogLevel.Info, 'Style got. Rest: ' + String(rest));
			if (rest === 0) {
				finish();
			}
		}

		function finish() {
			// Insert style element
			const styleEle = Dom.createElement('style');
			styleEle.innerHTML = style;
			const firstInnerStyle = Dom.querySelector('style');
			firstInnerStyle ?
				firstInnerStyle.parentElement.insertBefore(styleEle, firstInnerStyle) :
				Dom.head.appendChild(styleEle);

			// Remove link elements
			for (const link of CSSLinks) {
				link.parentElement.removeChild(link);
			}

			nextTask();
		}
	}

	function dealElements() {
		DoLog(LogLevel.Info, 'dealing elements...');
		const allEles = Dom.querySelectorAll('*');
		let restElesCount = allEles.length;
		for (const element of allEles) {
			dealElement(element);
		}

		function dealElement(element) {
			DoLog(LogLevel.Info, element, true);

			dealImg(element);
		}

		function dealImg(element) {
			const nextDealingTask = function() {dealBackgroundImg(element);};
			if (element.tagName === 'IMG' && element.src !== '') {
				if (element.src.substr(0,5) !== 'data:') {
					requestImageURL(element.src, function(dataURL) {
						element.src = dataURL;
						// 如何处理canvas? /*-pass*/
						// next dealing task
						nextDealingTask();
					})
				} else {nextDealingTask();}
			} else {nextDealingTask();}
		}

		function dealBackgroundImg(element) {
			// background-image to dataURL
			const cStyle = getComputedStyle(element);
			const backgroundImage = cStyle['background-image'];
			const httpUrlMatch = backgroundImage.match(/url\("(http.+)"\)/);
			if (httpUrlMatch) {
				const url = httpUrlMatch[1].replaceAll('\\\\', '\\');
				requestImageURL(url, function(dataURL) {
					const propValue = backgroundImage.replace(httpUrlMatch[1], dataURL);
					element.style['background-image'] = propValue;
					elementDealed();
				});
			} else {
				elementDealed();
			}
		}

		function elementDealed() {
			restElesCount--;
			DoLog(LogLevel.Info, 'element dealed, rest: ' + String(restElesCount) + ' elements')
			if (restElesCount === 0) {
				nextTask();
			}
		}
	}

	function output() {
		DoLog(LogLevel.Success, 'SingleFile finished.');
		DoLog(LogLevel.Success, Dom, true);

		const outputText = TEXT_ABOUT + '\n\n' + Dom.lastChild.outerHTML;
		saveTextToFile(outputText, 'SingleFile - ' + document.title + '.html');
		switchStatus();
	}

	function nextTask() {
		const funcIndex = taskNow ? taskList.indexOf(taskNow) : -1;
		if (funcIndex === taskList.length - 1) {
			taskNow = taskList[0];
			return true;
		}
		taskNow = taskList[funcIndex+1];
		taskNow();
	}

	function requestText(url, callback, args=[]) {
		GM_xmlhttpRequest({
            method:       'GET',
            url:          url,
            responseType: 'text',
            onload:       function(response) {
                const text = response.responseText;
				const argvs = [text].concat(args);
                callback.apply(null, argvs);
            }
        })
	}

	function requestImageURL(url, callback, args=[]) {
		GM_xmlhttpRequest({
            method:       'GET',
            url:          url,
            responseType: 'blob',
            onload:       function(response) {
                const blob = response.response;
				blobToDataURI(blob, function(url) {
					const argvs = [url].concat(args);
					callback.apply(null, argvs);
				})
            }
        })

		function blobToDataURI(blob, callback) {
			var reader = new FileReader();
			reader.onload = function (e) {
				callback(e.target.result);
			}
			reader.readAsDataURL(blob);
		}
	}

	// GM_XHR HOOK: The number of running GM_XHRs in a time must under maxXHR
	// Returns the abort function to stop the request anyway(no matter it's still waiting, or requesting)
	// (If the request is invalid, such as url === '', will return false and will NOT make this request)
	// If the abort function called on a request that is not running(still waiting or finished), there will be NO onabort event
	// Requires: function delItem(){...} & function uniqueIDMaker(){...}
	function GMXHRHook(maxXHR=5) {
		const GM_XHR = GM_xmlhttpRequest;
		const getID = uniqueIDMaker();
		let todoList = [], ongoingList = [];
		GM_xmlhttpRequest = safeGMxhr;

		function safeGMxhr() {
			// Get an id for this request, arrange a request object for it.
			const id = getID();
			const request = {id: id, args: arguments, aborter: null};

			// Deal onload function first
			dealEndingEvents(request);

			// Stop invalid requests
			if (!validCheck(request)) {
				return false;
			}

			// Judge if we could start the request now or later?
			todoList.push(request);
			checkXHR();
			return makeAbortFunc(id);

			// Decrease activeXHRCount while GM_XHR onload;
			function dealEndingEvents(request) {
				const e = request.args[0];

				// onload event
				const oriOnload = e.onload;
				e.onload = function() {
					reqFinish(request.id);
					checkXHR();
					oriOnload ? oriOnload.apply(null, arguments) : function() {};
				}

				// onerror event
				const oriOnerror = e.onerror;
				e.onerror = function() {
					reqFinish(request.id);
					checkXHR();
					oriOnerror ? oriOnerror.apply(null, arguments) : function() {};
				}

				// ontimeout event
				const oriOntimeout = e.ontimeout;
				e.ontimeout = function() {
					reqFinish(request.id);
					checkXHR();
					oriOntimeout ? oriOntimeout.apply(null, arguments) : function() {};
				}

				// onabort event
				const oriOnabort = e.onabort;
				e.onabort = function() {
					reqFinish(request.id);
					checkXHR();
					oriOnabort ? oriOnabort.apply(null, arguments) : function() {};
				}
			}

			// Check if the request is invalid
			function validCheck(request) {
				const e = request.args[0];

				if (!e.url) {
					return false;
				}

				return true;
			}

			// Call a XHR from todoList and push the request object to ongoingList if called
			function checkXHR() {
				if (ongoingList.length >= maxXHR) {return false;};
				if (todoList.length === 0) {return false;};
				const req = todoList.shift();
				const reqArgs = req.args;
				const aborter = GM_XHR.apply(null, reqArgs);
				req.aborter = aborter;
				ongoingList.push(req);
				return req;
			}

			// Make a function that aborts a certain request
			function makeAbortFunc(id) {
				return function() {
					let i;

					// Check if the request haven't been called
					for (i = 0; i < todoList.length; i++) {
						const req = todoList[i];
						if (req.id === id) {
							// found this request: haven't been called
							delItem(todoList, i);
							return true;
						}
					}

					// Check if the request is running now
					for (i = 0; i < ongoingList.length; i++) {
						const req = todoList[i];
						if (req.id === id) {
							// found this request: running now
							req.aborter();
							reqFinish(id);
							checkXHR();
						}
					}

					// Oh no, this request is already finished...
					return false;
				}
			}

			// Remove a certain request from ongoingList
			function reqFinish(id) {
				let i;
				for (i = 0; i < ongoingList.length; i++) {
					const req = ongoingList[i];
					if (req.id === id) {
						ongoingList = delItem(ongoingList, i);
						return true;
					}
				}
				return false;
			}
		}
	}

	// Del a item from an array using its index. Returns the array but can NOT modify the original array directly!!
	function delItem(arr, delIndex) {
		arr = arr.slice(0, delIndex).concat(arr.slice(delIndex+1));
		return arr;
	}

	// Makes a function that returns a unique ID number each time
	function uniqueIDMaker() {
		let id = 0;
		return makeID;
		function makeID() {
			id++;
			return id;
		}
	}

	function saveTextToFile(text, name) {
		const blob = new Blob([text],{type:"text/plain;charset=utf-8"});
		const url = URL.createObjectURL(blob);
		const a = document.createElement('a');
		a.href = url;
		a.download = name;
		a.click();
	}
})();