Greasy Fork is available in English.

轻小说文库+

TXT分卷批量下载,版权限制小说TXT简繁全本下载,书名/作者名双击复制,Ctrl+Enter快捷键发表书评,单章节下载,小说JPEG插图下载,下载线路点击切换,书评帖子全贴下载保存,书评帖子回复功能增强,书架功能增强,修复文库插入链接和图片无法识别https的自身bug,轻小说标签搜索(Feature Preview),用户书评搜索,简单的阅读页和目录页美化,每日自动推书

目前为 2021-06-30 提交的版本。查看 最新版本

/* eslint-disable no-multi-spaces */

// ==UserScript==
// @name         轻小说文库+
// @namespace    Wenku8+
// @version      1.2.4.2
// @description  TXT分卷批量下载,版权限制小说TXT简繁全本下载,书名/作者名双击复制,Ctrl+Enter快捷键发表书评,单章节下载,小说JPEG插图下载,下载线路点击切换,书评帖子全贴下载保存,书评帖子回复功能增强,书架功能增强,修复文库插入链接和图片无法识别https的自身bug,轻小说标签搜索(Feature Preview),用户书评搜索,简单的阅读页和目录页美化,每日自动推书
// @author       PY-DNG
// @match        http*://www.wenku8.net/*
// @connect      wenku8.com
// @connect      wenku8.net
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_info
// @require      https://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=939169
// @require      https://greasyfork.org/scripts/425166-elegant-alert-%E5%BA%93/code/elegant%20alert()%E5%BA%93.js?version=922763
// @noframes
// ==/UserScript==

/* 需求记录 [容易(优先级高) ➡️ 困难(优先级低)]
** [已完成]{BK}书评页提供用户书评搜索
** {BK}图片大小(最大)限制
** {BK}回复区插入@好友
** {jack158}[部分完成]全卷/分卷下载:文件重命名为书名,而不是书号
** · [已完成分卷&Book页]添加单文件下载重命名
** {BK}回复区悬浮显示
** {热忱}[已完成]修复https引用问题
** [已完成]引用回复
** [beta已完成]支持preview版tag搜索
** [已完成]每日自动推书
** {BK}类似ehunter的阅读模式
** 改进旧代码:
** · 每个page-addon内部要按照功能分模块,执行功能靠调用模块,不能直接写功能代码
** · 共性模块要写进脚本全局作用域,可以的话写成构造函数
** {热忱}书评:@某人时通知他
** {BK}[部分完成]页面美化
** · [已完成]阅读页去除广告
** · …
** {热忱}提供带文字和插图的epub整合下载
** {BK}[待完善]书评:草稿箱功能
*/
(function() {
    'use strict';

    // CONSTS
	const NUMBER_MAX_XHR = 10;
	const NUMBER_LOGSUCCESS_AFTER = NUMBER_MAX_XHR * 2;
	const NUMBER_ELEMENT_LOADING_WAIT_INTERVAL = 500;

	const KEY_COMMENT_DRAFTS = 'comment-drafts';
	const KEY_DRAFT_VERSION = 'version';
	const VALUE_DRAFT_VERSION = '0.1';

	const KEY_BOOKCASES = 'book-cases';
	const KEY_BOOKCASE_VERSION = 'version';
	const VALUE_BOOKCASE_VERSION = '0.1';

	const KEY_ATRCMMDS = 'auto-recommends';
	const KEY_ATRCMMDS_VERSION = 'version';
	const VALUE_ATRCMMDS_VERSION = '0.2';

	const KEY_USRDETAIL = 'user-detail';
	const KEY_USRDETAIL_VERSION = 'version';
	const VALUE_USRDETAIL_VERSION = '0.1';

	const VALUE_STR_NULL = 'null';

	const URL_REVIEWSEARCH = 'https://www.wenku8.net/modules/article/reviewslist.php?keyword={K}';
	const URL_USERINFO = 'https://www.wenku8.net/userinfo.php?id={K}';
	const URL_DOWNLOAD1 = 'http://dl.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_DOWNLOAD2 = 'http://dl2.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_DOWNLOAD3 = 'http://dl3.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_RECOMMEND = 'https://www.wenku8.net/modules/article/uservote.php?id={B}';
	const URL_TAGSEARCH = 'https://www.wenku8.net/modules/article/tags.php?t={TU}';
	const URL_USRDETALE = 'https://www.wenku8.net/userdetail.php';

	const CLASSNAME_BUTTON = 'plusbtn';
	const CLASSNAME_TEXT = 'plustext';
	const CLASSNAME_BOOKCASE_FORM = 'bcform';

	const HTML_BOOK_COPY = '<span class="{C}">[复制]</span>'.replace('{C}', CLASSNAME_BUTTON);
	const HTML_BOOK_META = '{K}:{V}<span class="{C}">[复制]</span>'.replace('{C}', CLASSNAME_BUTTON);
	const HTML_BOOK_TAG = '<a class="{C}" href="{U}" target="_blank">{TN}</span>'.replace('{C}', CLASSNAME_BUTTON).replace('{U}', URL_TAGSEARCH);
    const HTML_DOWNLOAD_CONTENER = '<div id="dctn" style=\"margin:0px auto;overflow:hidden;\">\n<fieldset style=\"width:820px;height:35px;margin:0px auto;padding:0px;\">\n<legend><b>《{BOOKNAME}》小说TXT简繁全本下载</b></legend>\n</fieldset>\n</div>';
    const HTML_DOWNLOAD_LINKS = '<div id="txtfull"style="margin:0px auto;overflow:hidden;"><fieldset style="width:820px;height:35px;margin:0px auto;padding:0px;"><legend><b>《{ORIBOOKNAME}》小说TXT全本下载</b></legend><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=txt&amp;id={BOOKID}">G版原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=txt&amp;id={BOOKID}&amp;fname={BOOKNAME}.txt">G版自动重命名</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=utf8&amp;id={BOOKID}">U版原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=utf8&amp;id={BOOKID}&amp;fname={BOOKNAME}">U版自动重命名</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=big5&amp;id={BOOKID}">繁体原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=big5&amp;id={BOOKID}&amp;fname={BOOKNAME}">繁体自动重命名</a></div></fieldset></div>'.replaceAll('{C}', CLASSNAME_BUTTON);
    const HTML_DOWNLOAD_BOARD = '<span class="{C}">[轻小说文库+] 为您提供《{ORIBOOKNAME}》的TXT简繁全本下载!</br>由此产生的一切法律及其他问题均由脚本用户承担</br>—— PY-DNG</span>'.replace('{C}', CLASSNAME_TEXT);
    const CSS_DOWNLOAD = '.even {display: grid; grid-template-columns: repeat(3, 1fr); text-align: center;} .dlink {text-align: center;}';
    const CSS_COLOR_BTN_NORMAL = 'rgb(0, 160, 0)', CSS_COLOR_BTN_HOVER = 'rgb(0, 100, 0)';
    const CSS_COMMON = '.{CT} {color: rgb(30, 100, 220) !important;} .{CB} {color: rgb(0, 160, 0) !important; cursor: pointer !important;} .{CB}:hover {color: rgb(0, 100, 0) !important;} .{CB}:focus {color: rgb(0, 100, 0) !important;}'.replaceAll('{CB}', CLASSNAME_BUTTON).replaceAll('{CT}', CLASSNAME_TEXT);

	const ARR_GUI_BOOKCASE_WIDTH = ['3%', '19%', '9%', '25%', '20%', '9%', '5%', '10%'];

    const TEXT_TIP_COPY = '点击复制';
	const TEXT_TIP_COPIED = '已复制';
    const TEXT_TIP_SERVERCHANGE = '点击切换线路';
	const TEXT_TIP_SEARCH_OPTION_TAG = '有关标签搜索</br></br>未完善-开发中…</br>官方尚未正式开放此功能</br>功能预览由[轻小说文库+]提供';
	const TEXT_ALT_DOWNLOADFINISH_REVIEW = '{T}({I}) 已下载完毕</br>{N} 已保存';
	const TEXT_ALT_META_COPIED = '{M} 已复制';
	const TEXT_ALT_ATRCMMDS_SAVED = '已保存:《{B}》</br>每日自动推荐{N}次</br>每日还可推荐{R}次';
	const TEXT_ALT_ATRCMMDS_INVALID = '未保存:{N}不是非负整数';
	const TEXT_ALT_ATRCMMDS_OVERFLOW = '注意:</br>您的用户信息显示您每天最多推荐{V}票</br>当前您已设置每日推荐合计{C}票</br>[单击此处以立即更新您的用户信息]';
	const TEXT_ALT_ATRCMMDS_AUTO = '已开启自动推书';
	const TEXT_ALT_ATRCMMDS_NOAUTO = '已关闭自动推书';
	const TEXT_ALT_ATRCMMDS_ALL_START = '{S}:正在自动推书...'.replaceAll('{S}', GM_info.script.name);
	const TEXT_ALT_ATRCMMDS_RUNNING = '正在推荐书目:</br>{BN}({BID})';
	const TEXT_ALT_ATRCMMDS_DONE = '推荐完成:</br>{BN}({BID})';
	const TEXT_ALT_ATRCMMDS_ALL_DONE = '全部书目推荐完成:</br>{R}';
	const TEXT_ALT_USRDTL_REFRESH = '{S}:正在更新用户信息({T})...'.replaceAll('{S}', GM_info.script.name).replaceAll('{T}', getTime());
	const TEXT_ALT_USRDTL_REFRESHED = '{S}:用户信息已更新</br>点此查看详细信息'.replaceAll('{S}', GM_info.script.name);
    const TEXT_GUI_DOWNLOAD_IMAGE = '下载图片';
    const TEXT_GUI_DOWNLOAD_TEXT = '下载本章';
    const TEXT_GUI_DOWNLOAD_REVIEW = '[下载本帖(共A页)]';
    const TEXT_GUI_DOWNLOADING_REVIEW = '[下载中...(C/A)]';
    const TEXT_GUI_DOWNLOADFINISH_REVIEW = '[下载完毕]';
	const TEXT_GUI_DOWNLOADALL = '下载全部分卷,请点击右边的按钮:';
	const TEXT_GUI_WAITING = ' 等待中...';
    const TEXT_GUI_DOWNLOADING = ' 下载中...';
    const TEXT_GUI_DOWNLOADED = ' (下载完毕)';
	const TEXT_GUI_NOTHINGHERE = '<span style="color:grey">-Nothing Here-</span>';
	const TEXT_GUI_SDOWNLOAD = '地址三(程序重命名)';
    const TEXT_GUI_DOWNLOADING_ALL = '下载中...(C/A)';
    const TEXT_GUI_DOWNLOADED_ALL = '下载图片(已完成)';
	const TEXT_GUI_AUTOSAVE = '(您输入的内容已保存到书评草稿中)';
	const TEXT_GUI_AUTOSAVE_CLEAR = '(草稿为空)';
	const TEXT_GUI_AUTOSAVE_RESTORE = '(已从书评草稿中恢复了您上次编辑的内容)';
	const TEXT_GUI_BOOKCASE_GETTING = '正在搬运书架...(C/A)';
	const TEXT_GUI_BOOKCASE_TOPTITLE = '您的书架可收藏 A 本,已收藏 B 本';
	const TEXT_GUI_BOOKCASE_MOVEBOOK = '移动到 [N]';
	const TEXT_GUI_BOOKCASE_DBLCLICK = '双击我,给我取一个好听的名字吧~';
	const TEXT_GUI_BOOKCASE_WHATNAME = '呜呜呜~会是什么名字呢?';
	const TEXT_GUI_BOOKCASE_ATRCMMD = '自动推书';
	const TEXT_GUI_BOOKCASE_RCMMDAT = '<span>每日自动推书:</span>';
	const TEXT_GUI_BOOKCASE_RCMMDNW = '立即推书';
	const TEXT_GUI_BOOKCASE_RCMMDNW_DONE = '今日推书已完成';
	const TEXT_GUI_BOOKCASE_RCMMDNW_NOTYET = '今日尚未推书';
	const TEXT_GUI_BOOKCASE_RCMMDNW_CONFIRM = '今天已经推过书了,是否要再推一遍?';
	const TEXT_GUI_SEARCH_OPTION_TAG = '标签(preview)';
	const TEXT_GUI_BLOCK_TITLE_DEFULT = '操作区域';
	const TEXT_GUI_USER_REVIEWSEARCH = '用户书评';
	const TEXT_GUI_USER_USERINFO = '详细资料';

	// Emoji smiles (not used in the script yet)
	const SmList =
		  [{text:"/:O",id:"1",alt:"惊讶"}, {text:"/:~",id:"2",alt:"撇嘴"}, {text:"/:*",id:"3",alt:"色色"},
		   {text:"/:|",id:"4",alt:"发呆"}, {text:"/8-)",id:"5",alt:"得意"}, {text:"/:LL",id:"6",alt:"流泪"},
		   {text:"/:$",id:"7",alt:"害羞"}, {text:"/:X",id:"8",alt:"闭嘴"}, {text:"/:Z",id:"9",alt:"睡觉"},
		   {text:"/:`(",id:"10",alt:"大哭"}, {text:"/:-",id:"11",alt:"尴尬"}, {text:"/:@",id:"12",alt:"发怒"},
		   {text:"/:P",id:"13",alt:"调皮"}, {text:"/:D",id:"14",alt:"呲牙"}, {text:"/:)",id:"15",alt:"微笑"},
		   {text:"/:(",id:"16",alt:"难过"}, {text:"/:+",id:"17",alt:"耍酷"}, {text:"/:#",id:"18",alt:"禁言"},
		   {text:"/:Q",id:"19",alt:"抓狂"}, {text:"/:T",id:"20",alt:"呕吐"}]

    /* \t
    ┌┬┐┌─┐┏┳┓┏━┓╭─╮
    ├┼┤│┼│┣╋┫┃╋┃│╳│
    └┴┘└─┘┗┻┛┗━┛╰─╯
    ╲╱╭╮
    ╱╲╰╯
    */
    /* **output format: Review Name.txt**
    ** 轻小说文库-帖子 [ID: reviewid]
    ** title
    ** 保存自: reviewlink
    ** 保存时间: savetime
    ** By scriptname Ver. version, author authorname
    **
    ** ──────────────────────────────
    ** [用户: username userid]
    ** 用户名: username
    ** 用户ID: userid
    ** 加入日期: 1970-01-01
    ** 用户链接: userlink
    ** 最早出现: 1楼
    ** ──────────────────────────────
    ** ...
    ** ──────────────────────────────
    ** [#1 2021-04-26 17:53:49] [username userid]
    ** ──────────────────────────────
    ** content - line 1
    ** content - line 2
    ** content - line 3
    ** ──────────────────────────────
    **
    ** ──────────────────────────────
    ** [#2 2021-04-26 19:28:08] [username userid]
    ** ──────────────────────────────
    ** content - line 1
    ** content - line 2
    ** content - line 3
    ** ──────────────────────────────
    **
    ** ...
    **
    **
    ** [THE END]
    */
    const TEXT_SPLIT_LINE_CHAR = '━'; const TEXT_SPLIT_LINE = TEXT_SPLIT_LINE_CHAR.repeat(20)
    const TEXT_OUTPUT_REVIEW_HEAD =
          '轻小说文库-帖子 [ID: {RWID}]\n{RWTT}\n保存自: {RWLK}\n保存时间: {SVTM}\nBy {SCNM} Ver. {VRSN}, author {ATNM}'
    const TEXT_OUTPUT_REVIEW_USER =
          '{LNSPLT}\n[用户: {USERNM} {USERID}]\n用户名: {USERNM}\n用户ID: {USERID}\n加入日期: {USERJT}\n用户链接: {USERLK}\n最早出现: {USERFL}楼\n{LNSPLT}'
    const TEXT_OUTPUT_REVIEW_FLOOR =
          '{LNSPLT}\n[#{RPNUMB} {RPTIME}] [{USERNM} {USERID}]\n{LNSPLT}\n{RPTEXT}\n{LNSPLT}';
    const TEXT_OUTPUT_REVIEW_END = '\n[THE END]';

    /** DoLog相关函数改自 Ocrosoft 的 Pixiv Previewer
     *  [GitHub]     Ocrosoft: https://github.com/Ocrosoft/
     *  [GreasyFork] Ocrosoft: https://greasyfork.org/zh-CN/users/63073
     *  [GreasyFork] Pixiv Previewer: https://greasyfork.org/zh-CN/scripts/30766
     *  [GitHub]     Pixiv Previewer: https://github.com/Ocrosoft/PixivPreviewer
     **/
    let LogLevel = {
        None: 0,
        Error: 1,
        Success: 2,
        Warning: 3,
        Info: 4,
        Elements: 5,
    };
    let g_logCount = 0;
    let g_logLevel = LogLevel.Warning; // Info Warning Success Error

    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;
            }
        }
    }

    // Common actions
	const tipready = tipcheck();
    addStyle(CSS_COMMON);
	GMXHRHook(NUMBER_MAX_XHR);
	const TASK = new taskManager();

	// Tags search beta
	formSearch();

    // Get tab url api part
    const API = window.location.href.replace(/https?:\/\/www\.wenku8\.net\//, '').replace(/\?.*/, '')
                .replace(/^book\/\d+\.html?/, 'book').replace(/novel\/(\d+\/?)+\.html?$/, 'novel')
	            .replace(/^novel[\/\d]+index\.html?$/,'novelindex');
	if (isAPIPage()) {pageAPI(API); return;};
	if (!API) {location.href = 'https://www.wenku8.net/index.php';return;};
    switch (API) {
        // Dwonload page
        case 'modules/article/packshow.php':
            pageDownload();
            break;
		// ReviewList page
        case 'modules/article/reviews.php':
			areaReply();
			break;
		// Review page
        case 'modules/article/reviewshow.php':
			areaReply();
            pageReview();
            break;
		// Bookcase page
		case 'modules/article/bookcase.php':
			pageBookcase();
			break;
		// Tags page
		case 'modules/article/tags.php':
			pageTags();
			break;
		case 'userpage.php':
			pageUser();
			break;
        // Index page
        case 'index.php':
            pageIndex();
            break;
        // Book page
		// Also: https://www.wenku8.net/modules/article/articleinfo.php?id={ID}&charset=gbk
        case 'book':
            pageBook();
            break;
		// Novel index page
		case 'novelindex':
			pageNovelIndex();
			break;
        // Novel page
        case 'novel':
            pageNovel();
            break;
        // Other pages
        default:
            DoLog(LogLevel.Info, API);
    }

	// Autorun tasks
	// use 'new' keyword
	function taskManager() {
		const TM = this;

		// UserDetail refresh
		TM.UserDetail = {
			// Refresh userDetail storage everyday
			refresh: function() {
				// Time check: whether recommend has done today
				if (getMyUserDetail().lasttime === getTime('-', false)) {return false;};
				refreshMyUserDetail();
			}
		}

		// Auto-recommend
		TM.AutoRecommend = {
			// config init
			initConfig: function(save) {
				const arConfig = {};
				arConfig[KEY_ATRCMMDS_VERSION] = VALUE_ATRCMMDS_VERSION;
				arConfig.allCount = 0;
				arConfig.books = {};
				arConfig.auto = true;

				save ? GM_getValue(KEY_ATRCMMDS, arConfig) : function() {};
				return arConfig;
			},

			// Auto-recommend config upgrade
			upgradeConfig: function() {
				// Get config
				const arConfig = GM_getValue(KEY_ATRCMMDS, TM.AutoRecommend.initConfig(true));

				switch (arConfig[KEY_ATRCMMDS_VERSION]) {
					case '0.1':
						arConfig.auto = true;
						DoLog(LogLevel.Success, 'Auto-recommend config successfully updated From v0.1 to {V}. '.replaceAll('{V}', VALUE_ATRCMMDS_VERSION));
						break;
					case VALUE_ATRCMMDS_VERSION:
						DoLog(LogLevel.Info, 'Auto-recommend config is in latest version. ');
						break
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version for Auto-recommend. ');
				}

				// Version info rewrite
				arConfig[KEY_ATRCMMDS_VERSION] = VALUE_ATRCMMDS_VERSION;

				// Save to gm_storage
				GM_setValue(KEY_ATRCMMDS, arConfig);
			},

			// Check if recommend has done
			checkRcmmd: function() {
				const arConfig = GM_getValue(KEY_ATRCMMDS, TM.AutoRecommend.initConfig(true));
				return arConfig.lasttime === getTime('-', false);
			},

			// Auto recommend main function
			run: function(recommendAnyway=false) {
				let i;

				// Get config
				const arConfig = GM_getValue(KEY_ATRCMMDS, TM.AutoRecommend.initConfig(true));

				// Time check: whether all recommends has done today
				if (TM.AutoRecommend.checkRcmmd() && !recommendAnyway) {return false;};

				// Config check: whether we need to auto-recommend
				if (!arConfig.auto && !recommendAnyway) {return false;}

				// Recommend for each
				let recommended = '', count = 0, allCount = 0;
				new ElegantAlertBox(TEXT_ALT_ATRCMMDS_ALL_START);
				for (const strBookID in arConfig.books) {
					// Only when inherited properties exists must we use hasOwnProperty()
					// here we know there is no inherited properties
					const book = arConfig.books[strBookID]
					const number = book.number;
					const bookID = book.id;
					const bookName = book.name;

					// Time check: whether this book's recommend has done today
					if (book.lasttime === getTime('-', false) && !recommendAnyway) {continue;};

					// Soft alert
					//new ElegantAlertBox(TEXT_ALT_ATRCMMDS_RUNNING.replaceAll('{BN}', bookName).replaceAll('{BID}', strBookID));

					// Go work
					for (i = 0; i < number; i++) {
						allCount++;
						getDocument(URL_RECOMMEND.replaceAll('{B}', strBookID), function(oDoc) {
							// title: "处理成功"
							const statusText = oDoc.querySelector('.blocktitle').innerText;
							// content: "我们已经记录了本次推荐,感谢您的参与!\n\n您每天拥有 5 次推荐权利,这是您今天第 1 次推荐。"
							const returnText = oDoc.querySelector('.blockcontent').innerText.replace(/\s*\[.+\]\s*$/, '');

							// Save
							count++;
							book.lasttime = getTime('-', false);
							count === allCount ? arConfig.lasttime = getTime('-', false) : function() {};
							GM_setValue(KEY_ATRCMMDS, arConfig);

							// GUI
							recommended += '</br>{BID} - {BN}'.replaceAll('{BN}', bookName).replaceAll('{BID}', strBookID);
							count === allCount ? new ElegantAlertBox(TEXT_ALT_ATRCMMDS_ALL_DONE.replaceAll('{R}', recommended)) : function() {};

							// Log
							DoLog(LogLevel.Info, statusText);
							DoLog(LogLevel.Info, returnText);
						})
					}

					// Soft alert
					//new ElegantAlertBox(TEXT_ALT_ATRCMMDS_DONE.replaceAll('{BN}', bookName).replaceAll('{BID}', strBookID));
				}

				return true;
			}
		}

		TM.UserDetail.refresh();
		TM.AutoRecommend.upgradeConfig();
		TM.AutoRecommend.run();
	}

    // Book page add-on
    function pageBook() {
		// Resource
		const pageResource = {
			elements: {},
			info: {}
		}
		collectPageResources();
		DoLog(LogLevel.Info, pageResource, true)

		// Provide meta info copy
		metaCopy();

		// Provide txtfull download for copyright book
		enableDownload();

		// Provide tag search
		tagOption();

        // Ctrl+Enter comment submit
        areaReply();

		// Get page resources
		function collectPageResources() {
			collectElements();
			collectInfos();

			function collectElements() {
				const elements = pageResource.elements;
				elements.content = document.querySelector('#content');
				elements.bookMain = elements.content.querySelector('div');
				elements.header = elements.content.querySelector('div>table');
				elements.bookName = elements.header.querySelector('b');
				elements.metaContainer = elements.header.querySelector('tr+tr');
				elements.metas = elements.metaContainer.querySelectorAll('td');
				elements.info = elements.bookMain.querySelector('div+table');
				elements.infoText = elements.info.querySelector('td+td');
				elements.notice = elements.infoText.querySelectorAll('span.hottext>b');
				elements.tags = elements.notice.length > 1 ? elements.notice[0] : null;
				elements.notice = elements.notice[elements.notice.length-1];
				elements.introduce = elements.infoText.querySelectorAll('span');
				elements.introduce = elements.introduce[elements.introduce.length-1];
			}

			function collectInfos() {
				const info = pageResource.info;
				const elements = pageResource.elements;
				info.bookName = elements.bookName.innerText;
				info.BID = Number(location.href.match(/book\/(\d+).htm/)[1]);
				info.metas = []; elements.metas.forEach(function(meta){this.push(getKeyValue(meta.innerText));}, info.metas);
				info.notice = elements.notice.innerText;
				info.tags = elements.tags ? getKeyValue(elements.tags.innerText).VALUE.split(' ') : null;
				info.introduce = elements.introduce.innerText;
				info.dlEnabled = elements.content.querySelector('legend>b');
				info.dlEnabled = info.dlEnabled ? info.dlEnabled.innerText : false;
				info.dlEnabled = info.dlEnabled ? (info.dlEnabled.indexOf('TXT') !== -1 && info.dlEnabled.indexOf('UMD') !== -1 && info.dlEnabled.indexOf('JAR') !== -1) : false;
			}
		}

		// Copy meta info
		function metaCopy() {
			let tip = TEXT_TIP_COPY;
			for (let i = -1; i < pageResource.elements.metas.length; i++) {
				const meta = i !== -1 ? pageResource.elements.metas[i] : pageResource.elements.bookName;
				const info = i !== -1 ? pageResource.info.metas[i] : pageResource.info.bookName;
				const value = i !== -1 ? info.VALUE : info;
				meta.innerHTML += HTML_BOOK_COPY;
				const copyBtn = meta.querySelector('.'+CLASSNAME_BUTTON);
				copyBtn.addEventListener('click', function() {
					copyText(value);
					showTip(TEXT_TIP_COPIED);
					const alertBox = new ElegantAlertBox(TEXT_ALT_META_COPIED.replaceAll('{M}', value));
					alertBox.elm.onclick = function() {
						// Prevent copying box content
						alertBox.close.call(alertBox);
					}
				});

				if (tipready) {
					copyBtn.addEventListener('mouseover', function() {showTip(TEXT_TIP_COPY);});
					copyBtn.addEventListener('mouseout' , tiphide);
				} else {
					copyBtn.title = TEXT_TIP_COPY;
				}
			}

			function showTip(text) {
				tip = text;
				tipshow(tip);
			}
		}

		// Download copyright book
		function enableDownload() {
			if (pageResource.info.dlEnabled) {return false;};

			let div = document.createElement('div');
			pageResource.elements.bookMain.appendChild(div);
			div.outerHTML = HTML_DOWNLOAD_LINKS
				.replaceAll('{ORIBOOKNAME}', pageResource.info.bookName)
				.replaceAll('{BOOKID}', String(pageResource.info.BID))
				.replaceAll('{BOOKNAME}', encodeURIComponent(pageResource.info.bookName));
			div = document.querySelector('#txtfull');
			pageResource.elements.txtfull = div;

			pageResource.elements.notice.innerHTML = HTML_DOWNLOAD_BOARD
				.replaceAll('{ORIBOOKNAME}', pageResource.info.bookName);
		}

		// Tag Search
		function tagOption() {
			const tagsEle = pageResource.elements.tags;
			const tags = pageResource.info.tags;
			if (!tags) {return false;}

			let html = getKeyValue(tagsEle.innerText).KEY + ':';
			for (const tag of tags) {
				html += HTML_BOOK_TAG.replace('{TU}', $URL.encode(tag)).replace('{TN}', tag) + ' ';
			}
			tagsEle.innerHTML = html;
		}
    }

	// Reply area add-on
	function areaReply() {
		/* ## Release title area ## */
        if (document.querySelector('td > input[name="Submit"]') && !document.querySelector('#ptitle')) {
            const table = document.querySelector('form>table');
            const titleText = table.innerHTML.match(/<!--[\s\S]+id="ptitle"[\s\S]+-->/)[0];
            const titleHTML = titleText.replace(/^<!--\s*/, '').replace(/\s*-->$/, '');
			const titleEle = document.createElement('tr');
			const caption = table.querySelector('caption');
			table.insertBefore(titleEle, caption);
			titleEle.outerHTML = titleHTML;
        }

        const commentArea = document.querySelector('#pcontent'); if (!commentArea) {return false;};
        const commentForm = document.querySelector('form[action^="https://www.wenku8.net/modules/article/review"]');
        const commentSbmt = document.querySelector('td > input[name="Submit"]');
        const commenttitl = document.querySelector('#ptitle');
		const commentbttm = commentSbmt.parentElement;

        /* ## Ctrl+Enter comment submit ## */
        if (commentSbmt) {
            commentSbmt.value = '发表书评(Ctrl+Enter)';
            commentSbmt.style.padding = '0.3em 0.4em 0.3em 0.4em';
            commentSbmt.style.height= 'auto';
            commentArea.addEventListener('keydown', hotkeyReply);
            commenttitl.addEventListener('keydown', hotkeyReply);
        }

		/* ## Enable https protocol for inserted url ## */
		fixHTTPS();

		/* ## Comment auto-save ## */
		// GUI
		const asTip = document.createElement('span');
		commentbttm.appendChild(asTip);

		// Review-Page: Same rid, same savekey - 'rid123456'
		// Book-Page & Book-Review-List-Page: Same bookid, same savekey - 'bid1234'
		let commentData = {
			rid : getUrlArgv('rid', Number),
			aid : getUrlArgv('aid', Number),
			bid : location.href.match(/\/book\/(\d+).htm/) ? Number(location.href.match(/\/book\/(\d+).htm/)[1]) : 0,
			page : getUrlArgv('page', Number, 1)
		}
		commentData.key = commentData.rid ? 'rid' + String(commentData.rid) : 'bid' + String(commentData.bid);
		restoreDraft();

		const events = ['focus', 'blur', 'mousedown', 'keydown', 'keyup'];
		const eventEles = [commentArea, commenttitl];
		for (const eventEle of eventEles) {
			for (const event of events) {
				eventEle.addEventListener(event, saveDraft);
			}
		}

		function saveDraft() {
			const content = commentArea.value;
			const title = commenttitl.value;

			if (!content && !title) {
				clearDraft();
				return;
			} else if (commentData.content === content && commentData.title === title) {
				return;
			}

			commentData.content = content;
			commentData.title = title;

			const allCData = GM_getValue(KEY_COMMENT_DRAFTS, {});

			allCData[commentData.key] = commentData;
			allCData[KEY_DRAFT_VERSION] = VALUE_DRAFT_VERSION;
			GM_setValue(KEY_COMMENT_DRAFTS, allCData);
			asTip.innerHTML = TEXT_GUI_AUTOSAVE;
		}

		function restoreDraft() {
			const allCData = GM_getValue(KEY_COMMENT_DRAFTS, {});
			if (!allCData[commentData.key]) {return false;};
			commentData = allCData[commentData.key];
			commenttitl.value = commentData.title;
			commentArea.value = commentData.content;
			asTip.innerHTML = TEXT_GUI_AUTOSAVE_RESTORE;
			return true;
		}

		function clearDraft() {
			const allCData = GM_getValue(KEY_COMMENT_DRAFTS, {});
			if (!allCData[commentData.key]) {return false;};
			allCData[commentData.key] = undefined;
			GM_setValue(KEY_COMMENT_DRAFTS, allCData);
			asTip.innerHTML = TEXT_GUI_AUTOSAVE_CLEAR;
			return true;
		}

        function hotkeyReply() {
            let keycode = event.keyCode;
            if (keycode === 13 && event.ctrlKey && !event.altKey) {
				// Do not submit directly like this; we need to submit with onsubmit executed
                //commentForm.submit();
				commentSbmt.click();
            }
        }

		function fixHTTPS() {
			if (typeof(UBBEditor) === 'undefined') {
				DoLog(LogLevel.Info, 'fixHTTPS: UBBEditor not loaded, waiting...');
				setTimeout(fixHTTPS, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
				return false;
			}
			const eid = 'pcontent';

			const menuItemInsertUrl = commentForm.querySelector('#menuItemInsertUrl');
			const menuItemInsertImage = commentForm.querySelector('#menuItemInsertImage');

			// Wait until menuItemInsertUrl and menuItemInsertImage is loaded
			if (!menuItemInsertUrl || !menuItemInsertImage) {
				DoLog(LogLevel.Info, 'fixHTTPS: element not loaded, waiting...');
				setTimeout(fixHTTPS, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
				return false;
			}

			// Wait until original onclick function is set
			if (!menuItemInsertUrl.onclick || !menuItemInsertImage.onclick) {
				DoLog(LogLevel.Info, 'fixHTTPS: defult onclick not loaded, waiting...');
				setTimeout(fixHTTPS, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
				return false;
			}

			menuItemInsertUrl.onclick = function () {
				var url = prompt("请输入超链接地址", "http://");
				if (url != null && url.indexOf("http://") < 0 && url.indexOf("https://") < 0) {
					alert("请输入完整的超链接地址!");
					return;
				}
				if (url != null) {
					if ((document.selection && document.selection.type == "Text") ||
						(window.getSelection &&
						 document.getElementById(eid).selectionStart > -1 && document.getElementById(eid).selectionEnd >
						 document.getElementById(eid).selectionStart)) {UBBEditor.InsertTag(eid, "url", url,'');}
					else {UBBEditor.InsertTag(eid, "url", url, url);}
				}
			};

			menuItemInsertImage.onclick = function () {
				var imgurl = prompt("请输入图片路径", "http://");
				if (imgurl != null && imgurl.indexOf("http://") < 0 && imgurl.indexOf("https://") < 0) {
					alert("请输入完整的图片路径!");
					return;
				}
				if (imgurl != null) {
					UBBEditor.InsertTag(eid, "img", "", imgurl);
				}
			};

			return true;
		}

		function submitHook() {
			const onsubmit = commentForm.onsubmit;
			commentForm.onsubmit = onsubmitForm;

			function onsubmitForm(e) {
				clearDraft();
				return onsubmit ? onsubmit() : function() {return true;};
			}
		}
	}

    // Review page add-on
    function pageReview() {
        // ## Save whole post ##
        // GUI
        const pageCountText = document.querySelector('#pagelink>.last').href.match(/page=(\d+)/)[1];

        const main = document.querySelector('#content');
        const headBars = main.querySelectorAll('tr>td[align]');
        headBars[0].width = '80%';
        headBars[1].width = '20%';

        const saveBtn = document.createElement('span');
        saveBtn.innerText = TEXT_GUI_DOWNLOAD_REVIEW.replaceAll('A', pageCountText);
        saveBtn.classList.add(CLASSNAME_BUTTON);
        saveBtn.addEventListener('click', downloadWholePost);
        headBars[1].appendChild(saveBtn);

		addQuoteBtns();
		addQueryBtns();

		function addQuoteBtns() {
			// Get content textarea
			const pcontent = document.querySelector('#pcontent');
			const form = document.querySelector('form[action^="https://www.wenku8.net/modules/article/review"]');

			// Get floor elements
			const avatars = main.querySelectorAll('table div img.avatar');
			for (const avatar of avatars) {
				// do not insert the button as the first childnode. page saving function uses the first childnode as the time element.
				const table = avatar.parentElement.parentElement.parentElement.parentElement.parentElement;
				const numberEle = table.querySelector('td.even div a');
				const attr = numberEle.parentElement;
				const btn = createQuoteBtn(attr);
				const spliter = document.createTextNode(' | ');
				attr.insertBefore(spliter, numberEle);
				attr.insertBefore(btn, spliter);
			}

			function createQuoteBtn() {
				const btn = document.createElement('span');
				btn.classList.add(CLASSNAME_BUTTON);
				btn.addEventListener('click', quoteThisFloor);
				btn.innerHTML = '引用';
				return btn;

				function quoteThisFloor() {
					// In DOM Events, <this> keyword points to the Event Element.
					const numberEle = this.parentElement.querySelector('a[name]');
					const numberText = numberEle.innerText;
					const url = numberEle.href;
					const contentEle = this.parentElement.parentElement.querySelector('hr+div');
					const content = getFloorContent(contentEle);
					const insertPosition = pcontent.selectionEnd;
					const text = pcontent.value;
					const leftText = text.substr(0, insertPosition);
					const rightText = text.substr(insertPosition);

					/* ## Create insert value ## */
					let insertValue = '[url=U]N[/url] [quote]Q[/quote]';
					insertValue = insertValue.replace('U', url).replace('N', numberText).replace('Q', content);
					// if not at the beginning of a line then insert a whitespace before the link
					insertValue = ((leftText.length === 0 || /[\r\n]$/.test(leftText)) ? '' : ' ') + insertValue;
					// if not at the end of a line then insert a whitespace after the link
					insertValue += (rightText.length === 0 || /^[\r\n]/.test(leftText)) ? '' : ' ';

					pcontent.value = leftText + insertValue + rightText;
					const position = insertPosition + (pcontent.value.length - text.length);
					form.scrollIntoView(); pcontent.focus(); pcontent.setSelectionRange(position, position);
				}

				function getFloorContent(contentEle) {
					const subNodes = contentEle.childNodes;
					let content = '';

					for (const node of subNodes) {
						const type = node.nodeName;
						switch (type) {
							case '#text':
								// Prevent 'Quote:' repeat
								content += node.data.replace(/^\s*Quote:\s*$/, '');
								break;
							case 'IMG':
								content += '[img]S[/img]'.replace('S', node.src);
								break;
							case 'A':
								content += '[url=U]T[/url]'.replace('U', node.href).replace('T', getFloorContent(node));
								break;
							case 'BR':
								// no need to add \n, because \n will be preserved in #text nodes
								//content += '\n';
								break;
							case 'DIV':
								if (node.classList.contains('jieqiQuote')) {
									content += getTagedSubcontent('quote', node);
								} else if (node.classList.contains('jieqiCode')) {
									content += getTagedSubcontent('code', node);
								} else if (node.classList.contains('divimage')) {
									content += getFloorContent(node);
								} else {
									content += getFloorContent(node);
								}
								break;
							case 'CODE': content += getFloorContent(node); break; // Just ignore
							case 'PRE':  content += getFloorContent(node); break; // Just ignore
							case 'SPAN': content += getFontedSubcontent(node); break; // Size and color
							case 'P':    content += getFloorContent(node); break; // Text Align
							case 'B':    content += getTagedSubcontent('b', node); break;
							case 'I':    content += getTagedSubcontent('i', node); break;
							case 'U':    content += getTagedSubcontent('u', node); break;
							case 'DEL':  content += getTagedSubcontent('d', node); break;
							default:     content += getFloorContent(node); break;
							/*
							case 'SPAN':
								subContent = getFloorContent(node);
								size = node.style.fontSize.match(/\d+/) ? node.style.fontSize.match(/\d+/)[0] : '';
								color = node.style.color.match(/rgb\((\d+), ?(\d+), ?(\d+)\)/);
								break;
							*/
						}
					}
					return content;

					function getTagedSubcontent(tag, node) {
						const subContent = getFloorContent(node);
						return '[{T}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{S}', subContent);
					}

					function getFontedSubcontent(node) {
						let strSize = node.style.fontSize.match(/\d+/);
						let strColor = node.style.color;
						strSize = strSize ? strSize[0] : null;
						strColor = strColor ? rgbToHex.apply(null, strColor.match(/\d+/g)) : null;
						const tag = strSize ? 'size' : (strColor ? 'color' : null);
						const value = strSize ? strSize : (strColor ? strColor : null);
						const subContent = getFloorContent(node);
						if (tag && value) {
							return '[{T}={V}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{V}', value).replaceAll('{S}', subContent);
						} else {
							return subContent;
						}
					}
				}
			}
		}

		function addQueryBtns() {
			// Get floor elements
			const avatars = main.querySelectorAll('table div img.avatar');
			for (const avatar of avatars) {
				// Get container div
				const div = avatar.parentElement;

				// Create buttons
				const qBtn = document.createElement('a'); // Button for query reviews
				const iBtn = document.createElement('a'); // Button for query userinfo

				// Get UID
				const user = div.querySelector('a');
				const UID = Number(user.href.match(/uid=(\d+)/)[1]);

				// Create text spliter
				const spliter = document.createTextNode(' | ');

				// Config buttons
				qBtn.href = URL_REVIEWSEARCH.replaceAll('{K}', String(UID));
				iBtn.href = URL_USERINFO    .replaceAll('{K}', String(UID));
				qBtn.target = '_blank';
				iBtn.target = '_blank';
				qBtn.innerText = TEXT_GUI_USER_REVIEWSEARCH;
				iBtn.innerText = TEXT_GUI_USER_USERINFO;

				// Append to GUI
				div.appendChild(document.createElement('br'));
				div.appendChild(iBtn);
				div.appendChild(qBtn);
				div.insertBefore(spliter, qBtn);
			}
		}

        /*
        // Testing
        getAllPages(function(data) {
            const txt = joinTXT(data);
            DoLog(LogLevel.Success, txt);
        });
        */

        // ## Function: Get data from page document or join it into the given data variable ##
        function getDataFromPage(document, data) {
            let i;
			DoLog(LogLevel.Info, document, true);

            // Get Floors; avatars uses for element locating
            const main = document.querySelector('#content');
            const avatars = main.querySelectorAll('table div img.avatar');

            // init data, floors and users if need
            let floors = {}, users = {};
            if (data) {
                floors = data.floors;
                users = data.users;
            } else {
                data = {};
                initData(data, floors, users);
            }
            for (i = 0; i < avatars.length; i++) {
                const floor = newFloor(floors, avatars, i);
                const elements = getFloorElements(floor);
                const reply = getFloorReply(floor);
                const user = getFloorUser(floor);
                appendFloor(floors, floor);
            }
            return data;

            function initData(data, floors, users) {
                // data vars
                data.floors = floors; floors.data = data;
                data.users = users; users.data = data;

                // review info
                data.link = location.href;
                data.id = getUrlArgv('rid', Number, 0);
                data.page = getUrlArgv('page', Number, 1);
                data.title = main.querySelector('th strong').innerText;
                return data;
            }

            function newFloor(floors, avatars, i) {
                const floor = {};
                floor.avatar = avatars[i];
                floor.floors = floors;
                return floor;
            }

            function getFloorElements(floor) {
                const elements = {}; floor.elements = elements;
                elements.avatar = floor.avatar;
                elements.table = elements.avatar.parentElement.parentElement.parentElement.parentElement.parentElement;
                elements.tr = elements.table.querySelector('tr');
                elements.tdUser = elements.table.querySelector('td.odd');
                elements.tdReply = elements.table.querySelector('td.even');
                elements.divUser = elements.tdUser.querySelector('div');
                elements.aUser = elements.divUser.querySelector('a');
                elements.attr = elements.tdReply.querySelector('div a').parentElement;
                elements.time = elements.attr.childNodes[0];
                elements.number = elements.attr.querySelector('a[name]');
                elements.title = elements.tdReply.querySelector('div>strong');
                elements.content = elements.tdReply.querySelector('hr+div');
                return elements;
            }

            function getFloorReply(floor) {
                const elements = floor.elements;
                const reply = {}; floor.reply = reply;
                reply.time = elements.time.nodeValue.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)[0];
                reply.number = Number(elements.number.innerText.match(/\d+/)[0]);
                reply.value = elements.content.innerText;
                reply.title = elements.title.innerText;
                return reply;
            }

            function getFloorUser(floor) {
                const elements = floor.elements;
                const user = {}; floor.user = user;
                user.id = elements.aUser.href.match(/uid=(\d+)/)[1];
                user.name = elements.aUser.innerText;
                user.avatar = elements.avatar.src;
                user.link = elements.aUser.href;
                user.jointime = elements.divUser.innerText.match(/\d{4}-\d{2}-\d{2}/)[0];

                const data = floor.floors.data; const users = data.users;
                if (!users.hasOwnProperty(user.id)) {
                    users[user.id] = user;
                    user.floors = [floor];
                } else {
                    const uFloors = users[user.id].floors;
                    uFloors.push(floor);
                    sortUserFloors(uFloors);
                }
                return user;
            }

            function sortUserFloors(uFloors) {
                uFloors.sort(function(F1, F2) {
                    return F1.reply.number > F2.reply.number;
                })
            }

            function appendFloor(floors, floor) {
                floors[floor.reply.number-1] = floor;
            }
        }

        // ## Function: Get pages and parse each pages to a data, returns data ##
        // callback(data, gotcount, finished) is called when xhr and parsing completed
        function getAllPages(callback) {
            let i, data, gotcount = 0;
            const ridMatcher = /rid=(\d+)/, pageMatcher = /page=(\d+)/;
            const lastpageUrl = document.querySelector('#pagelink>.last').href;
            const rid = Number(lastpageUrl.match(ridMatcher)[1]);
            const pageCount = Number(lastpageUrl.match(pageMatcher)[1]);
            const curPageNum = location.href.match(pageMatcher) ? Number(location.href.match(pageMatcher)[1]) : 1;

            for (i = 1; i <= pageCount; i++) {
                const url = lastpageUrl.replace(pageMatcher, 'page='+String(i));
                getDocument(url, joinPageData, callback);
            }

            function joinPageData(pageDocument, callback) {
                data = getDataFromPage(pageDocument, data);
                gotcount++;

                // log
				const level = gotcount % NUMBER_LOGSUCCESS_AFTER ? LogLevel.Info : LogLevel.Success;
                DoLog(level, 'got ' + String(gotcount) + ' pages.');
                if (gotcount === pageCount) {
                    DoLog(LogLevel.Success, 'All pages xhr and parsing completed.');
                    DoLog(LogLevel.Success, data, true);
                }

                // callback
                if (callback) {callback(data, gotcount, gotcount === pageCount);};
            }
        }

        // Function output
        function joinTXT(data, noSpliter=true) {
            const floors = data.floors; const users = data.users;

            // HEAD META DATA
            const saveTime = getTime();
            const head = TEXT_OUTPUT_REVIEW_HEAD
                .replaceAll('{RWID}', data.id).replaceAll('{RWTT}', data.title).replaceAll('{RWLK}', data.link)
                .replaceAll('{SVTM}', saveTime).replaceAll('{SCNM}', GM_info.script.name)
                .replaceAll('{VRSN}', GM_info.script.version).replaceAll('{ATNM}', GM_info.script.author);

            // join userinfos
            let userText = '';
            for (const [pname, user] of Object.entries(users)) {
                if (!isNumeric(pname)) {continue;};
                userText += TEXT_OUTPUT_REVIEW_USER
                    .replaceAll('{LNSPLT}', noSpliter ? '' : TEXT_SPLIT_LINE).replaceAll('{USERNM}', user.name)
                    .replaceAll('{USERID}', user.id).replaceAll('{USERJT}', user.jointime)
                    .replaceAll('{USERLK}', user.link).replaceAll('{USERFL}', user.floors[0].reply.number);
                userText += '\n'.repeat(2);
            }

            // join floors
            let floorText = '';
            for (const [pname, floor] of Object.entries(floors)) {
                if (!isNumeric(pname)) {continue;};
                const avatar = floor.avatar; const elements = floor.elements; const user = floor.user; const reply = floor.reply;
                floorText += TEXT_OUTPUT_REVIEW_FLOOR
                    .replaceAll('{LNSPLT}', noSpliter ? '' : TEXT_SPLIT_LINE).replaceAll('{RPNUMB}', String(reply.number))
                    .replaceAll('{RPTIME}', reply.time).replaceAll('{USERNM}', user.name)
                    .replaceAll('{USERID}', user.id).replaceAll('{RPTEXT}', reply.value);
                floorText += '\n'.repeat(2);
            }

            // End
            const foot = TEXT_OUTPUT_REVIEW_END;

            // return
            const txt = head + '\n'.repeat(2) + userText + '\n'.repeat(2) + floorText + '\n'.repeat(2) + foot;
            return txt;
        }

        // ## Function: Download the whole post ##
        function downloadWholePost() {
            // Continues only if not working
            if (downloadWholePost.working) {return;};
            downloadWholePost.working = true;

            // GUI
            saveBtn.innerText = TEXT_GUI_DOWNLOADING_REVIEW
                .replaceAll('C', '0').replaceAll('A', pageCountText);

            // go work!
            getAllPages(function(data, gotCount, finished) {
                // GUI
                saveBtn.innerText = TEXT_GUI_DOWNLOADING_REVIEW
                    .replaceAll('C', String(gotCount)).replaceAll('A', pageCountText);

                // Stop here if not completed
                if (!finished) {return;};

                // Join text
                const TXT = joinTXT(data);

                // Download
                const blob = new Blob([TXT],{type:"text/plain;charset=utf-8"});
                const url = URL.createObjectURL(blob);
                const name = '文库贴 - ' + String(data.id) + '.txt';

                const a = document.createElement('a');
                a.href = url;
                a.download = name;
                a.click();

                // GUI
                saveBtn.innerText = TEXT_GUI_DOWNLOADFINISH_REVIEW;
				new ElegantAlertBox(TEXT_ALT_DOWNLOADFINISH_REVIEW.replaceAll('{T}', data.title).replaceAll('{I}', data.id).replaceAll('{N}', name));

                // Work finish
                downloadWholePost.working = false;
            })
        }
    }

	// Bookcase page add-on
	function pageBookcase() {
		// Get auto-recommend config
		let arConfig = GM_getValue(KEY_ATRCMMDS, TASK.AutoRecommend.initConfig(true));
		// Get bookcase lists
		const bookCaseURL = 'https://www.wenku8.net/modules/article/bookcase.php?classid={CID}';
		const content = document.querySelector('#content');
		const selector = document.querySelector('[name="classlist"]');
		const options = selector.children;
		// Current bookcase
		const curForm = content.querySelector('#checkform');
		const curClassid = Number(document.querySelector('[name="clsssid"]').value);
		const bookcases = readPreferences();
		addTopTitle();
		decorateForm(curForm, bookcases[curClassid]);

		// gowork
		showBookcases();
		recommendAllGUI();

		function recommendAllGUI() {
			const block = createLeftBlock(TEXT_GUI_BOOKCASE_ATRCMMD, true, {
				type: 'mypage',
				links: [
					{innerHTML: TASK.AutoRecommend.checkRcmmd() ? TEXT_GUI_BOOKCASE_RCMMDNW_DONE : TEXT_GUI_BOOKCASE_RCMMDNW_NOTYET, id: 'arstatus'},
					{innerHTML: TEXT_GUI_BOOKCASE_RCMMDAT, id: 'autorcmmd'},
					{innerHTML: TEXT_GUI_BOOKCASE_RCMMDNW, id: 'rcmmdnow'}
				]
			})

			// Configure buttons
			const ulitm = block.querySelector('.ulitem');
			const txtst = block.querySelector('#arstatus');
			const btnAR = block.querySelector('#autorcmmd');
			const btnRN = block.querySelector('#rcmmdnow');
			const txtAR = btnAR.querySelector('span');
			const checkbox = document.createElement('input');
			txtst.classList.add(CLASSNAME_TEXT);
			btnAR.classList.add(CLASSNAME_BUTTON);
			btnRN.classList.add(CLASSNAME_BUTTON);
			checkbox.type = 'checkbox';
			checkbox.checked = arConfig.auto;
			checkbox.addEventListener('click', onclick);
			btnAR.addEventListener('click', onclick);
			btnAR.appendChild(checkbox);
			btnRN.addEventListener('click', rcmmdnow);

			function onclick(e) {
				e.preventDefault();
				e.stopPropagation();
				arConfig.auto = !arConfig.auto;
				setTimeout(function() {checkbox.checked = arConfig.auto;}, 0);
				GM_setValue(KEY_ATRCMMDS, arConfig);
				new ElegantAlertBox(arConfig.auto ? TEXT_ALT_ATRCMMDS_AUTO : TEXT_ALT_ATRCMMDS_NOAUTO);
			}

			function rcmmdnow() {
				if (TASK.AutoRecommend.checkRcmmd() && !confirm(TEXT_GUI_BOOKCASE_RCMMDNW_CONFIRM)) {return false;}
				TASK.AutoRecommend.run(true);
			}
		}

		function readPreferences() {
			let bookcases = GM_getValue(KEY_BOOKCASES, null);
			if (!bookcases) {
				bookcases = initPreferences();
			}
			return bookcases;
		}

		function initPreferences() {
			const lists = [];
			for (const option of options) {
				lists.push({
					classid: Number(option.value),
					url: bookCaseURL.replace('{CID}', String(option.value)),
					name: option.innerText
				})
			}
			savePreferences(lists);
			return lists;
		}

		function savePreferences(value) {
			GM_setValue(KEY_BOOKCASES, (value ? value : bookcases));
		}

		function addTopTitle() {
			// Clone title bar
			const checkform = document.querySelector('#checkform') ? document.querySelector('#checkform') : document.querySelector('.'+CLASSNAME_BOOKCASE_FORM);
			const oriTitle = checkform.querySelector('div.gridtop');
			const topTitle = oriTitle.cloneNode(true);
			content.insertBefore(topTitle, checkform);

			// Hide bookcase selector
			const bcSelector = topTitle.querySelector('[name="classlist"]');
			bcSelector.style.display = 'none';

			// Write title text
			const textNode = topTitle.childNodes[0];
			const numMatch = textNode.nodeValue.match(/\d+/g);
			const text = TEXT_GUI_BOOKCASE_TOPTITLE.replace('A', numMatch[0]).replace('B', numMatch[1]);
			textNode.nodeValue = text;
		}

		function showBookcases() {
			// GUI
			const topTitle = content.querySelector('script+div.gridtop');
			const textNode = topTitle.childNodes[0];
			const oriTitleText = textNode.nodeValue;
			const allCount = bookcases.length;
			let finished = 1;
			textNode.nodeValue = TEXT_GUI_BOOKCASE_GETTING.replace('C', String(finished)).replace('A', String(allCount));

			// Get all bookcase pages
			for (const bookcase of bookcases) {
				if (bookcase.classid === curClassid) {continue;};
				getDocument(bookcase.url, appendBookcase, [bookcase]);
			}

			function appendBookcase(mDOM, bookcase) {
				const classid = bookcase.classid;

				// Get bookcase form and modify it
				const form = mDOM.querySelector('#checkform');
				form.parentElement.removeChild(form);

				// Find the right place to insert it in
				const forms = content.querySelectorAll('.'+CLASSNAME_BOOKCASE_FORM);
				for (let i = 0; i < forms.length; i++) {
					const thisForm = forms[i];
					const cid = thisForm.classid ? thisForm.classid : curClassid;
					if (cid > classid) {
						content.insertBefore(form, thisForm);
						break;
					}
				}
				if(!form.parentElement) {content.appendChild(form);};

				// Decorate
				decorateForm(form, bookcase);

				// finished increase
				finished++;
				textNode.nodeValue = finished < allCount ?
					TEXT_GUI_BOOKCASE_GETTING.replace('C', String(finished)).replace('A', String(allCount)) :
					oriTitleText;
			}
		}

		function decorateForm(form, bookcase) {
			const classid = bookcase.classid;
			let name = bookcase.name;

			// Provide auto-recommand button
			arBtn();

			// Modify properties
			form.classList.add(CLASSNAME_BOOKCASE_FORM);
			form.id += String(classid);
			form.classid = classid;
			form.onsubmit = my_check_confirm;

			// Hide bookcase selector
			const bcSelector = form.querySelector('[name="classlist"]');
			bcSelector.style.display = 'none';

			// Change title
			const titleBar = bcSelector.parentElement;
			titleBar.childNodes[0].nodeValue = name;
			titleBar.addEventListener('dblclick', editName);

			// Show tips
			let tip = TEXT_GUI_BOOKCASE_DBLCLICK;
			if (tipready) {
                // tipshow and tiphide is coded inside wenku8 itself, its function is to show a text tip besides the mouse
                titleBar.addEventListener('mouseover', function() {tipshow(tip);});
                titleBar.addEventListener('mouseout' , tiphide);
            } else {
                titleBar.title = tip;
            }

			// Change selector names
			renameSelectors(false);

			// Replaces the original check_confirm() function
			function my_check_confirm() {
				const checkform = this;
				let checknum = 0;
				for (let i = 0; i < checkform.elements.length; i++){
					if (checkform.elements[i].name == 'checkid[]' && checkform.elements[i].checked == true) checknum++;
				}
				if (checknum === 0){
					alert('请先选择要操作的书目!');
					return false;
				}
				const newclassid = checkform.querySelector('#newclassid');
				if(newclassid.value == -1){
					if (confirm('确实要将选中书目移出书架么?')) {return true;} else {return false;};
				} else {
					return true;
				}
			}

			// Selector name refresh
			function renameSelectors(renameAll) {
				if (renameAll) {
					const forms = content.querySelectorAll('.'+CLASSNAME_BOOKCASE_FORM);
					for (const form of forms) {
						renameFormSlctr(form);
					}
				} else {
					renameFormSlctr(form);
				}

				function renameFormSlctr(form) {
					const newclassid = form.querySelector('#newclassid');
					const options = newclassid.children;
					for (let i = 0; i < options.length; i++) {
						const option = options[i];
						const value = Number(option.value);
						const bc = bookcases[value];
						bc ? option.innerText = TEXT_GUI_BOOKCASE_MOVEBOOK.replace('N', bc.name) : function(){};
					}
				}
			}

			// Provide <input> GUI to edit bookcase name
			function editName() {
				const nameInput = document.createElement('input');
				const form = this;
				tip = TEXT_GUI_BOOKCASE_WHATNAME;
				tipready ? tipshow(tip) : function(){};

				titleBar.childNodes[0].nodeValue = '';
				titleBar.appendChild(nameInput);
				nameInput.value = name;
				nameInput.addEventListener('blur', onblur);
				nameInput.addEventListener('keydown', onkeydown)
				nameInput.focus();
				nameInput.setSelectionRange(0, name.length);

				function onblur() {
					tip = TEXT_GUI_BOOKCASE_DBLCLICK;
					tipready ? tipshow(tip) : function(){};
					const value = nameInput.value.trim();
					if (value) {
						name = value;
						bookcase.name = name;
						savePreferences();
					}
					titleBar.childNodes[0].nodeValue = name;
					titleBar.removeChild(nameInput);
					renameSelectors(true);
				}

				function onkeydown(e) {
					if (e.keyCode === 13) {
						e.preventDefault();
						onblur();
					}
				}
			}

			// Provide auto-recommend option
			function arBtn() {
				const table = form.querySelector('table');
				for (const tr of table.querySelectorAll('tr')) {
					tr.querySelector('.odd') ? decorateRow(tr) : function() {};
					tr.querySelector('th') ? decorateHeader(tr) : function() {};
					tr.querySelector('td.foot') ? decorateFooter(tr) : function() {};
				}

				// Insert auto-recommend option for given row
				function decorateRow(tr) {
					const eleBookLink = tr.querySelector('td:nth-child(2)>a');
					const strBookID = eleBookLink.href.match(/aid=(\d+)/)[1];
					const strBookName = eleBookLink.innerText;
					const newTd = document.createElement('td');
					const input = document.createElement('input');
					newTd.classList.add('odd');
					input.type = 'number';
					input.inputmode = 'numeric';
					input.style.width = '85%';
					input.value = arConfig.books[strBookID] ? String(arConfig.books[strBookID].number) : '0';
					input.addEventListener('change', onvaluechange);
					input.strBookID = strBookID; input.strBookName = strBookName;
					newTd.appendChild(input); tr.appendChild(newTd);
				}

				// Insert a new row for auto-recommend options
				function decorateHeader(tr) {
					const allTh = tr.querySelectorAll('th');
					const width = ARR_GUI_BOOKCASE_WIDTH;
					const newTh = document.createElement('th');
					newTh.innerText = TEXT_GUI_BOOKCASE_ATRCMMD;
					newTh.classList.add(CLASSNAME_TEXT);
					tr.appendChild(newTh);
					for (let i = 0; i < allTh.length; i++) {
						const th = allTh[i];
						th.style.width = width[i];
					}
				}

				// Fit the width
				function decorateFooter(tr) {
					const td = tr.querySelector('td.foot');
					td.colSpan = ARR_GUI_BOOKCASE_WIDTH.length;
				}

				// auto-recommend onvaluechange
				function onvaluechange(e) {
					arConfig = GM_getValue(KEY_ATRCMMDS, TASK.AutoRecommend.initConfig(true));
					const input = e.target;
					const value = input.value;
					const strBookID = input.strBookID;
					const strBookName = input.strBookName;
					const bookID = Number(strBookID);
					const userDetail = getMyUserDetail() ? getMyUserDetail().userDetail : refreshMyUserDetail();
					if (isNumeric(value, true) && Number(value) >= 0) {
						// allCount increase
						const oriNum = arConfig.books[strBookID] ? arConfig.books[strBookID].number : 0;
						const number = Number(value);
						arConfig.allCount += number - oriNum;

						// save to config
						number > 0 ? arConfig.books[strBookID] = {number: number, name: strBookName, id: bookID} : delete arConfig.books[strBookID];
						GM_setValue(KEY_ATRCMMDS, arConfig);

						// alert
						new ElegantAlertBox(
							TEXT_ALT_ATRCMMDS_SAVED
							.replaceAll('{B}', strBookName)
							.replaceAll('{N}', value)
							.replaceAll('{R}', userDetail.vote-arConfig.allCount)
						);
						if (userDetail && arConfig.allCount > userDetail.vote) {
							const alertBox = new ElegantAlertBox(
								TEXT_ALT_ATRCMMDS_OVERFLOW
								.replace('{V}', String(userDetail.vote))
								.replace('{C}', String(arConfig.allCount))
							);
							alertBox.elm.onclick = function() {
								alertBox.close.call(alertBox);
								refreshMyUserDetail();
							}
						};
					} else {
						// invalid input value, alert
						new ElegantAlertBox(TEXT_ALT_ATRCMMDS_INVALID.replaceAll('{N}', value));
					}
				}
			}
		}
	}

	// Novel ads remover
	function removeTopAds() {
		const ads = []; document.querySelectorAll('div>script+script+a').forEach(function(a) {ads.push(a.parentElement);});
		for (const ad of ads) {
			ad.parentElement.removeChild(ad);
		}
	}

	// Novel index page add-on
	function pageNovelIndex() {
		removeTopAds();
	}

    // Novel page add-on
    function pageNovel() {
		const pageResource = {elements: {}, infos: {}, download: {}};
		collectPageResources(); DoLog(LogLevel.Info, pageResource, true)

		// Remove ads
		removeTopAds();

		// Provide download GUI
		downloadGUI();

        // Prevent URL.revokeObjectURL in script 轻小说文库下载
        revokeObjectURLHOOK();

		function collectPageResources() {
			collectElements();
			collectInfos();
			initDownload();

			function collectElements() {
				const elements = pageResource.elements;
				elements.title          = document.querySelector('#title');
				elements.images         = document.querySelectorAll('.imagecontent');
				elements.rightButtonDiv = document.querySelector('#linkright');
				elements.rightNodes     = elements.rightButtonDiv.childNodes;
				elements.rightBlank     = elements.rightNodes[elements.rightNodes.length-1];
				elements.content        = document.querySelector('#content');
				elements.spliterDemo    = document.createTextNode(' | ');
			}
			function collectInfos() {
				const elements = pageResource.elements;
				const infos    = pageResource.infos;
				infos.title       = elements.title.innerText;
				infos.isImagePage = elements.images.length > 0;
				infos.content     = infos.isImagePage ? null : elements.content.innerText;
			}
			function initDownload() {
				const elements = pageResource.elements;
				const download = pageResource.download;
				download.running  = false;
				download.finished = 0;
				download.all      = elements.images.length;
				download.error    = 0;
			}
		}

		// Prevent URL.revokeObjectURL in script 轻小说文库下载
		function revokeObjectURLHOOK() {
			const Ori_revokeObjectURL = URL.revokeObjectURL;
			URL.revokeObjectURL = function(arg) {
				if (typeof(arg) === 'string' && arg.substr(0, 5) === 'blob:') {return false;};
				return Ori_revokeObjectURL(arg);
			}
		}

		// Provide download GUI
		function downloadGUI() {
			const elements = pageResource.elements;
			const infos    = pageResource.infos;
			const download = pageResource.download;

			// Create donwload button
			const dlBtn = elements.downloadBtn = document.createElement('span');
			dlBtn.classList.add(CLASSNAME_BUTTON);
			dlBtn.addEventListener('click', infos.isImagePage ? dlNovelImages : dlNovelText);
			dlBtn.innerText = infos.isImagePage ? TEXT_GUI_DOWNLOAD_IMAGE : TEXT_GUI_DOWNLOAD_TEXT;

			// Create spliter
			const spliter = elements.spliterDemo.cloneNode();

			// Append to rightButtonDiv
			elements.rightButtonDiv.style.width = '550px';
			elements.rightButtonDiv.insertBefore(spliter, elements.rightBlank);
			elements.rightButtonDiv.insertBefore(dlBtn,   elements.rightBlank);

			function dlNovelImages() {
				if (download.running) {return false;};
				download.running = true; download.finished = 0; download.error = 0;
				updateDownloadStatus();

				const lenNumber = String(elements.images.length).length;
				for (let i = 0; i < elements.images.length; i++) {
					const img = elements.images[i];
					const name = infos.title + '_' + fillNumber(i+1, lenNumber) + '.jpg';
					GM_xmlhttpRequest({
						url: img.src,
						responseType: 'blob',
						onloadstart: function() {
							DoLog(LogLevel.Info, '[' + String(i) + ']downloading novel image from ' + img.src);
						},
						onload: function(e) {
							DoLog(LogLevel.Info, '[' + String(i) + ']image got: ' + img.src);

							const image = new Image();
							image.onload = function() {
								const url = toImageFormatURL(image, 1);
								DoLog(LogLevel.Info, '[' + String(i) + ']image transformed: ' + img.src);

								const a = document.createElement('a');
								a.href = url;
								a.download = name;
								a.click();

								download.finished++;
								updateDownloadStatus();
								// Code below seems can work, but actually it doesn't work well and somtimes some file cannot be saved
								// The reason is still unknown, but from what I know I can tell that mistakes happend in GM_xmlhttpRequest
								// Error stack: GM_xmlhttpRequest.onload ===> image.onload ===> downloadFile ===> GM_xmlhttpRequest =X=> .onload
								// This Error will also stuck the GMXHRHook.ongoingList
								/*downloadFile({
									url: url,
									name: name,
									onload: function() {
										download.finished++;
										DoLog(LogLevel.Info, '[' + String(i) + ']file saved: ' + name);
										alert('[' + String(i) + ']file saved: ' + name);
										updateDownloadStatus();
									},
									onerror: function() {
										alert('downloadfile error! url = ' + String(url) + ', i = ' + String(i));
									}
								})*/
							}
							image.onerror = function() {
								alert('image load error! image.src = ' + String(image.src) + ', i = ' + String(i));
							}
							image.src = URL.createObjectURL(e.response);
						},
						onerror: function(e) {
							// Error dealing need...
							DoLog(LogLevel.Error, '[' + String(i) + ']image fetch error: ' + img.src);
							download.error++;
						}
					})
				}

				function updateDownloadStatus() {
					elements.downloadBtn.innerText = TEXT_GUI_DOWNLOADING_ALL.replaceAll('C', String(download.finished)).replaceAll('A', String(download.all));
					if (download.finished === download.all) {
						DoLog(LogLevel.Success, 'All images got.');
						elements.downloadBtn.innerText = TEXT_GUI_DOWNLOADED_ALL;
						download.running = false;
					}
				}
			}

			function dlNovelText() {
				const name = infos.title + '.txt';
				const text = infos.content.replaceAll(/[\r\n]+/g, '\r\n');
				downloadText(text, name);
			}
		}

        // Image format changing function
		// image: <img> or Image(); format: 1 for jpeg, 2 for png, 3 for webp
        function toImageFormatURL(image, format) {
            if (typeof(format) === 'number') {format = ['image/jpeg', 'image/png', 'image/webp'][format-1]}
            const cvs = document.createElement('canvas');
            cvs.width = image.width;
		    cvs.height = image.height;
            const ctx = cvs.getContext('2d');
            ctx.drawImage(image, 0, 0);
            return cvs.toDataURL(format);
        }
    }

	// Search form add-on
	function formSearch() {
		const searchForm = document.querySelector('form[name="articlesearch"]');
		if (!searchForm) {return false;};
		const typeSelect = searchForm.querySelector('#searchtype');
		const searchText = searchForm.querySelector('#searchkey');
		const searchSbmt = searchForm.querySelector('input[class="button"][type="submit"]');

		let optionTags;
		provideTagOption();
		onsubmitHOOK();

		function provideTagOption() {
			optionTags = document.createElement('option');
			optionTags.value = VALUE_STR_NULL;
			optionTags.innerText = TEXT_GUI_SEARCH_OPTION_TAG;
			typeSelect.appendChild(optionTags);

			if (tipready) {
				// tipshow and tiphide is coded inside wenku8 itself, its function is to show a text tip besides the mouse
				typeSelect.addEventListener('mouseover', show);
				searchSbmt.addEventListener('mouseover', show);
				typeSelect.addEventListener('mouseout' , tiphide);
				searchSbmt.addEventListener('mouseout' , tiphide);
			} else {
				typeSelect.title = TEXT_TIP_SEARCH_OPTION_TAG;
				searchSbmt.title = TEXT_TIP_SEARCH_OPTION_TAG;
			}

			function show() {
				optionTags.selected ? tipshow(TEXT_TIP_SEARCH_OPTION_TAG) : function() {};
			}
		}
		function onsubmitHOOK() {
			const onsbmt = searchForm.onsubmit;
			searchForm.onsubmit = function() {
				if (optionTags.selected) {
					// DON'T USE window.open()!
					// Wenku8 has no window.open used in its own scripts, so do not use it in userscript either.
					// It might cause security problems.
					//window.open('https://www.wenku8.net/modules/article/tags.php?t=' + $URL.encode(searchText.value));
					if (typeof($URL) === 'undefined' ) {
						$URLError();
						return true;
					} else {
						GM_openInTab(URL_TAGSEARCH.replace('{TU}', $URL.encode(searchText.value)), {
							active: true, insert: true, setParent: true, incognito: false
						});
						return false;
					}
				}
			}

			function $URLError() {
				DoLog(LogLevel.Error, '$URL(from gbk.js) is not loaded.');
				DoLog(LogLevel.Warning, 'Search as plain text instead.');

				// Search as plain text instead
				for (const node of typeSelect.childNodes) {
					node.selected = (node.tagName === 'OPTION' && node.value === 'articlename') ? true : false;
				}
			}
		}
	}

	// Tags page add-on
	function pageTags() {
	}

	// User page add-on
	function pageUser() {
		const UID = Number(getUrlArgv('uid'));

		// Provide review search option
		reviewButton();

		// Review search option
		function reviewButton() {
			// clone button and container div
			const oriContainer = document.querySelectorAll('.blockcontent .userinfo')[0].parentElement;
			const container = oriContainer.cloneNode(true);
			const button = container.querySelector('a');
			button.innerText = TEXT_GUI_USER_REVIEWSEARCH;
			button.href = URL_REVIEWSEARCH.replaceAll('{K}', String(UID));
			oriContainer.parentElement.appendChild(container);
		}
	}

	// Index page add-on
    function pageIndex() {
    }

    // Download page add-on
    function pageDownload() {
        let i;
        let dlCount = 0; // number of active download tasks
        let dlAllRunning = false; // whether there is downloadAll running

		// Get novel info
		const novelInfo = {}; collectNovelInfo();
		const myDlBtns = [];

		// Donwload GUI
		downloadGUI();

        // Server GUI
        serverGUI();

        /* ******************* Code ******************* */
		function collectNovelInfo() {
			novelInfo.novelName = document.querySelector('html body div.main div#centerm div#content table.grid caption a').innerText;
			novelInfo.displays = getAllNameEles();
			novelInfo.volumeNames = getAllNames();
			novelInfo.type = getUrlArgv('type');
			novelInfo.ext = novelInfo.type !== 'txtfull' ? novelInfo.type : 'txt';
		}

		// Donwload GUI
		function downloadGUI() {
			// Only txt is really separated by volumes
			if (novelInfo.type !== 'txt') {return false;};

			// define vars
			let i;

			const tbody = document.querySelector('table>tbody');
			const header = tbody.querySelector('th').parentElement;
			const thead = header.querySelector('th');

			// Append new th
			const newHead = thead.cloneNode(true);
			newHead.innerText = TEXT_GUI_SDOWNLOAD;
			thead.width = '40%';
			header.appendChild(newHead);

			// Append new td
			const trs = tbody.querySelectorAll('tr');
			for (i = 1; i < trs.length; i++) { /* i = 1 to trs.length-1: skip header */
				const index = i-1;
				const tr = trs[i];
				const newTd = tr.querySelector('td.even').cloneNode(true);
				const links = newTd.querySelectorAll('a');
				for (const a of links) {
					a.classList.add(CLASSNAME_BUTTON);
					a.info = {
						description: 'volume download button',
						name: novelInfo.volumeNames[index],
						filename: '{NovelName} {VolumeName}.{Extension}'
							.replace('{NovelName}', novelInfo.novelName)
							.replace('{VolumeName}', novelInfo.volumeNames[index])
							.replace('{Extension}', novelInfo.ext),
						index: index,
						display: novelInfo.displays[index]
					}
					a.onclick = downloadOnclick;
					myDlBtns.push(a);
				}
				tr.appendChild(newTd);
			}

			// Append new tr, provide batch download
			const newTr = trs[trs.length-1].cloneNode(true);
			const newTds = newTr.querySelectorAll('td');
			newTds[0].innerText = TEXT_GUI_DOWNLOADALL;
			//clearChildnodes(newTds[1]); clearChildnodes(newTds[2]);
			newTds[1].innerHTML = newTds[2].innerHTML = TEXT_GUI_NOTHINGHERE;
			tbody.insertBefore(newTr, tbody.children[1]);

			const allBtns = newTds[3].querySelectorAll('a');
			for (i = 0; i < allBtns.length; i++) {
				const a = allBtns[i];
				a.href = 'javascript:void(0);';
				a.info = {
					description: 'download all button',
					index: i
				}
				a.onclick = downloadAllOnclick;
			}
		}

		// Download button onclick
		function downloadOnclick() {
			const a = this;
			a.info.display.innerText = a.info.name + TEXT_GUI_WAITING;
			downloadFile({
				url: a.href,
				name: a.info.filename,
				onloadstart: function(e) {
					a.info.display.innerText = a.info.name + TEXT_GUI_DOWNLOADING;
				},
				onload: function(e) {
					a.info.display.innerText = a.info.name + TEXT_GUI_DOWNLOADED;
				}
			});
			return false;
		}

		// DownloadAll button onclick
		function downloadAllOnclick() {
			const a = this;
			const index = (a.info.index+1)%3;
			for (let i = 0; i < myDlBtns.length; i++) {
				if ((i+1)%3 !== index) {continue;};
				const btn = myDlBtns[i];
				btn.click();
			}
			return false;
		}

		// Get all name display elements
		function getAllNameEles() {
            return document.querySelectorAll('.grid tbody tr .odd');
        }

		// Get all names
		function getAllNames() {
            const all = getAllNameEles()
            const names = [];
            for (let i = 0; i < all.length; i++) {
                names[i] = all[i].innerText;
            }
            return names;
        }

		// Server GUI
		function serverGUI() {
			let servers = document.querySelectorAll('#content>b');
			let serverEles = [];
			for (i = 0; i < servers.length; i++) {
				if (servers[i].innerText.includes('wenku8.com')) {
					serverEles.push(servers[i]);
				}
			}
			for (i = 0; i < serverEles.length; i++) {
				serverEles[i].classList.add(CLASSNAME_BUTTON);
				serverEles[i].addEventListener('click', function () {
					changeAllServers(this.innerText);
				});
				if (tipready) {
					// tipshow and tiphide is coded inside wenku8 itself, its function is to show a text tip besides the mouse
					serverEles[i].addEventListener('mouseover', function () {
						tipshow(TEXT_TIP_SERVERCHANGE);
					});
					serverEles[i].addEventListener('mouseout', tiphide);
				} else {
					serverEles[i].title = TEXT_TIP_SERVERCHANGE;
				}
			}
		}

        // Change all server elements
        function changeAllServers(server) {
            let i;
            const allA = document.querySelectorAll('.even a');
            for (i = 0; i < allA.length; i++) {
                changeServer(server, allA[i]);
            }
        }

        // Change server for an element
        function changeServer(server, element) {
            if (!element.href) {return false;};
            element.href = element.href.replace(/\/\/dl\d?\.wenku8\.com\//g, '//' + server + '/');
        }
    }

	// isAPIPage page add-on
	function pageAPI(API) {
		DoLog(LogLevel.Info, 'This is wenku API page.');
		DoLog(LogLevel.Info, 'API is: [' + API + ']');
		DoLog(LogLevel.Info, 'There is nothing to do. Quiting...');
	}

	// Check if current page is an wenku API page ('处理成功', '出现错误!')
	function isAPIPage() {
		// API page has just one .block div and one close-page button
		const block = document.querySelectorAll('.block');
		const close = document.querySelectorAll('a[href="javascript:window.close()"]');
		return block.length === 1 && close.length === 1;
	}

	// getMyUserDetail with soft alerts
	function refreshMyUserDetail() {
		new ElegantAlertBox(TEXT_ALT_USRDTL_REFRESH);
		getMyUserDetail(function() {
			const alertBox = new ElegantAlertBox(TEXT_ALT_USRDTL_REFRESHED);

			// rewrite onclick function from copying to showing details
			alertBox.elm.onclick = function() {
				alertBox.close.call(alertBox);
				new ElegantAlertBox(JSON.stringify(getMyUserDetail()));
			}
		})
	}

	// Get my user info detail
	// if no argument provided, this function will just read userdetail from gm_storage
	// otherwise, the function will make a http request to get the latest userdetail
	// if no argument provided and no gm_storage record, then will just return false
	// if callback is not a function, then will just request&store but not callback
	function getMyUserDetail(callback, args=[]) {
		if (callback) {
			getDocument(URL_USRDETALE, function(oDoc, callback, ...args) {
				const content = oDoc.querySelector('#content');
				const userDetail = {
					userID: Number(content.querySelector('tr:nth-child(1)>.even').innerText),  // '用户ID'
					userLink: content.querySelector('tr:nth-child(2)>.even').innerText,        // '推广链接'
					userName: content.querySelector('tr:nth-child(3)>.even').innerText,        // '用户名'
					displayName: content.querySelector('tr:nth-child(4)>.even').innerText,     // '用户昵称'
					userType: content.querySelector('tr:nth-child(5)>.even').innerText,        // '等级'
					userGrade: content.querySelector('tr:nth-child(6)>.even').innerText,       // '头衔'
					gender: content.querySelector('tr:nth-child(7)>.even').innerText,          // '性别'
					email: content.querySelector('tr:nth-child(8)>.even').innerText,           // 'Email'
					qq: content.querySelector('tr:nth-child(9)>.even').innerText,              // 'QQ'
					msn: content.querySelector('tr:nth-child(10)>.even').innerText,            // 'MSN'
					site: content.querySelector('tr:nth-child(11)>.even').innerText,           // '网站'
					signupDate: content.querySelector('tr:nth-child(13)>.even').innerText,     // '注册日期'
					contibute: content.querySelector('tr:nth-child(14)>.even').innerText,      // '贡献值'
					exp: content.querySelector('tr:nth-child(15)>.even').innerText,            // '经验值'
					credit: content.querySelector('tr:nth-child(16)>.even').innerText,         // '现有积分'
					friends: content.querySelector('tr:nth-child(17)>.even').innerText,        // '最多好友数'
					mailbox: content.querySelector('tr:nth-child(18)>.even').innerText,        // '信箱最多消息数'
					bookcase: content.querySelector('tr:nth-child(19)>.even').innerText,       // '书架最大收藏量'
					vote: content.querySelector('tr:nth-child(20)>.even').innerText,           // '每天允许推荐次数'
					sign: content.querySelector('tr:nth-child(22)>.even').innerText,           // '用户签名'
					intoduction: content.querySelector('tr:nth-child(23)>.even').innerText,    // '个人简介'
					userImage: content.querySelector('tr>td>img').src                          // '头像'
				}

				// Save to gm_storage
				const storage = {userDetail: userDetail};
				storage[KEY_USRDETAIL_VERSION] = VALUE_USRDETAIL_VERSION;
				storage.lasttime = getTime('-', false);
				GM_setValue(KEY_USRDETAIL, storage);

				// Callback
				typeof(callback) === 'function' ? callback.apply(null, args) : function() {};
			}, [callback].concat(args))
			return true;
		} else {
			const storage = GM_getValue(KEY_USRDETAIL, null);
			if (!storage) {
				DoLog(LogLevel.Warning, 'Attempt to read userDetail from gm_storage but no record found');
				return false;
			};
			const userDetail = storage;
			return userDetail;
		}
	}

	// Check if tipobj is ready, if not, then make it
	function tipcheck() {
		DoLog(LogLevel.Info, 'checking tipobj...');
		if (typeof(tipobj) === 'object' && tipobj !== null) {
			DoLog(LogLevel.Info, 'tipobj ready...');
			return true;
		} else {
			DoLog(LogLevel.Warning, 'tipobj not ready');
			if (typeof(tipinit) === 'function') {
				DoLog(LogLevel.Success, 'tipinit executed');
				tipinit();
				return true;
			} else {
				DoLog(LogLevel.Error, 'tipinit not found');
				return false;
			}
		}
	}

	// Create a left .block operatingArea
	// options = {type: '', ...opts}
	function createLeftBlock(title=TEXT_GUI_BLOCK_TITLE_DEFULT, append=false, options) {
		const leftEle  = document.querySelector('#left');
		const blockEle = document.querySelector('#left>.block').cloneNode(true);
		const titleEle = blockEle.querySelector('.blocktitle>.txt');
		const cntntEle = blockEle.querySelector('.blockcontent');

		titleEle.innerText = title;
		clearChildnodes(cntntEle);

		const type = options ? options.type.toLowerCase() : null;
		switch (type.toLowerCase()) {
			case 'mypage': typeMypage(); break;
			default: DoLog(LogLevel.Error, 'createLeftBlock: Invalid block type');
		}

		append ? leftEle.appendChild(blockEle) : function() {};

		return blockEle;

		// Links such as https://www.wenku8.net/userdetail.php
		// options = {type: 'mypage', links = [...{href: '', innerHTML: '', id: ''}]}
		function typeMypage() {
			const ul = document.createElement('ul');
			ul.classList.add('ulitem');
			for (const link of options.links) {
				const li = document.createElement('li');
				const a = document.createElement('a');
				a.href = link.href ? link.href : 'javascript: void(0);';
				a.innerHTML = link.innerHTML;
				a.id = link.id ? link.id : '';
				li.appendChild(a);
				ul.appendChild(li);
			}
			blockEle.appendChild(ul);
		}
	}

	// Remove all childnodes from an element
	function clearChildnodes(element) {
		const cns = []
		for (const cn of element.childNodes) {
			cns.push(cn);
		}
		for (const cn of cns) {
			element.removeChild(cn);
		}
	}

	// 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);

			/* DO NOT DO THIS! KEEP ITS ORIGINAL PROPERTIES!
			// 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;
			}
		}
	}

    // Download and parse a url page into a html document(dom).
    // when xhr onload: callback.apply([dom, args])
    function getDocument(url, callback, args=[]) {
        GM_xmlhttpRequest({
            method       : 'GET',
            url          : url,
            responseType : 'blob',
			onloadstart  : function() {
				DoLog(LogLevel.Info, 'getting document, url=\'' + url + '\'');
			},
            onload       : function(response) {
                const htmlblob = response.response;
                const reader = new FileReader();
                reader.onload = function(e) {
                    const htmlText = reader.result;
                    const dom = new DOMParser().parseFromString(htmlText, 'text/html');
                    args = [dom].concat(args);
                    callback.apply(null, args);
                    //callback(dom, htmlText);
                }
                reader.readAsText(htmlblob, 'GBK');
                /* 注意!原来这里只是使用了DOMParser,DOMParser不像iframe加载Document一样拥有完整的上下文并执行所有element的功能,
                ** 只是按照HTML格式进行解析,所以在文库页面的GBK编码下仍然会按照UTF-8编码进行解析,导致中文乱码。
                ** 所以处理dom时不要使用ASC-II字符集以外的字符!
                **
                ** 注:现在使用了FileReader来以GBK编码解析htmlText,故编码问题已经解决,可以正常使用任何字符
                */
            }
        })
    }

	// Save dataURL to file
	function saveFile(dataURL, filename) {
		const a = document.createElement('a');
		a.href = dataURL;
		a.download = filename;
		a.click();
	}

	// File download function
	// details looks like the detail of GM_xmlhttpRequest
	// onload function will be called after file saved to disk
	function downloadFile(details) {
		if (!details.url || !details.name) {return false;};

		// Configure request object
		const requestObj = {
			url: details.url,
			responseType: 'blob',
			onload: function(e) {
				// Save file
				saveFile(URL.createObjectURL(e.response), details.name);

				// onload callback
				details.onload ? details.onload(e) : function() {};
			}
		}
		if (details.onloadstart       ) {requestObj.onloadstart        = details.onloadstart;};
		if (details.onprogress        ) {requestObj.onprogress         = details.onprogress;};
		if (details.onerror           ) {requestObj.onerror            = details.onerror;};
		if (details.onabort           ) {requestObj.onabort            = details.onabort;};
		if (details.onreadystatechange) {requestObj.onreadystatechange = details.onreadystatechange;};
		if (details.ontimeout         ) {requestObj.ontimeout          = details.ontimeout;};

		// Send request
		GM_xmlhttpRequest(requestObj);
	}

	// Save text to textfile
	function downloadText(text, name) {
		if (!text || !name) {return false;};

		// Get blob url
		const blob = new Blob([text],{type:"text/plain;charset=utf-8"});
		const url = URL.createObjectURL(blob);

		// Create <a> and download
		const a = document.createElement('a');
		a.href = url;
		a.download = name;
		a.click();
	}

	// Get a url argument from lacation.href
	// also recieve a function to deal the matched string
	// returns defultValue if name not found
	function getUrlArgv(name, dealFunc=(function(a) {return a;}), defultValue=null) {
		const url = location.href;
		const matcher = new RegExp(name + '=([^&]+)');
		const result = url.match(matcher);
		const argv = result ? dealFunc(result[1]) : defultValue;

		return argv;
	}

    // Get a time text like 1970-01-01 00:00:00
	// if dateSpliter provided false, there will be no date part. The same for timeSpliter.
    function getTime(dateSpliter='-', timeSpliter=':') {
        const d = new Date();
		let fulltime = ''
		fulltime += dateSpliter ? fillNumber(d.getFullYear(), 4) + dateSpliter + fillNumber((d.getMonth() + 1), 2) + dateSpliter + fillNumber(d.getDate(), 2) : '';
		fulltime += dateSpliter && timeSpliter ? ' ' : '';
		fulltime += timeSpliter ? fillNumber(d.getHours(), 2) + timeSpliter + fillNumber(d.getMinutes(), 2) + timeSpliter + fillNumber(d.getSeconds(), 2) : '';
        return fulltime;
    }

	// Get key-value object from text like 'key: value'/'key:value'/' key   :  value   '
	// returns: {key: value, KEY: key, VALUE: value}
	function getKeyValue(text, delimiters=[':', ':', ',']) {
		// Modify from https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error#examples
		// Create a new object, that prototypally inherits from the Error constructor.
		function SplitError(message) {
			this.name = 'SplitError';
			this.message = message || 'SplitError Message';
			this.stack = (new Error()).stack;
		}
		SplitError.prototype = Object.create(Error.prototype);
		SplitError.prototype.constructor = SplitError;

		if (!text) {return [];};

		const result = {};
		let key, value;
		for (let i = 0; i < text.length; i++) {
			const char = text.charAt(i);
			for (const delimiter of delimiters) {
				if (delimiter === char) {
					if (!key && !value) {
						key = text.substr(0, i).trim();
						value = text.substr(i+1).trim();
						result[key] = value;
						result.KEY = key;
						result.VALUE = value;
					} else {
						throw new SplitError('Mutiple Delimiter in Text');
					}
				}
			}
		}

		return result;
	}

	// Convert rgb color(e.g. 51,51,153) to hex color(e.g. '333399')
	function rgbToHex(r, g, b) {return ((r << 16) | (g << 8) | b).toString(16);}

    // Fill number text to certain length with '0'
    function fillNumber(number, length) {
        let str = String(number);
        for (let i = str.length; i < length; i++) {
            str = '0' + str;
        }
        return str;
    }

    // Judge whether the str is a number
    function isNumeric(str, disableFloat=false) {
        const result = Number(str);
        return !isNaN(result) && str !== '' && (!disableFloat || result===Math.floor(result));
    }

	// 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;
	}

	// Clone(deep) an object variable
	// Returns the new object
	function deepclone(obj) {
		if (obj === null) return null;
		if (typeof(obj) !== 'object') return obj;
		if (obj.constructor === Date) return new Date(obj);
		if (obj.constructor === RegExp) return new RegExp(obj);
		var newObj = new obj.constructor(); //保持继承的原型
		for (let key in obj) {
			if (obj.hasOwnProperty(key)) {
				const val = obj[key];
				newObj[key] = typeof val === 'object' ? deepclone(val) : val;
			}
		}
		return newObj;
	}

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

    // Append a style text to document(<head>) with a <style> element
    function addStyle(css) {
        document.head.appendChild(document.createElement("style")).textContent = css;
    }

    // Copy text to clipboard (needs to be called in an user event)
    function copyText(text) {
        // Create a new textarea for copying
        const newInput = document.createElement('textarea');
        document.body.appendChild(newInput);
        newInput.value = text;
        newInput.select();
        document.execCommand('copy');
        document.body.removeChild(newInput);
    }
})();