轻小说文库+

轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。

当前为 2022-05-20 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

/* eslint-disable no-multi-spaces */
/* eslint-disable no-useless-call */
/* eslint-disable userscripts/no-invalid-headers */

// ==UserScript==
// @name         轻小说文库+
// @namespace    Wenku8+
// @version      1.7.2.6
// @description  轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。
// @updateinfo   <h3>v1.7.2.6</h3><ul><li>bug fix: 修复部分网络环境下脚本无法正常加载的bug</li></ul>
// @author       PY-DNG
// @license      GPL-license
// @icon         data:image/vnd.microsoft.icon;base64,AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAD/igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//rlB//3HaP/858P//OfD//znw//858P//OfD//znw//858P//OfD//zitv/91Y///dWP//3Vj//9y3X//cdo//3HaP/9x2j//cdo//zitv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//znw//858P//OfD//znw//9x2j//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6cN//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//3ZnP//ogD//6IA//+iAP//ogD//sJb//3HaP/83qn//OfD//zeqf//pw3//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+rGv/90IL//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//cdo//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//OfD//znw//858P//OfD//znw//858P//6IA//+iAP/84rb//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//rQ0//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//3Vj//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//6wJ///ogD//6sa//3HaP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//+iAP//ogD//rQ0//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//zitv/+vk7//6IA//+iAP//ogD//6IA//+iAP/+tDT//OfD//znw//858P//OfD//znw//858P//rAn//+iAP//ogD//ct1//3HaP/+tDT//rQ0//6+Tv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//ct1//7CW///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//65Qf//ogD//6IA//3HaP/9x2j//6cN//+iAP//ogD//r5O//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//60NP/9x2j//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//OfD//65Qf//ogD//6cN//znw//858P//6IA//+iAP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP/858P//cdo//+iAP//ogD//dWP//3Vj///pw3//6IA//7CW//858P//rQ0//+iAP/92Zz//OfD//3Ldf/+tDT//OK2//znw//858P//N6p//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//7CW///ogD//6IA//6+Tv//ogD//6IA//6+Tv/91Y///6IA//+iAP/+vk7//OfD//+iAP//ogD//6IA//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//dWP//+iAP//ogD//6IA//+iAP//ogD//OK2//+rGv//ogD//6IA//3ZnP/858P//6IA//+iAP//qxr//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//sJb//+iAP//pw3//rQ0//+iAP//ogD//OfD//znw//+wlv//6IA//+iAP//ogD//6cN//3ZnP/+vk7//6IA//+iAP/90IL//OfD//60NP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//84rb//rAn//+iAP//ogD//6IA//+iAP/+sCf//rQ0//+iAP//ogD//6IA//+iAP/9x2j//OfD//65Qf//ogD//rAn//znw//858P//rQ0//+iAP/+uUH//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//r5O//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/9x2j//6IA//+iAP/9x2j//OfD//6+Tv//ogD//6IA//znw//84rb//6IA//+iAP/84rb//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//+nDf//ogD//6cN//60NP/+vk7//ct1//znw//+wlv//6IA//+iAP/858P//OK2//+iAP//ogD//dmc//znw///pw3//6IA//6wJ//83qn//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//+iAP//ogD//dmc//znw//858P//OfD//znw///pw3//6IA//+nDf/83qn//dmc//+nDf//ogD//6sa//znw//92Zz//6IA//+iAP/858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//N6p//3HaP/+sCf//6IA//+iAP//ogD//6IA//+iAP//qxr//dmc//3ZnP//pw3//6IA//6+Tv/858P//dmc//+iAP//ogD//OfD//znw//9x2j//sJb//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/+vk7//OfD//znw//91Y///OK2//znw//858P//dWP//3Vj//9x2j//rlB//+nDf//pw3//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//3Ldf//ogD//6IA//+iAP//ogD//6IA//60NP/+tDT//cdo//zitv/+tDT//rQ0//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/83qn//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//dmc//+iAP/+sCf//OfD//znw//858P//dWP//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//zeqf/858P//OfD//znw//858P//rQ0//60NP/+tDT//rQ0//60NP/9x2j//cdo//3HaP/91Y///dWP//zeqf/858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
// @match        http*://www.wenku8.net/*
// @connect      wenku8.com
// @connect      wenku8.net
// @connect      greasyfork.org
// @connect      image.kieng.cn
// @connect      sm.ms
// @connect      catbox.moe
// @connect      liumingye.cn
// @connect      p.sda1.dev
// @connect      api.pandaimg.com
// @connect      imagelol.com
// @connect      pic.jitudisk.com
// @connect      cdn.jsdelivr.net
// @connect      kit.fontawesome.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_openInTab
// @grant        GM_getResourceText
// @grant        GM_info
// @grant        unsafeWindow
// @require      https://cdn.jsdelivr.net/gh/PYUDNG/CDN@eed1fcf0e901348bc4e752fd483bcb571ebe0408/js/GBK_URL/GBK.js
// @require      https://cdn.jsdelivr.net/gh/PYUDNG/CDN@94fc2bdd313f7bf2af6db5b8699effee8dd0b18d/js/ajax/GreasyForkScriptUpdate.js
// @require      https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/alertify.min.js
// @require      https://unpkg.com/@popperjs/core@2
// @require      https://unpkg.com/tippy.js@6
// @resource     alertify-css    https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/alertify.min.css
// @resource     alertify-theme  https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/themes/default.min.css
// @resource     css-tooltip     https://cdn.jsdelivr.net/gh/PYUDNG/css-components@main/build/tooltip/tooltip.css
// @noframes
// ==/UserScript==

/* 需求记录 [容易(优先级高) ➡️ 困难(优先级低)(我懒,一般而言优先做低难度的)]
** [已完成]{BK}书评页提供用户书评搜索
** {BK}图片大小(最大)限制
** [已完成]{BK}回复区插入@好友
** [已完成]全卷/分卷下载:文件重命名为书名,而不是书号
** · [已完成]添加单文件下载重命名
** {BK}回复区悬浮显示
** {热忱}[已完成]修复https引用问题
** [已完成]书评打开最后一页
** [待完善]书评实时更新
** · [待完善]新回复直接添加到当前页面
** · 主动回复内容直接添加到当前页面
** [待完善]引用回复
** · [已完成]引用楼层号和回复内容
** · [已完成]仅引用楼层号
** [已完成]支持preview版tag搜索
** [待完善]书评帖子收藏
** · [已完成]书评页面收藏
** · [高优先级]收藏的书评页面可以添加编辑备注
** [已完成]每日自动推书
** [待完善]{热忱}快速切换账号
** · [已完成]为每个账号储存单独的配置
** · [待完善]保存账号信息并快速自动切换
** [待完善]快速插入图片/表情
** · [已完成]直接插入本地图片
** · [持续进行]更多图床
** · [低优先级]保存常用图片/表情链接
** [部分完成]{BK}页面美化
** · [已完成]阅读页去除广告
** · [已完成]阅读页美化
** · [已完成]书评页美化
** · …
** [高优先级][施工中]脚本储存管理界面
** [高优先级][待完善]稍后再读(可以的话,请给我提出改进建议)
** {BK}类似ehunter的阅读模式
** 改进旧代码:
** · 每个page-addon内部要按照功能分模块,执行功能靠调用模块,不能直接写功能代码
** · 共性模块要写进脚本全局作用域,可以的话写成构造函数
** [低优先级]{RC}书评:@某人时通知他
** [待完善]{BK}书评:草稿箱功能
** {热忱}{s1h2}提供带文字和插图的epub整合下载
*/
(function FUNC_MAIN() {
    'use strict';

    // Polyfills
	const script_name = '轻小说文库+';
	const script_version = '1.7.2.6';
	const NMonkey_Info = {
		GM_info: {
			script: {
				name: script_name,
				author: 'PY-DNG',
				version: script_version,
			}
		},
		mainFunc: FUNC_MAIN,
		name: 'wenku8_plus',
		requires: [
			// GBK-URL
			{
				name: 'GBK-URL',
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/CDN@eed1fcf0e901348bc4e752fd483bcb571ebe0408/js/GBK_URL/GBK.js',
				srcset: [
					'https://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=953098'
				],
				loaded: () => (typeof $URL === 'object'),
				execmode: 'function'
			},

			// GreasyForkScriptUpdate
			{
				name: 'GreasyForkScriptUpdate',
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/CDN@94fc2bdd313f7bf2af6db5b8699effee8dd0b18d/js/ajax/GreasyForkScriptUpdate.js',
				srcset: [
					'https://greasyfork.org/scripts/431490-greasyforkscriptupdate/code/GreasyForkScriptUpdate.js?version=965063',
				],
				loaded: () => (typeof GreasyForkUpdater === 'function'),
				execmode: 'eval'
			},

			// Alertify
			{
				name: 'Alertify',
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/alertify.min.js',
				srcset: [
					'https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/alertify.min.js',
					'https://bowercdn.net/c/alertify-js-1.13.1/build/alertify.min.js',
					'https://cdn.bootcdn.net/ajax/libs/AlertifyJS/1.9.0/alertify.min.js',
				],
				loaded: () => (typeof alertify === 'object'),
				execmode: 'function'
			},

			// FontAwesome
			/*
			{
				src: 'https://kit.fontawesome.com/1288cd6170.js',
				loaded: () => (typeof(FontAwesomeKitConfig) === 'object')
			}
			*/

			// Tippy.js
			{
				name: 'Tippy.js-Core',
				src: 'https://unpkg.com/@popperjs/core@2',
				loaded: () => (typeof tippy === 'function'),
				execmode: 'function'
			},
			{
				name: 'Tippy.js',
				src: 'https://unpkg.com/tippy.js@6',
				loaded: () => (typeof tippy === 'function'),
				execmode: 'function'
			},
		],
		resources: [
			// Alertify css
			{
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/alertify.min.css',
				srcset: [
					'https://bowercdn.net/c/alertify-js-1.13.1/build/css/alertify.min.css',
					'https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/alertify.min.css',
					'https://cdn.bootcdn.net/ajax/libs/AlertifyJS/1.9.0/css/alertify.min.css',
				],
				name: 'alertify-css',
				isCss: true
			},

			// Alertify theme
			{
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/themes/default.min.css',
				srcset: [
					'https://bowercdn.net/c/alertify-js-1.13.1/build/css/themes/default.min.css',
					'https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/themes/default.min.css',
					'https://cdn.bootcdn.net/ajax/libs/AlertifyJS/1.9.0/css/themes/default.min.css',
				],
				name: 'alertify-theme',
				isCss: true
			},

			// tooltip
			/*
			{
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/css-components@main/build/tooltip/tooltip.css',
				srcset: [
					'',
				],
				name: 'css-tooltip',
				isCss: true
			},
			*/

			// FontAwesome
			/*
			{
				src: 'https://cdn.jsdelivr.net/npm/@fortawesome/[email protected]/css/all.min.css',
				srcset: [
					'https://bowercdn.net/c/fontAwesome-6.1.1/css/all.min.css',
					'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css',
				],
				name: 'css-fontawesome',
				isCss: true
			}
			*/
		]
	};
    const NMonkey_Ready = NMonkey(NMonkey_Info);
	if (!NMonkey_Ready) {return false;}
	polyfill_replaceAll();

    // CONSTS
	const NUMBER_MAX_XHR = typeof mbrowser === 'object' ? 1 : 10;
	const NUMBER_LOGSUCCESS_AFTER = NUMBER_MAX_XHR * 2;
	const NUMBER_ELEMENT_LOADING_WAIT_INTERVAL = 500;

	const KEY_CM = 'Config-Manager';
	const KEY_CM_VERSION = 'version';
	const VALUE_CM_VERSION = '0.3';

	const KEY_DRAFT_DRAFTS = 'comment-drafts';
	const KEY_DRAFT_VERSION = 'version';
	const VALUE_DRAFT_VERSION = '0.2';

	const KEY_REVIEW_PREFS = 'comment-preferences';
	const KEY_REVIEW_VERSION = 'version';
	const VALUE_REVIEW_VERSION = '0.9';

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

	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.2';

	const KEY_BEAUTIFIER = 'beautifier';
	const KEY_BEAUTIFIER_VERSION = 'version';
	const VALUE_BEAUTIFIER_VERSION = '0.9';

	const KEY_USERGLOBAL = 'user-global-config';
	const KEY_USERGLOBAL_VERSION = 'version';
	const VALUE_USERGLOBAL_VERSION = '0.1';

	const VALUE_STR_NULL = 'null';

	const URL_NOVELINDEX   = 'https://www.wenku8.net/book/{I}.htm';
	const URL_REVIEWSEARCH = 'https://www.wenku8.net/modules/article/reviewslist.php?keyword={K}';
	const URL_REVIEWSHOW   = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}';
	const URL_REVIEWSHOW_1 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}';
	const URL_REVIEWSHOW_2 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&page={P}';
	const URL_REVIEWSHOW_3 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}';
	const URL_REVIEWSHOW_4 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&page={P}#{Y}';
	const URL_REVIEWSHOW_5 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}#{Y}';
	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_PACKSHOW = 'https://www.wenku8.net/modules/article/packshow.php?id={A}&type={T}';
	const URL_BOOKINTRO = 'https://www.wenku8.net/book/{A}.htm';
	const URL_ADDBOOKCASE = 'https://www.wenku8.net/modules/article/addbookcase.php?bid={A}';
	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_USRDETAIL = 'https://www.wenku8.net/userdetail.php';
	const URL_USRFRIEND = 'https://www.wenku8.net/myfriends.php';
	const URL_BOOKCASE  = 'https://www.wenku8.net/modules/article/bookcase.php';
	const URL_USRLOGIN  = 'https://www.wenku8.net/login.php?do=submit&jumpurl=http%3A%2F%2Fwww.wenku8.net%2Findex.php';
	const URL_USRLOGOFF = 'https://www.wenku8.net/logout.php';

	const DATA_XHR_LOGIN = [
		"username={U}",
		"password={P}",
		"usecookie={C}",
		"action=login",
		"submit=%26%23160%3B%B5%C7%26%23160%3B%26%23160%3B%C2%BC%26%23160%3B" // '&#160;登&#160;&#160;录&#160'
	].join('&');
	const DATA_IMAGERS = {
		default: 'SDAIDEV',
		/* Imager Model
		_IMAGER_KEY_: {
			available: true,
			name: '_IMAGER_DISPLAY_NAME_',
			tip: '_IMAGER_DISPLAY_TIP_',
			upload: {
				request: {
					url: '_UPLOAD_URL_',
					data: {
						'_FORM_NAME_FOR_FILE_': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json._SUCCESS_KEY_ === '_SUCCESS_VALUE_';},
					geturl: (json)=>{return json._PATH_._SUCCESS_URL_KEY_;},
					getname: (json)=>{return json._PATH_ ? json._PATH_._FILENAME_ : null;},
					getsize: (json)=>{return json._PATH_._SIZE_},
					getpage: (json)=>{return json._PATH_ ? json._PATH_._PAGE_ : null;},
					gethash: (json)=>{return json._PATH_ ? json._PATH_._HASH_ : null;},
					getdelete: (json)=>{return json._PATH_ ? json._PATH_._DELETE_ : null;}
				}
			},
			isImager: true
		},
		*/
		LIUMINGYE: {
			available: true,
			name: '刘明野-全能图床',
			tip: '2021-12-04测试可用</br>理论无上传大小限制,实际测试图片过大会上传失败',
			upload: {
				request: {
					url: 'https://tool.liumingye.cn/tuchuang/update.php',
					data: {
						'file': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 0;},
					geturl: (json)=>{return json.msg;}
				}
			},
			isImager: true
		},
		PANDAIMG: {
			available: true,
			name: '熊猫图床',
			tip: '2022-01-16测试可用</br>单张图片最大5MB',
			upload: {
				request: {
					url: 'https://api.pandaimg.com/upload',
					data: {
						'file': '$file$',
						'classifications': '',
						'day': '0'
					},
					headers: {
						'usersOrigin': '5edd88d4dfe5d288518c0454d3ccdd2a'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === '200';},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		SDAIDEV: {
			available: true,
			name: '流浪图床',
			tip: '2022-01-09测试可用</br>单张图片最大5MB',
			upload: {
				request: {
					url: 'https://p.sda1.dev/api/v1/upload_external_noform',
					urlargs: {
						'filename': '$filename$',
						'ts': '$time$',
						'rand': '$random$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.success;},
					geturl: (json)=>{return json.data.url;},
					getdelete: (json)=>{return json.data ? json.data.delete_url : null;},
					getsize: (json)=>{return json.data ? json.data.size : null;}
				}
			},
			isImager: true
		},
		JITUDISK: {
			available: true,
			name: '极兔兔床',
			tip: '2022-02-02测试可用',
			upload: {
				request: {
					url: 'https://pic.jitudisk.com/api/upload',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		IMAGELOL: {
			available: false,
			name: '笑果图床',
			tip: '2022-01-17测试可用</br>该图床不支持重复上传同一张图片,请注意</br>单张图片最大2MB',
			upload: {
				request: {
					url: 'https://imagelol.com/json',
					data: {
						'source': '$file$',
						'type': 'file',
						'action': 'upload',
						'timestamp': '$time$',
						'auth_token': '4f6fb8d04525bae5a455f4f09e2b09aa750e60c3',
						'nsfw': '0'
					}
				},
				response: {
					checksuccess: (json)=>{return json.status_code === 200 && json.success && json.success.code === 200;},
					geturl: (json)=>{return json.image.url;},
					getname: (json)=>{return json.image.original_filename;},
					getsize: (json)=>{return json.image.size},
					gethash: (json)=>{return json.image.md5;},
				}
			},
			isImager: true
		},
		/*GEJIBA: {
			available: true,
			name: '老王图床',
			tip: '2022-01-17测试可用</br>单张图片最大10MB</br>PS:此图床审核比较严格',
			upload: {
				request: {
					url: '_UPLOAD_URL_',
					data: {
						'_FORM_NAME_FOR_FILE_': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json._SUCCESS_KEY_ === '_SUCCESS_VALUE_';},
					geturl: (json)=>{return json._PATH_._SUCCESS_URL_KEY_;},
					getname: (json)=>{return json._PATH_ ? json._PATH_._FILENAME_ : null;},
					getsize: (json)=>{return json._PATH_._SIZE_},
					getpage: (json)=>{return json._PATH_ ? json._PATH_._PAGE_ : null;},
					gethash: (json)=>{return json._PATH_ ? json._PATH_._HASH_ : null;},
					getdelete: (json)=>{return json._PATH_ ? json._PATH_._DELETE_ : null;}
				}
			}
		},*/
		KIENG_JD: {
			available: false,
			name: 'KIENG-JD',
			tip: '默认图床</br>个人体验良好,推荐使用',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=jd',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_SG: {
			available: false,
			name: 'KIENG-SG',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=sg',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_58: {
			available: false,
			name: 'KIENG-58',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=c58',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_WY: {
			available: false,
			name: 'KIENG-WY',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=wy',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_QQ: {
			available: false,
			name: 'KIENG-QQ',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=qq',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_SN: {
			available: false,
			name: 'KIENG-SN',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=sn',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		KIENG_HL: {
			available: false,
			name: 'KIENG-HLX',
			upload: {
				request: {
					url: 'https://image.kieng.cn/upload.html?type=hl',
					data: {
						'image': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.code === 200;},
					geturl: (json)=>{return json.data.url;},
					getname: (json)=>{return json.data.name;}
				}
			},
			isImager: true
		},
		SMMS: {
			available: true,
			name: 'SM.MS',
			tip: '注意:此图床跨域访问较不稳定,且有用户反映其被国内部分服务商屏蔽,请谨慎使用此图床',
			warning: '注意:此图床跨域访问较不稳定,且有用户反映其被国内部分服务商屏蔽,请谨慎使用此图床</br>如出现上传错误/图片加载慢/无法加载图片等情况,请更换其他图床',
			upload: {
				request: {
					url: 'https://sm.ms/api/v2/upload?inajax=1',
					data: {
						'smfile': '$file$'
					}
				},
				response: {
					checksuccess: (json)=>{return json.success === true || /^https?:\/\//.test(json.images);},
					geturl: (json)=>{return json.data ? json.data.url : json.images;},
					getname: (json)=>{return json.data ? json.data.filename : null;},
					getpage: (json)=>{return json.data ? json.data.page : null;},
					gethash: (json)=>{return json.data ? json.data.hash : null;},
					getdelete: (json)=>{return json.data ? json.data.delete : null;}
				}
			},
			isImager: true
		},
		CATBOX: {
			available: true,
			name: 'CatBox',
			tip: '注意:此图床访问较不稳定,请谨慎使用此图床',
			warning: '注意:此图床访问较不稳定,请谨慎使用此图床</br>如出现上传错误/图片加载慢/无法加载图片等情况,请更换其他图床',
			upload: {
				request: {
					url: 'https://catbox.moe/user/api.php',
					responseType: 'text',
					data: {
						'fileToUpload': '$file$',
						'reqtype': 'fileupload'
					}
				},
				response: {
					checksuccess: (text)=>{return true;},
					geturl: (text)=>{return text;}
				}
			},
			isImager: true
		}
	};

	const FUNC_LATERBOOK_SORTERS = {
		'addTime_old2new': {
			name: '由旧到新',
			sorter: (a, b) => (a.addTime - b.addTime),
		},
		'addTime_new2old': {
			name: '由新到旧',
			sorter: (a, b) => (b.addTime - a.addTime),
		},
		'sort': {
			name: '手动排序',
			sorter: (a, b) => (a.sort - b.sort),
		}
	}

	const CLASSNAME_BUTTON = 'plus_btn';
	const CLASSNAME_TEXT = 'plus_text';
	const CLASSNAME_DISABLED = 'plus_disabled';
	const CLASSNAME_BOOKCASE_FORM = 'plus_bcform';
	const CLASSNAME_LIST = 'plus_list';
	const CLASSNAME_LIST_ITEM = 'plus_list_item';
	const CLASSNAME_LIST_BUTTON = 'plus_list_input';
	const CLASSNAME_MODIFIED = 'plus_modified';

	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_OLD = '<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_LINKS = '<div style="margin:0px auto;overflow:hidden;"><fieldset style="width:820px;height:35px;margin:0px auto;padding:0px;"><legend><b>《{ORIBOOKNAME}》小说TXT、UMD、JAR电子书下载</b></legend><div style="width:210px; float:left; text-align:center;"><a href="https://www.wenku8.net/modules/article/packshow.php?id={BOOKID}&amp;type=txt">TXT简繁分卷</a></div><div style="width:210px; float:left; text-align:center;"><a href="https://www.wenku8.net/modules/article/packshow.php?id={BOOKID}&amp;type=txtfull">TXT简繁全本</a></div><div style="width:210px; float:left; text-align:center;"><a href="https://www.wenku8.net/modules/article/packshow.php?id={BOOKID}&amp;type=umd">UMD分卷下载</a></div><div style="width:190px; float:left; text-align:center;"><a href="https://www.wenku8.net/modules/article/packshow.php?id={BOOKID}&amp;type=jar">JAR分卷下载</a></div></fieldset></div>';
    const HTML_DOWNLOAD_BOARD = '<span class="{C}">阅读与下载限制已解除</br>此功能仅供学习交流,请支持正版<span style="text-align: right;">——{N}</span></span>'.replace('{N}', GM_info.script.name).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_PAGE_API = 'body>div {display: flex; align-items: center; justify-content: center;}';
    const CSS_COLOR_BTN_NORMAL = 'rgb(0, 160, 0)', CSS_COLOR_BTN_HOVER = 'rgb(0, 100, 0)', CSS_COLOR_FLOOR_MODIFIED = '#CCCCFF';
    const CSS_COMMON = '.{CT} {color: rgb(30, 100, 220) !important;} .{CB} {color: rgb(0, 160, 0) !important; cursor: pointer !important; user-select: none;} .{CB}:hover {color: rgb(0, 100, 0) !important;} .{CB}:focus {color: rgb(0, 100, 0) !important;} .{CB}.{CD} {color: rgba(150, 150, 150) !important; cursor: not-allowed !important;}'.replaceAll('{CB}', CLASSNAME_BUTTON).replaceAll('{CT}', CLASSNAME_TEXT).replaceAll('{CD}', CLASSNAME_DISABLED)
	                 + '.{CAT}>ul {list-style: none; text-align: center; padding: 0px; margin: 0px;} .{CAT} {position: absolute; zIndex: 999; backgroundColor: #f5f5f5; float: left; clear: both; height: 180px; overflow-y: auto; overflow-x: visible;} .{CLI} {display: block; list-style: outside none none; margin: 0px; border: 1px solid rgb(204, 204, 204);} .{CLB} {border: 0px; width: 100%; height: 100%; cursor: pointer; padding: 0 0.5em;}'.replaceAll('{CAT}', CLASSNAME_LIST).replaceAll('{CLI}', CLASSNAME_LIST_ITEM).replaceAll('{CLB}', CLASSNAME_LIST_BUTTON)
	                 + '.tippy-box[data-theme~="wenku_tip"] {background-color: #f0f7ff;color: black;border: 1px solid #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="top"]>.tippy-arrow::before {border-top-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="left"]>.tippy-arrow::before {border-left-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="right"]>.tippy-arrow::before {border-right-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="bottom"]>.tippy-arrow::before {border-bottom-color: #a3bee8;}';
	const CSS_COMMONBEAUTIFIER = '.plus_cbty_image {position: fixed;top: 0;left: 0;z-index: -2;}.plus_cbty_cover {position: fixed;top: 0;left: calc((100vw - 960px) / 2);z-Index: -1;background-color: rgba(255,255,255,0.7);width: 960px;height: 100vh;}body {overflow: auto;}body>.main {position: relative;margin-left: 0;margin-right: 0;left: calc((100vw - 960px) / 2);}body.plus_cbty table.grid td, body.plus_cbty .odd, body.plus_cbty .even, body.plus_cbty .blockcontent {background-color: rgba(255,255,255,0) !important;}.textarea, .text {background-color: rgba(255,255,255,0.9);}#headlink{background-color: rgba(255,255,255,0.7);}';
	const CSS_REVIEWSHOW ='body {overflow: auto;background-image: url({BGI});}#content > table > tbody > tr > td {background-color: rgba(255,255,255,0.7) !important;overflow: auto;}body.plus_cbty #content > table > tbody > tr > td {background-color: rgba(255,255,255,0) !important;overflow: auto;}#content {height: 100vh;overflow: auto;}.m_top, .m_head, .main.nav, .m_foot {display: none;}.main {margin-top: 0px;}#content table div[style*="width:100%"]{font-size: calc(1em * {S}/ 100);line-height: calc(120% * {S}/ 100);}.jieqiQuote, .jieqiCode, .jieqiNote {font-size: inherit;}.{M}{background-color: {C}}'.replace('{M}', CLASSNAME_MODIFIED).replace('{C}', CSS_COLOR_FLOOR_MODIFIED);
	const CSS_NOVEL = 'html{background-image: url({BGI});}body {width: 100vw;height: 100vh;overflow: overlay;margin: 0px;background-color: rgba(255,255,255,0.7);}#contentmain {overflow-y: auto;height: calc(100vh - {H});max-width: 100%;min-width: 0px;max-width: 100vw;}#adv1, #adtop, #headlink, #footlink, #adbottom {overflow: overlay;min-width: 0px;max-width: 100vw;}#adv900, #adv5 {max-width: 100vw;}';
	const CSS_SIDEPANEL = '#sidepanel-panel {background-color: #00000000;z-index: 4000;}.sidepanel-button {font-size: 1vmin;color: #1E64DC;background-color: #FDFDFD;}.sidepanel-button:hover, .sidepanel-button.low-opacity:hover {opacity: 1;color: #FDFDFD;background-color: #1E64DC;}.sidepanel-button.low-opacity{opacity: 0.4 }.sidepanel-button>i[class^="fa-"] {line-height: 3vmin;width: 3vmin;}.sidepanel-button[class*="tooltip"]:hover::after {font-size: 0.9rem;top: calc((5vmin - 25px) / 2);}.sidepanel-button[class*="tooltip"]:hover::before {top: calc((5vmin - 12px) / 2);}.sidepanel-button.accept-pointer{pointer-events:auto;}';

	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_API_PACKSHOW_LOADING = '正在初始化下载页面,请稍候...';
	const TEXT_TIP_API_PACKSHOW_LOADED = '初始化下载页面成功';
	const TEXT_TIP_INDEX_LATERREADS = '文库首页显示前六本稍后再读书目</br>您可以在书架页面管理稍后阅读书目和调整书籍顺序';
	const TEXT_TIP_SEARCH_OPTION_TAG = '有关标签搜索</br></br>未完善-开发中…</br>官方尚未正式开放此功能</br>功能预览由[轻小说文库+]提供';
	const TEXT_TIP_REVIEW_BEAUTIFUL = '背景图片可以在"用户面板"中设置</br>您可以从文库首页左侧点击进入用户面板';
	const TEXT_TIP_REVIEW_IMG_INSERTURL = '直接插入网络图片的链接地址';
	const TEXT_TIP_REVIEW_IMG_SELECTIMG = '选择本地图片上传到第三方图床,然后再插入图床提供的图片链接</br>您也可以直接拖拽图片到输入框,或者Ctrl+V直接粘贴您剪贴板里面的图片</br>您可以在用户面板中切换图床</br></br>上传图片请遵守法律以及图床使用规定</br>请不要上传违规图片';
	const TEXT_TIP_IMAGE_FIT = '请选择适合您的屏幕宽高比的图片</br>您选择的图片将会被拉伸以适应屏幕的宽高比,图片宽高比与屏幕宽高比相差过大会导致图片扭曲</br>请避免选择文件大小过大的图片,以防止浏览器卡顿';
	const TEXT_TIP_IMAGER_DEFAULT = '</br></br><span class=\'{CT}\'>{N} 默认图床</span>'.replace('{N}', GM_info.script.name).replace('{CT}', CLASSNAME_TEXT);
	const TEXT_TIP_DOWNLOAD_BBCODE = 'BBCODE格式:</br>即文库评论的代码格式</br>相当于引用楼层时自动填入回复框的内容</br>保存为此格式可以保留排版及多媒体信息';
	const TEXT_TIP_ACCOUNT_NOACCOUNT = '没有储存的账号信息</br>请在登录页面手动登录一次,相关帐号信息就会自动储存</br></br>所有储存的账号信息都自动保存在浏览器的本地存储中';
	const TEXT_ALT_SCRIPT_ERROR_AJAX_FA = 'FontAwesome加载失败(自动重试也失败了),可能会影响一部分脚本界面图标和样式的展示,但基本不会影响功能</br>您可以将此消息<a href="https://greasyfork.org/scripts/416310/feedback" class=\'{CB}\'>反馈给开发者</a>以尝试解决问题'.replace('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_DOWNLOAD_BBCODE_NOCHANGE = '帖子正在下载中,请不要更改此设置!';
	const TEXT_ALT_DOWNLOADFINISH_REVIEW = '{T}({I}) 已下载完毕</br>{N} 已保存';
	const TEXT_ALT_DOWNLOADIMG_CONFIRM_TITLE = '确认下载';
	const TEXT_ALT_DOWNLOADIMG_CONFIRM_MESSAGE = '是否要下载 {N} 的全部插图?';
	const TEXT_ALT_DOWNLOADIMG_CONFIRM_OK = '下载';
	const TEXT_ALT_DOWNLOADIMG_CONFIRM_CANCEL = '取消';
	const TEXT_ALT_DOWNLOADIMG_STATUS_INDEX = '正在获取小说目录...';
	const TEXT_ALT_DOWNLOADIMG_STATUS_LOADING = '正在下载: {CCUR}/{CALL}';
	const TEXT_ALT_DOWNLOADIMG_STATUS_FINISH = '全部插图下载完毕:)';
	const TEXT_ALT_BOOK_AFTERBOOKS_ADDED = '已添加到稍后再读';
	const TEXT_ALT_BOOK_AFTERBOOKS_REMOVED = '已将其从稍后再读中移除';
	const TEXT_ALT_BOOKCASE_AFTERBOOKS_MISSING = '看起来这本书并不在稍后再读的列表里呢</br>是不是已经在其他的标签页里把它从稍后再读中移除了?';
	const TEXT_ALT_AUTOREFRESH_ON = '页面自动刷新已开启';
	const TEXT_ALT_AUTOREFRESH_OFF = '页面自动刷新已关闭';
	const TEXT_ALT_AUTOREFRESH_NOTLAST = '请先翻到最后一页再开启页面自动刷新</br><span class="{CB}">[点击这里翻到最后一页]</span>'.replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_AUTOREFRESH_WORKING = '正在获取新的回复...';
	const TEXT_ALT_AUTOREFRESH_NOMORE = '木有新的回复';
	const TEXT_ALT_AUTOREFRESH_APPLIED = '发现了新的回复,页面已更新~</br>'.replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_AUTOREFRESH_MODIFIED = '发现已有楼层内容变更,已对其进行了颜色标记</br>点击标记区域即可恢复原来的颜色';
	const TEXT_ALT_BEAUTIFUL_ON = '页面美化已开启</br>您可能需要刷新页面使其生效';
	const TEXT_ALT_BEAUTIFUL_OFF = '页面美化已关闭</br>您可能需要刷新页面使其生效';
	const TEXT_ALT_FAVORITE_LAST_ON = '将在点击收藏的帖子时打开最后一页';
	const TEXT_ALT_FAVORITE_LAST_OFF = '将在点击收藏的帖子时打开第一页';
	const TEXT_ALT_IMAGE_FORMATERROR = '很遗憾,您选择的图片格式无法识别</br>(建议选择jpeg,png)!';
	const TEXT_ALT_IMAGE_UPLOAD_WORKING = '正在上传图片…';
	const TEXT_ALT_IMAGE_DOWNLOAD_WORKING = '正在下载图片…';
	const TEXT_ALT_IMAGE_UPLOAD_SUCCESS = '图片上传成功!</br>文件名: {NAME}</br>URL: {URL}';
	const TEXT_ALT_IMAGE_DOWNLOAD_SUCCESS = '图片下载成功!</br>已经将背景图片 {NAME} 保存在本地';
	const TEXT_ALT_IMAGE_RESPONSE_NONAME = '空(服务器没有返回文件名)';
	const TEXT_ALT_IMAGE_UPLOAD_ERROR = '上传错误!';
	const TEXT_ALT_TEXTSCALE_CHANGED = '字体缩放已保存:{S}%';
	const TEXT_ALT_CONFIG_EXPORTED = '配置文件已导出</br>文件名:{N}';
	const TEXT_ALT_CONFIG_IMPORTED = '配置文件已导入';
	const TEXT_ALT_IMAGER_RESET = '由于{O}已失效,您的图床已自动切换到{N}';
	const TEXT_ALT_IMAGER_NOAVAILBLE = '{O}已失效';
	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><span class="{CB}">[单击此处以立即更新您的用户信息]</span>'.replaceAll('{CB}', CLASSNAME_BUTTON);
	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_ATRCMMDS_NOTASK = '木有要推荐的书目╮( ̄▽ ̄)╭';
	const TEXT_ALT_ATRCMMDS_NOTASK_OPENBC = '您还没有设置每日自动推荐的书目╮( ̄▽ ̄)╭</br><span class="{CB}">[点击此处打开书架页面进行设置]</span>'.replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_ATRCMMDS_NOTASK_PLSSET = '请在\'自动推书\'一栏设置每日推荐的书目及推荐次数';
	const TEXT_ALT_ATRCMMDS_MAXRCMMD = '根据您的头衔,您每日一共可以推荐{V}次';
	const TEXT_ALT_USRDTL_REFRESH = '{S}:正在更新用户信息({T})...'.replaceAll('{S}', GM_info.script.name).replaceAll('{T}', getTime());
	const TEXT_ALT_USRDTL_REFRESHED = '{S}:用户信息已更新</br><span class="{CB}">[点此查看详细信息]</span>'.replaceAll('{S}', GM_info.script.name).replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_POLYFILL = '<span class="{CT}">提示:正在使用移动端适配模式</span>'.replaceAll('{CT}', CLASSNAME_TEXT);
	const TEXT_ALT_LASTPAGE_LOADING = '正在获取最后一页,请稍候...';
	const TEXT_ALT_ACCOUNT_SWITCHED = '帐号已切换到 <i>"<span class="{CT}">{N}</span>"</i></br>3s后自动刷新页面</br><span class="{CB}">点击这里取消刷新</span>'.replaceAll('{CT}', CLASSNAME_TEXT).replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_ACCOUNT_WORKING_LOGOFF = '正在退出当前账号...';
	const TEXT_ALT_ACCOUNT_WORKING_LOGIN = '正在登录...';
	const TEXT_ALT_SCRIPT_UPDATE_CHECKING = '正在检查脚本更新...';
	const TEXT_ALT_SCRIPT_UPDATE_GOT = '<div class="{CT}">{SN} 有新版本啦!</br>新版本:{NV}</br>当前版本:{CV}</br><span id="script_update_info" class="{CB}">[点击此处 查看 更新]</span></br><span id="script_update_install" class="{CB}">[点击此处 安装 更新]</span></div>'.replaceAll('{CT}', CLASSNAME_TEXT).replaceAll('{CB}', CLASSNAME_BUTTON);
	const TEXT_ALT_SCRIPT_UPDATE_INFO = '更新信息';
	const TEXT_ALT_SCRIPT_UPDATE_NOINFO = '没有发现更新日志。。';
	const TEXT_ALT_SCRIPT_UPDATE_INSTALL = '安装';
	const TEXT_ALT_SCRIPT_UPDATE_CLOSE = '朕知道了';
	const TEXT_ALT_SCRIPT_UPDATE_NONE = '当前已是最新版本';
	const TEXT_ALT_DETAIL_IMPORTED = '配置导入成功';
	const TEXT_ALT_DETAIL_CONFIG_IMPORT_ERROR_SELECT = '您选择的文件不是配置文件,请检查后再试';
	const TEXT_ALT_DETAIL_CONFIG_IMPORT_ERROR_READ = '配置文件读取出错,请检查是否粘贴了正确的配置文件,以及配置文件是否损坏';
	const TEXT_ALT_DETAIL_MANAGE_NOTFOUND = '该记录已不存在,您是否已经在其他标签页删除它了呢?';
	const TEXT_GUI_API_ADDBOOKCASE_TOBOOKCASE = '进入书架';
	const TEXT_GUI_API_ADDBOOKCASE_REMOVE = '移出本书';
	const TEXT_GUI_API_PACKSHOW_TITLE_LOADING = '初始化下载界面...';
	const TEXT_GUI_API_PACKSHOW_TITLE = '{N} 轻小说TXT分卷下载 - 轻小说文库';
	const TEXT_GUI_UNKNOWN = '未知';
	const TEXT_GUI_DOWNLOAD_THISVOLUME = '下载本卷';
	const TEXT_GUI_DOWNLOAD_THISCHAPTER = '下载本章';
	const TEXT_GUI_NOVEL_FILLING = '</br><span class="{CT}">[轻小说文库+] 正在获取章节内容...</span>'.replaceAll('{CT}', CLASSNAME_TEXT);
	const TEXT_GUI_BOOK_IMAGESDOWNLOAD = '全部插图下载';
	const TEXT_GUI_BOOK_READITLATER = '稍后再读';
	const TEXT_GUI_BOOK_DONTREADLATER = '移出稍后再读';
	const TEXT_GUI_REVIEW_ADDFAVORITE = '收藏本帖:';
	const TEXT_GUI_REVIEW_FAVORADDED = '已收藏 {N}';
	const TEXT_GUI_REVIEW_FAVORDELED = '已从收藏中移除 {N}';
	const TEXT_GUI_REVIEW_BEAUTIFUL = '页面美化:';
	const TEXT_GUI_REVEIW_IMG_INSERTURL = '插入网图链接';
	const TEXT_GUI_REVEIW_IMG_SELECTIMG = '选择本地图片';
	const TEXT_GUI_REVIEW_UNLOCK_WARNING = '<span style="color: red;">仅供测试使用,请勿滥用此功能!</span>';
    const TEXT_GUI_DOWNLOAD_REVIEW = '[下载本帖(共A页)]';
    const TEXT_GUI_DOWNLOADING_REVIEW = '[下载中...(C/A)]';
	const TEXT_GUI_DOWNLOAD_BBCODE = '保存为BBCODE格式:';
    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_SDOWNLOAD_FILENAME = '{NovelName} {VolumeName}.{Extension}';
    const TEXT_GUI_DOWNLOADING_ALL = '下载中...(C/A)';
    const TEXT_GUI_DOWNLOADED_ALL = '下载图片(已完成)';
	const TEXT_GUI_AUTOREFRESH = '自动更新页面:';
	const TEXT_GUI_AUTOREFRESH_PAUSED = '(回复编辑中,暂停刷新)';
	const TEXT_GUI_AUTOSAVE = '(您输入的内容已保存到书评草稿中)';
	const TEXT_GUI_AUTOSAVE_CLEAR = '(草稿为空)';
	const TEXT_GUI_AUTOSAVE_RESTORE = '(已从书评草稿中恢复了您上次编辑的内容)';
	const TEXT_GUI_AREAREPLY_AT = '想用@提到谁?';
	const TEXT_GUI_INDEX_FAVORITES = '收藏的书评';
	const TEXT_GUI_INDEX_STATUS = '{S} 正在运行,版本 {V}。'.replace('{S}', GM_info.script.name).replace('{V}', GM_info.script.version);
	const TEXT_GUI_INDEX_LATERBOOKS = '稍后再读';
	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_NOTASK = '您还没有设置自动推书';
	const TEXT_GUI_BOOKCASE_RCMMDNW_CONFIRM = '今天已经推过书了,是否要再推一遍?';
	const TEXT_GUI_SEARCH_OPTION_TAG = '标签(preview)';
	const TEXT_GUI_DETAIL_TITLE_SETTINGS = '脚本设置';
	const TEXT_GUI_DETAIL_TITLE_BGI = '页面美化背景图片';
	const TEXT_GUI_DETAIL_DEFAULT_BGI = '点击选择图片 / 拖拽图片到此处 / Ctrl+V粘贴剪贴板中的图片';
	const TEXT_GUI_DETAIL_BGI = '当前图片:{N}';
	const TEXT_GUI_DETAIL_BGI_WORKING = '处理中...';
	const TEXT_GUI_DETAIL_BGI_UPLOADING = '正在上传: {NAME}';
	const TEXT_GUI_DETAIL_BGI_UPLOADFAILED = '{NAME}(上传失败,已本地保存)';
	const TEXT_GUI_DETAIL_BGI_DOWNLOADING = '正在下载: {NAME}';
	const TEXT_GUI_DETAIL_BGI_UPLOAD = '上传图片到图床以防止卡顿';
	const TEXT_GUI_DETAIL_BGI_LEGAL = '上传图片请遵守法律以及图床使用规定</br>请不要上传违规图片';
	const TEXT_GUI_DETAIL_GUI_IMAGER = '图床选择';
	const TEXT_GUI_DETAIL_GUI_SCALE = '书评字体缩放';
	const TEXT_GUI_DETAIL_BTF_NOVEL = '阅读页面美化';
	const TEXT_GUI_DETAIL_BTF_REVIEW = '书评页面美化';
	const TEXT_GUI_DETAIL_BTF_COMMON = '其他页面美化';
	const TEXT_GUI_DETAIL_FVR_LASTPAGE = '点击收藏的帖子时打开最后一页';
	const TEXT_GUI_DETAIL_VERSION_CURVER = '当前版本';
	const TEXT_GUI_DETAIL_VERSION_CHECKUPDATE = '检查更新';
	const TEXT_GUI_DETAIL_VERSION_CHECK = '点击此处检查更新';
	const TEXT_GUI_DETAIL_CONFIG_EXPORT = '导出所有脚本配置到文件(包含账号密码)';
	const TEXT_GUI_DETAIL_CONFIG_EXPORT_NOPASS = '导出所有脚本配置到文件(不包含账号密码)';
	const TEXT_GUI_DETAIL_EXPORT_CLICK = '点击导出';
	const TEXT_GUI_DETAIL_CONFIG_IMPORT = '从文件导入脚本配置';
	const TEXT_GUI_DETAIL_IMPORT_CLICK = '点击导入 / 拖拽配置文件到此处 / Ctrl+V粘贴剪贴板中的配置文件,并刷新页面';
	const TEXT_GUI_DETAIL_FEEDBACK_TITLE = '提出反馈';
	const TEXT_GUI_DETAIL_FEEDBACK = '点击打开反馈页面';
	const TEXT_GUI_DETAIL_UPDATEINFO_TITLE = '更新日志';
	const TEXT_GUI_DETAIL_UPDATEINFO = '点击去主页查看';
	const TEXT_GUI_DETAIL_CONFIG_MANAGE = '管理存储的信息';
	const TEXT_GUI_DETAIL_CONFIG_MANAGE_EMPTY = '<span style="color:grey;">没有内容</span>';
	const TEXT_GUI_DETAIL_CONFIG_MANAGE_MORE = '<span style="color:grey;">…</span>';
	const TEXT_GUI_DETAIL_MANAGE_CLICK = '点击打开管理页面';
	const TEXT_GUI_DETAIL_MANAGE_HEADER = '脚本储存管理';
	const TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_OPEN = '打开';
	const TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_NOTE = '备注';
	const TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_DELETE = '删除';
	const TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_TIP = '为{TITLE}设置备注: </br>备注将在主页鼠标经过此帖子收藏的链接时悬浮显示';
	const TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_TITLE = '编辑备注';
	const TEXT_GUI_DETAIL_MANAGE_FAV_DELETE_TIP = '确认将{TITLE}移除收藏?';
	const TEXT_GUI_DETAIL_MANAGE_FAV_DELETE_TITLE = '移除收藏';
	const TEXT_GUI_DETAIL_MANAGE_FAV_SAVED = '已保存';
	const TEXT_GUI_DETAIL_MANAGE_FAV_DELETED = '已删除';
	const TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_SELECT = '是否要将您粘贴的图片({N})中设置为页面美化背景图片?';
	const TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_PASTE = '是否要从您粘贴的配置文件({N})中导入配置?\n建议先备份您当前的配置,再导入新配置';
	const TEXT_GUI_BLOCK_TITLE_DEFULT = '操作区域';
	const TEXT_GUI_USER_REVIEWSEARCH = '用户书评';
	const TEXT_GUI_USER_USERINFO = '详细资料';
	const TEXT_GUI_LINK_TOLASTPAGE = '[打开尾页]';
	const TEXT_GUI_ACCOUNT_SWITCH = '切换账号:';
	const TEXT_GUI_ACCOUNT_CONFIRM = '是否要切换到帐号 "{N}"?';
	const TEXT_GUI_ACCOUNT_NOACCOUNT = '(帐号列表为空)';
	const TEXT_GUI_ACCOUNT_NOTLOGGEDIN = '(没有登录信息)';

	// 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]';

    // Arguments: level=LogLevel.Info, logContent, asObject=false
    // Needs one call "DoLog();" to get it initialized before using it!
    function DoLog() {
        // Global log levels set
        unsafeWindow.LogLevel = {
            None: 0,
            Error: 1,
            Success: 2,
            Warning: 3,
            Info: 4,
        }
        unsafeWindow.LogLevelMap = {};
        unsafeWindow.LogLevelMap[LogLevel.None]     = {prefix: ''          , color: 'color:#ffffff'}
        unsafeWindow.LogLevelMap[LogLevel.Error]    = {prefix: '[Error]'   , color: 'color:#ff0000'}
        unsafeWindow.LogLevelMap[LogLevel.Success]  = {prefix: '[Success]' , color: 'color:#00aa00'}
        unsafeWindow.LogLevelMap[LogLevel.Warning]  = {prefix: '[Warning]' , color: 'color:#ffa500'}
        unsafeWindow.LogLevelMap[LogLevel.Info]     = {prefix: '[Info]'    , color: 'color:#888888'}
        unsafeWindow.LogLevelMap[LogLevel.Elements] = {prefix: '[Elements]', color: 'color:#000000'}

        // Current log level
        DoLog.logLevel = (unsafeWindow ? unsafeWindow.isPY_DNG : window.isPY_DNG) ? LogLevel.Info : LogLevel.Warning; // Info Warning Success Error

        // Log counter
        DoLog.logCount === undefined && (DoLog.logCount = 0);
        if (++DoLog.logCount > 512) {
            console.clear();
            DoLog.logCount = 0;
        }

        // Get args
        let level, logContent, asObject;
        switch (arguments.length) {
            case 1:
                level = LogLevel.Info;
                logContent = arguments[0];
                asObject = false;
                break;
            case 2:
                level = arguments[0];
                logContent = arguments[1];
                asObject = false;
                break;
            case 3:
                level = arguments[0];
                logContent = arguments[1];
                asObject = arguments[2];
                break;
            default:
                level = LogLevel.Info;
                logContent = 'DoLog initialized.';
                asObject = false;
                break;
        }

        // Log when log level permits
        if (level <= DoLog.logLevel) {
            let msg = '%c' + LogLevelMap[level].prefix;
            let subst = LogLevelMap[level].color;

            if (asObject) {
                msg += ' %o';
            } else {
                switch(typeof(logContent)) {
                    case 'string': msg += ' %s'; break;
                    case 'number': msg += ' %d'; break;
                    case 'object': msg += ' %o'; break;
                }
            }

            console.log(msg, subst, logContent);
        }
    }
    DoLog();

	let tipready, CONFIG, TASK, DMode, SPanel, AndAPI
	let API
    main();

	// Main
	function main() {
		// Get tab url api part
		API = window.location.href.replace(/https?:\/\/www\.wenku8\.net\//, '').replace(/\?.*/, '').replace(/#.*/, '')
			.replace(/^book\/\d+\.html?/, 'book').replace(/novel\/(\d+\/?)+\.html?$/, 'novel')
			.replace(/^novel[\/\d]+index\.html?$/, 'novelindex');

		// Common actions
		loadinResourceCSS();
		loadinFontAwesome();
		polyfillAlert();
		tipready = tipcheck();
		tipscroll();
		addStyle(CSS_COMMON);
		GMXHRHook(NUMBER_MAX_XHR);
		CONFIG = new configManager();
		TASK = new taskManager();
		AndAPI = new AndroidAPI();
		//DMode = new Darkmode({autoMatchOsTheme: false});
		formSearch();
		linkReview();
		multiAccount();
		commonBeautify(API);
		SPanel = sideFunctions();
		unsafeWindow.alertify = alertify;
		alertify.set('notifier','position', 'top-right');

		if (isAPIPage()) {
			if (!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;
			// ReviewEdit page
			case 'modules/article/reviewedit.php':
				areaReply();
                pageReviewedit();
				break;
			// Bookcase page
			case 'modules/article/bookcase.php':
				pageBookcase();
				break;
			// Tags page
			case 'modules/article/tags.php':
				pageTags();
				break;
			// Mylink page
			case 'mylink.php':
				pageMylink();
				break;
			case 'userpage.php':
				pageUser();
				break;
			// Detail page
			case 'userdetail.php':
				pageDetail();
				break;
			// Index page
			case 'index.php':
				pageIndex();
				break;
			// Book page
			// Also: https://www.wenku8.net/modules/article/articleinfo.php?id={ID}&charset=gbk
			case 'modules/article/articleinfo.php':
			case 'book':
				pageBook();
				break;
			// Novel index page
			case 'novelindex':
				pageNovelIndex();
				break;
			// Novel page
			case 'novel':
				pageNovel();
				break;
			// Login page
			case 'login.php':
				pageLogin();
				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 = {

			// Check if recommend has done
			checkRcmmd: function() {
				const arConfig = CONFIG.AutoRecommend.getConfig();
				return arConfig.lasttime === getTime('-', false);
			},

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

				// Get config
				const arConfig = CONFIG.AutoRecommend.getConfig();

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

				// Config check: whether the recommend list is empty
				if (arConfig.allCount === 0) {
					const altBox = alertify.notify(
						/modules\/article\/bookcase\.php$/.test(location.href) ?
						TEXT_ALT_ATRCMMDS_NOTASK_PLSSET + (getMyUserDetail().userDetail ? '</br>'+TEXT_ALT_ATRCMMDS_MAXRCMMD.replace('{V}', String(getMyUserDetail().userDetail.vote)) : '') :
						TEXT_ALT_ATRCMMDS_NOTASK_OPENBC
					);
					altBox.callback = (isClicked) => {
						isClicked && window.open(URL_BOOKCASE);
					}
					return false;
				};

				// Recommend for each
				let recommended = {}, AM = new AsyncManager();
				AM.onfinish = allFinish;

				alertify.notify(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
					//alertify.notify(TEXT_ALT_ATRCMMDS_RUNNING.replaceAll('{BN}', bookName).replaceAll('{BID}', strBookID));

					// Go work
					for (i = 0; i < number; i++) {
						AM.add();
						getDocument(URL_RECOMMEND.replaceAll('{B}', strBookID), bookFinish,[book, strBookID, bookName]);
					}

					// Soft alert
					//alertify.notify(TEXT_ALT_ATRCMMDS_DONE.replaceAll('{BN}', bookName).replaceAll('{BID}', strBookID));
				}
				AM.finishEvent = true;
				return true;

				function bookFinish(oDoc, book, strBookID, bookName) {
					// title: "处理成功"
					const statusText = $(oDoc, '.blocktitle').innerText;
					// success: "我们已经记录了本次推荐,感谢您的参与!\n\n您每天拥有 5 次推荐权利,这是您今天第 1 次推荐。"
					// overflow: "\n错误原因:对不起,您今天已经用完了推荐的权利!\n\n您每天可以推荐 20 次。\n\n请 返 回 并修正"
					const returnText = $(oDoc, '.blockcontent').innerText.replace(/\s*\[.+\]\s*$/, '');

					// Save book
					book.lasttime = getTime('-', false);
					CONFIG.AutoRecommend.saveConfig(arConfig);

					// Log
					DoLog(statusText + '\n' + returnText);

					/*
					// Check status
					const success = /我们已经记录了本次推荐,感谢您的参与!\s*您每天拥有\s*(\d+)\s*次推荐权利,这是您今天第\s*(\d+)\s*次推荐。/;
					const overflow = /\s*错误原因:对不起,您今天已经用完了推荐的权利!\s*您每天可以推荐\s*(\d+)\s*次。\s*请\s*返\s*回\s*并修正/;
					*/
					const b = recommended[strBookID] = recommended[strBookID] || {name: bookName, strID: strBookID, count: 0};
					b.count++;
					AM.finish();
				}

				function allFinish() {
					// Save config
					arConfig.lasttime = getTime('-', false);
					CONFIG.AutoRecommend.saveConfig(arConfig);

					// Soft alert
					let text = [];
					for (const strBookID of Object.keys(recommended)) {
						const book = recommended[strBookID];
						text.push('[{BID}]{BN} 推荐了{C}次'.replaceAll('{C}', book.count).replaceAll('{BID}', book.strID).replaceAll('{BN}', book.name));
					}
					alertify.success(TEXT_ALT_ATRCMMDS_ALL_DONE.replaceAll('{R}', text.join('</br>')));
				}
			}
		}

		// Config Maintainer
		TM.Cleaner = {
			cleanPageStatus: function() {
				const config = CONFIG.BkReviewPrefs.getConfig();
				const history = config.history;
				let count = 0;
				for (const [rid, his] of Object.entries(history)) {
					if (!his.time || (new Date()).getTime() - his.time > 30*1000) {
						delete history[rid];
						count++;
					}
				}
				CONFIG.BkReviewPrefs.saveConfig(config);
				DoLog(count > 0 ? LogLevel.Success : LogLevel.Info, 'Review page status cleaned ({C})'.replace('{C}', count.toString()));
			},

			imagerFix: function() {
				const config = CONFIG.UserGlobalCfg.getConfig();
				const curimager = config.imager;

				// If imager does not exist or imager disabled, change it to default
				if (!DATA_IMAGERS[curimager] || !DATA_IMAGERS[curimager].available) {
					DoLog(LogLevel.Warning, 'Current imager unavailable, changing to default.');
					if (curimager !== DATA_IMAGERS.default && DATA_IMAGERS[DATA_IMAGERS.default].available) {
						// Default available
						config.imager = DATA_IMAGERS.default;
						DoLog(LogLevel.Success, 'Changed to default.');
					} else {
						// Default not available
						DoLog(LogLevel.Warning, 'Default imager unavailable, trying to find another imager for use. ')
						for (const [key, imager] of Object.entries(DATA_IMAGERS)) {
							if (imager.available) {
								config.imager = key;
								DoLog(LogLevel.Success, 'Changed to {K}.'.replace('{K}', key));
								break;
							}
						}

						if (config.imager === curimager) {
							// OMG, There's NO IMAGER AVAILABLE!!
							DoLog(LogLevel.Error, 'OMG, There\'s NO IMAGER AVAILABLE!!');
						}
					}

					CONFIG.UserGlobalCfg.saveConfig(config);
					alertify.warning((config.imager !== curimager ? TEXT_ALT_IMAGER_RESET : TEXT_ALT_IMAGER_NOAVAILBLE).replace('{O}', DATA_IMAGERS[curimager].name).replace('{N}', DATA_IMAGERS[config.imager].name));
				}
			},
		}

		// Script
		TM.Script = {
			// Check & Update to latest version of script
			update: function(force=false) {
				// Check for update once a day
				const scriptID = 416310;
				const config = CONFIG.GlobalConfig.getConfig();
				if (!force && config.scriptUpdate.lasttime === getTime('-', false)) {return false;}

				const GFU = new GreasyForkUpdater();
				alertify.notify(TEXT_ALT_SCRIPT_UPDATE_CHECKING);
				GFU.checkUpdate(scriptID, GM_info.script.version, function(update, updateurl, metaData) {
					if (update) {
						const box = alertify.notify(TEXT_ALT_SCRIPT_UPDATE_GOT.replaceAll('{SN}', metaData.name).replaceAll('{NV}', metaData.version).replaceAll('{CV}', GM_info.script.version));
						const btnInfo = $(box.element, '#script_update_info');
						const btnInstall = $(box.element, '#script_update_install');
						btnInfo.addEventListener('click', show);
						btnInstall.addEventListener('click', install);
					} else {
						alertify.message(TEXT_ALT_SCRIPT_UPDATE_NONE);
					}
					config.scriptUpdate.lasttime = getTime('-', false);
					CONFIG.GlobalConfig.saveConfig(config);

					function install(e) {
						location.href = updateurl;
					}

					function show(e) {
						const info = metaData.updateinfo;
						const box = alertify.confirm(info ? info : TEXT_ALT_SCRIPT_UPDATE_NOINFO, install);
						box.setHeader(TEXT_ALT_SCRIPT_UPDATE_INFO);
						box.set('labels', {ok: TEXT_ALT_SCRIPT_UPDATE_INSTALL, cancel: TEXT_ALT_SCRIPT_UPDATE_CLOSE});
						box.set('overflow', true);
					}
				});

				return true;
			}
		}

		TM.Script.update();
		TM.Cleaner.cleanPageStatus();
		TM.Cleaner.imagerFix();
		TM.UserDetail.refresh();
		TM.AutoRecommend.run();
	}

	// Config Manager
	// use 'new' keyword
	function configManager() {
		const CM = this;
		const [getValue, setValue, deleteValue, listValues] = [
			window.getValue    ? window.getValue    : GM_getValue,
			window.setValue    ? window.setValue    : GM_setValue,
			window.deleteValue ? window.deleteValue : GM_deleteValue,
			window.listValues  ? window.listValues  : GM_listValues,
		]

		CM.GlobalConfig = {
			saveConfig: function(config) {
				config ? config[KEY_CM_VERSION] = VALUE_CM_VERSION : function() {};
				setValue(KEY_CM, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					users: {},
					scriptUpdate: {
						lasttime: ''
					}
				};

				config = func ? func(config) : config;
				save ? CM.GlobalConfig.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = getValue(KEY_CM, null);
				config = config ? config : (init ? CM.GlobalConfig.initConfig(true, init) : CM.GlobalConfig.initConfig());
				return config;
			},

			// Review config upgrade (Uses GM_functions)
			upgradeConfig: function() {
				// Get version
				const default_self = {}; default_self[KEY_CM_VERSION] = '0.1'; // v0.1 has no self object
				const self = GM_getValue(KEY_CM, default_self);
				const version = self[KEY_CM_VERSION];

				// Upgrade by version
				if (self[KEY_CM_VERSION] === VALUE_CM_VERSION) {DoLog(LogLevel.Info, 'Config Manager self config is in latest version. ');};
				switch(version) {
					case '0.1':
						v01_To_v02();
						v02_To_v03();
						logUpgrade();
						break;
					case '0.2':
						v02_To_v03();
						logUpgrade();
						break;
				}

				// Save to global gm_storage
				self[KEY_CM_VERSION] = VALUE_CM_VERSION;
				setValue(KEY_CM, self);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'Config Manager self config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', version).replaceAll('{V2}', VALUE_CM_VERSION));
				}

				function v01_To_v02() {
					const props = GM_listValues();
					const userStorage = {};
					for (const prop of props) {
						userStorage[prop] = GM_getValue(prop);
					}
					const userID = getUserID();
					userID ? GM_setValue(userID, userStorage) : GM_setValue('temp', userStorage);
					for (const prop of props) {
						GM_deleteValue(prop);
					}
				}

				function v02_To_v03() {
					self.scriptUpdate = self.scriptUpdate ? self.scriptUpdate : {lasttime: ''};
				}
			},

			// Redirect global gm_storage to user's storage area (Uses GM_functions)
			// callback(key)
			redirectToUser: function (callback) {
				// Get userID from cookies
				const userID = getUserID();

				if (userID) {
					// delete temp data if exist
					GM_deleteValue('temp');

					// Save lastUserID
					const config = CM.GlobalConfig.getConfig();
					config.lastUserID = userID;
					CM.GlobalConfig.saveConfig(config);

					// Redirect to user storage area
					redirectGMStorage(userID);
					DoLog(LogLevel.Info, 'GM_storage redirected to ' + String(userID));
				} else {
					// Redirect to temp storage area before request finish
					const lastUserID = CM.GlobalConfig.getConfig().lastUserID;
					redirectTemp(lastUserID);

					// Request userID
					getMyUserDetail((userDetail)=>{
						const key = userDetail.userDetail.userID;

						// Move temp data to user storage area
						redirectGMStorage();
						const tempStorage = GM_getValue('temp');
						GM_setValue(lastUserID ? lastUserID : key, tempStorage);
						GM_deleteValue('temp');

						// Save lastUserID
						const config = CM.GlobalConfig.getConfig();
						config.lastUserID = key;
						CM.GlobalConfig.saveConfig(config);

						// Redirect to user storage area
						redirectGMStorage(key);
						DoLog(LogLevel.Info, 'GM_storage redirected to ' + String(key));

						// callback
						callback ? callback(key) : function() {};
					})
				}

				// When userID request not finished, use 'temp' as gm_storage key
				function redirectTemp(lastUserID) {
					if (lastUserID) {
						// Copy config of the user we use last time to 'temp' storage area
						const lastUser = GM_getValue(lastUserID, {});
						GM_setValue('temp', lastUser);
					}
					redirectGMStorage('temp');
					DoLog(LogLevel.Info, 'GM_storage redirected to temp');
				}
			}
		}

		CM.GlobalConfig.upgradeConfig();
		CM.GlobalConfig.redirectToUser();

		CM.AutoRecommend = {
			saveConfig: function(config) {
				config ? config[KEY_ATRCMMDS_VERSION] = VALUE_ATRCMMDS_VERSION : function() {};
				GM_setValue(KEY_ATRCMMDS, config);
			},

			initConfig: function(save=true, func) {
				let config = {};
				config[KEY_ATRCMMDS_VERSION] = VALUE_ATRCMMDS_VERSION;
				config.allCount = 0;
				config.books = {};
				config.auto = true;

				config = func ? func(config) : config;
				save ? CM.AutoRecommend.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_ATRCMMDS, null);
				config = config ? config : (init ? CM.AutoRecommend.initConfig(true, init) : CM.AutoRecommend.initConfig());
				return config;
			},

			// Auto-recommend config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.AutoRecommend.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[KEY_ATRCMMDS_VERSION]) {
					case '0.1':
						config.auto = true;
						logUpgrade();
						break;
					case VALUE_ATRCMMDS_VERSION:
						DoLog(LogLevel.Info, 'Auto-recommend config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for Auto-recommend. '.replace('{V}', config[KEY_ATRCMMDS_VERSION]));
				}

				// Save to gm_storage
				CM.AutoRecommend.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'Auto-recommend config successfully upgraded From v{V1} to {V2}. '.replaceAll('{V1}', config[KEY_ATRCMMDS_VERSION]).replaceAll('{V2}', VALUE_ATRCMMDS_VERSION));
				}
			}
		}

		CM.commentDrafts = {
			saveConfig: function(config) {
				config ? config[KEY_DRAFT_VERSION] = VALUE_DRAFT_VERSION : function() {};
				GM_setValue(KEY_DRAFT_DRAFTS, config);
			},

			initConfig: function(save=true, func) {
				let config = {};

				config = func ? func(config) : config;
				save ? CM.commentDrafts.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_DRAFT_DRAFTS, null);
				config = config ? config : (init ? CM.commentDrafts.initConfig(true, init) : CM.commentDrafts.initConfig());
				return config;
			},

			// Comment-drafts config upgrade
			upgradeConfig: function() {
				// Get config
				let config = CM.commentDrafts.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[KEY_DRAFT_VERSION]) {
					case '0.1':
					case undefined:
						v01_To_v02();
						logUpgrade();
						break;
					case VALUE_DRAFT_VERSION:
						DoLog(LogLevel.Info, 'comment-drafts config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for comment-drafts. '.replace('{V}', config[KEY_DRAFT_VERSION]));
				}

				// Save to gm_storage
				CM.commentDrafts.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'comment-drafts config successfully upgraded From v{V1} to {V2}. '.replaceAll('{V1}', config[KEY_DRAFT_VERSION]).replaceAll('{V2}', VALUE_DRAFT_VERSION));
				}

				function v01_To_v02() {
					// Fix bug caused bookcase's config overwriting comment-drafts' config
					if (config instanceof Array) {
						config = {};
					}
				}
			}
		}

		CM.bookcasePrefs = {
			saveConfig: function(config) {
				config ? config[KEY_BOOKCASE_VERSION] = VALUE_BOOKCASE_VERSION : function() {};
				GM_setValue(KEY_BOOKCASES, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					bookcases: [],
					laterbooks: {
						sortby: 'addTime_old2new',
						books: {}
					}
				};

				config = func ? func(config) : config;
				save ? CM.bookcasePrefs.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_BOOKCASES, null);
				config = config ? config : (init ? CM.bookcasePrefs.initConfig(true, init) : CM.bookcasePrefs.initConfig());
				return config;
			},

			// Bookcase config upgrade
			upgradeConfig: function() {
				// Get config
				let config = CM.bookcasePrefs.getConfig();

				// if not inited
				if (!config) {return;};

				// Original version
				let V = config && config[KEY_BOOKCASE_VERSION] ? config[KEY_BOOKCASE_VERSION] : '0';

				switch (V) {
					case '0.1':
					case undefined:
					case '0':
						v01_To_v02();
						v02_To_v03();
						v03_To_v04();
						logUpgrade();
						break;
					case '0.2':
						v01_To_v02();
						v02_To_v03();
						v03_To_v04();
						logUpgrade();
						break;
					case '0.3':
						v01_To_v02();
						v02_To_v03();
						v03_To_v04();
						logUpgrade();
						break;
					case VALUE_BOOKCASE_VERSION:
						DoLog(LogLevel.Info, 'Bookcase config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for Bookcase. '.replace('{V}', config[KEY_BOOKCASE_VERSION]));
				}

				// Save to gm_storage
				CM.bookcasePrefs.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'Bookcase config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', V).replaceAll('{V2}', VALUE_BOOKCASE_VERSION));
				}

				function v01_To_v02() {
					// Clear useless key added falsely
					delete config.bbcode;

					// Convert array to an object
					if (Array.isArray(config)) {
						const newConfig = {bookcases: []};
						for (let i = 0; i < config.length; i++) {
							newConfig.bookcases[i] = config[i];
						}
						config = newConfig;
					}
				}

				function v02_To_v03() {
					// Fix bug caused config.bookcases equals to []
					if (config && config.bookcases && config.bookcases.length === 0) {
						config = CM.bookcasePrefs.initConfig();
					}
				}

				function v03_To_v04() {
					if (config.laterbooks) {return false;}
					config.laterbooks = {
						sortby: 'addTime_old2new',
						books: {}
					};
				}
			}
		}

		CM.userDtlePrefs = {
			saveConfig: function(config) {
				config ? config[KEY_USRDETAIL_VERSION] = VALUE_USRDETAIL_VERSION : function() {};
				GM_setValue(KEY_USRDETAIL, config);
			},

			initConfig: function(save=true, func) {
				let config = {userDetail: null};

				config = func ? func(config) : config;
				save ? CM.userDtlePrefs.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_USRDETAIL, null);
				config = config ? config : (init ? CM.userDtlePrefs.initConfig(true, init) : CM.userDtlePrefs.initConfig());
				return config;
			},

			// userDetail config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.userDtlePrefs.getConfig();

				// if not inited
				if (!config) {return;};

				// Original version
				let V = config && config[KEY_BOOKCASE_VERSION] ? config[KEY_BOOKCASE_VERSION] : '0';

				switch (V) {
					case '0.1':
						refreshMyUserDetail(logUpgrade);
						break;
					case VALUE_USRDETAIL_VERSION:
						DoLog(LogLevel.Info, 'User-detail config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for User-detail. '.replace('{V}', V));
				}

				// Save to gm_storage
				CM.userDtlePrefs.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'User-detail config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', V).replaceAll('{V2}', VALUE_USRDETAIL_VERSION));
				}
			}
		}

		CM.BkReviewPrefs = {
			saveConfig: function(config) {
				config ? config[KEY_REVIEW_VERSION] = VALUE_REVIEW_VERSION : function() {};
				GM_setValue(KEY_REVIEW_PREFS, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					bbcode: false,
					autoRefresh: false,
					beautiful: true,
					backgroundImage: 'https://img12.360buyimg.com/ddimg/jfs/t1/197476/22/6462/3478996/613227a8E03e8ffc3/99970183ddb9f896.jpg',
					favorites: {
						228884: {
							name: '文库导航姬',
							href: 'https://www.wenku8.net/modules/article/reviewshow.php?rid=228884',
							tiptitle: '梦想成为书评区大水怪的可以来康康'
						}
					},
					favorlast: false,
                    history: {}
				};

				config = func ? func(config) : config;
				save ? CM.BkReviewPrefs.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_REVIEW_PREFS, null);
				config = config ? config : (init ? CM.BkReviewPrefs.initConfig(true, init) : CM.BkReviewPrefs.initConfig());
				return config;
			},

			// Review config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.BkReviewPrefs.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[KEY_REVIEW_VERSION]) {
					case '0.1':
						v01_To_v02();
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.2':
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.3':
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
                    case '0.4':
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.5':
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.6':
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.7':
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.8':
						v08_To_v09();
						logUpgrade();
						break;
					case VALUE_REVIEW_VERSION:
						DoLog(LogLevel.Info, 'Review config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for Review. '.replace('{V}', config[KEY_REVIEW_VERSION]));
				}

				// Save to gm_storage
				CM.BkReviewPrefs.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'Review config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', config[KEY_REVIEW_VERSION]).replaceAll('{V2}', VALUE_REVIEW_VERSION));
				}

				function v01_To_v02() {
					config.autoRefresh = false;
					delete config.downloading;
				}

				function v02_To_v03() {
					config.favorites = {
						228884: {
							name: '文库导航姬',
							href: 'https://www.wenku8.net/modules/article/reviewshow.php?rid=228884',
							tiptitle: '梦想成为书评区大水怪的可以来康康'
						}
					}
				}

				function v03_To_v04() {
					if (config.favorites) {return;};
					config.favorites = {
						228884: {
							name: '文库导航姬',
							href: 'https://www.wenku8.net/modules/article/reviewshow.php?rid=228884',
							tiptitle: '梦想成为书评区大水怪的可以来康康'
						}
					};
				}

                function v04_To_v05() {
                    if (config.history) {return;};
                    config.history = {};
                }

				function v05_To_v06() {
					if (config.beautiful !== undefined) {return;};
                    config.beautiful = true;
					config.backgroundImage = 'https://img12.360buyimg.com/ddimg/jfs/t1/197476/22/6462/3478996/613227a8E03e8ffc3/99970183ddb9f896.jpg';
				}

				function v06_To_v07() {
					// Move CM.BkReviewPrefs.upgradeConfig.beautiful to CM.BeautifierCfg
					if (config.beautiful === undefined) {return;};
					const beautifierConfig = {
						reviewshow: {
							beautiful: config.beautiful,
							backgroundImage: config.backgroundImage
						}
					}
					CM.BeautifierCfg.saveConfig(beautifierConfig);

                    delete config.beautiful;
					delete config.backgroundImage;
				}

				function v07_To_v08() {
					// Move CM.BkReviewPrefs.upgradeConfig.beautiful to CM.BeautifierCfg
					if (config.favorlast !== undefined) {return;};
					config.favorlast = false;
					for (const [rid, favorite] of Object.entries(config.favorites)) {
						config.favorites[rid] = {
							name: favorite.name,
							href: favorite.href.replace(/&page=1$/, ''),
							tiptitle: favorite.tiptitle
						};
					}
				}

				function v08_To_v09() {
					// Fill all favorite bookreviews' tiptitle using null for those don't have
					config.favorlast = false;
					for (const [rid, favorite] of Object.entries(config.favorites)) {
						!favorite.tiptitle && (favorite.tiptitle = null);
					}
				}
			}
		}

		CM.BeautifierCfg = {
			saveConfig: function(config) {
				config ? config[KEY_BEAUTIFIER_VERSION] = VALUE_BEAUTIFIER_VERSION : function() {};
				GM_setValue(KEY_BEAUTIFIER, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					upload: false,
					reviewshow: {
						beautiful: true,
					},
					novel: {
						beautiful: true,
					},
					common: {
						beautiful: false,
					},
					backgroundImage: 'https://img12.360buyimg.com/ddimg/jfs/t1/197476/22/6462/3478996/613227a8E03e8ffc3/99970183ddb9f896.jpg',
					bgiName: '默认背景图片 - Pixiv ID: 88913164',
					textScale: 100
				};

				config = func ? func(config) : config;
				save ? CM.BeautifierCfg.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_BEAUTIFIER, null);
				config = config ? config : (init ? CM.BeautifierCfg.initConfig(true, init) : CM.BeautifierCfg.initConfig());
				return config;
			},

			// Beautifier config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.BeautifierCfg.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[KEY_BEAUTIFIER_VERSION]) {
					/*case '0.1':
						v01_To_v02();
						break;*/
					case VALUE_BEAUTIFIER_VERSION:
						DoLog(LogLevel.Info, 'Beautifier config is in latest version. ');
						break;
					case '0.1':
						v01_To_v02();
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.2':
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.3':
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.4':
						v04_To_v05();
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.5':
						v05_To_v06();
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.6':
						v06_To_v07();
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.7':
						v07_To_v08();
						v08_To_v09();
						logUpgrade();
						break;
					case '0.8':
						v08_To_v09();
						logUpgrade();
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for Beautifier. '.replace('{V}', config[KEY_BEAUTIFIER_VERSION]));
				}

				// Save to gm_storage
				CM.BeautifierCfg.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'Beautifier config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', config[KEY_BEAUTIFIER_VERSION]).replaceAll('{V2}', VALUE_BEAUTIFIER_VERSION));
				}

				function v01_To_v02() {
					if (config.upload !== undefined) {return false;};
					config.upload = false;
				}

				function v02_To_v03() {
					if (config.reviewshow.bgiName !== undefined) {return false;};
					config.reviewshow.bgiName = 'image.jpeg';
				}

				function v03_To_v04() {
					if (config.textScale !== undefined) {return false;};
					config.textScale = 100;
				}

				function v04_To_v05() {
					if (config.novel !== undefined) {return false;};
					config.novel = {
						beautiful: true
					};
				}

				function v05_To_v06() {
					if (!config.textScale) {config.textScale = 100;};
					if (!config.novel) {config.novel = {beautiful: true};};
				}

				function v06_To_v07() {
					config.backgroundImage = config.reviewshow.backgroundImage;
					config.bgiName = config.reviewshow.bgiName;
					delete config.reviewshow.backgroundImage;
					delete config.reviewshow.bgiName;
				}

				function v07_To_v08() {
					if (config.common) {return false;}
					config.common = {
						beautiful: false
					};
				}

				function v08_To_v09() {
					if (config.common) {return false;}
					config.common = {
						beautiful: false
					};
				}
			}
		}

		CM.UserGlobalCfg = {
			saveConfig: function(config) {
				config ? config[KEY_USERGLOBAL_VERSION] = VALUE_USERGLOBAL_VERSION : function() {};
				GM_setValue(KEY_USERGLOBAL, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					imager: DATA_IMAGERS.default
				};

				config = func ? func(config) : config;
				save ? CM.UserGlobalCfg.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(KEY_USERGLOBAL, null);
				config = config ? config : (init ? CM.UserGlobalCfg.initConfig(true, init) : CM.UserGlobalCfg.initConfig());
				return config;
			},

			// Beautifier config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.UserGlobalCfg.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[KEY_USERGLOBAL_VERSION]) {
					//case '0.1':
					//	v01_To_v02();
					//	logUpgrade();
					//	break;
					case VALUE_USERGLOBAL_VERSION:
						DoLog(LogLevel.Info, 'UserGlobal config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for UserGlobalCfg. '.replace('{V}', config[KEY_USERGLOBAL_VERSION]));
				}

				// Save to gm_storage
				CM.UserGlobalCfg.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'UserGlobal config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', config[KEY_USERGLOBAL_VERSION]).replaceAll('{V2}', VALUE_USERGLOBAL_VERSION));
				}

				//function v#BEFORE_To_v#AFTER() {
				//	if (config.#NEWPROP !== undefined) {return false;};
				//	config.#NEWPROP = #DEFAULTVALUE;
				//}
			}
		}

		// New Config Item Template
		/*CM.#NEWCONFIGNAME = {
			saveConfig: function(config) {
				config ? config[#KEY_NEWCONFIG_VERSION] = #VALUE_NEWCONFIG_VERSION : function() {};
				GM_setValue(#KEY_NEWCONFIG, config);
			},

			initConfig: function(save=true, func) {
				let config = {
					#key: #value,
					#key: #value
				};

				config = func ? func(config) : config;
				save ? CM.#NEWCONFIGNAME.saveConfig(config) : function() {};
				return config;
			},

			getConfig: function(init) {
				let config = GM_getValue(#KEY_NEWCONFIG, null);
				config = config ? config : (init ? CM.#NEWCONFIGNAME.initConfig(true, init) : CM.#NEWCONFIGNAME.initConfig());
				return config;
			},

			// Beautifier config upgrade
			upgradeConfig: function() {
				// Get config
				const config = CM.#NEWCONFIGNAME.getConfig();

				// if not inited
				if (!config) {return;};

				switch (config[#KEY_NEWCONFIG_VERSION]) {
					//case '0.1':
					//	v01_To_v02();
					//	logUpgrade();
					//	break;
					case #VALUE_NEWCONFIG_VERSION:
						DoLog(LogLevel.Info, '#NEWCONFIGNAME config is in latest version. ');
						break;
					default:
						DoLog(LogLevel.Error, 'configCheckUpgrade: Invalid config version({V}) for #NEWCONFIGNAME. '.replace('{V}', config[#KEY_NEWCONFIG_VERSION]));
				}

				// Save to gm_storage
				CM.#NEWCONFIGNAME.saveConfig(config);

				function logUpgrade() {
					DoLog(LogLevel.Success, '#NEWCONFIGNAME config successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', config[#KEY_NEWCONFIG_VERSION]).replaceAll('{V2}', #VALUE_NEWCONFIG_VERSION));
				}

				//function v#BEFORE_To_v#AFTER() {
				//	if (config.#NEWPROP !== undefined) {return false;};
				//	config.#NEWPROP = #DEFAULTVALUE;
				//}
			}
		}*/

		CM.AutoRecommend.upgradeConfig();
		CM.commentDrafts.upgradeConfig();
		CM.bookcasePrefs.upgradeConfig();
		CM.userDtlePrefs.upgradeConfig();
		CM.BkReviewPrefs.upgradeConfig();
		CM.BeautifierCfg.upgradeConfig();
		CM.UserGlobalCfg.upgradeConfig();
		//CM.#NEWCONFIGNAME.upgradeConfig();
	}

	// Beautifier for all wenku pages
	function commonBeautify(API) {
		// No beautifier on exluded pages
		const excludes = ['novel']
		if (excludes.includes(API)) {return false;}

		// No beatifier if user does not want
		if (!CONFIG.BeautifierCfg.getConfig().common.beautiful) {return false;}

		const img = $CrE('img');
		img.src = CONFIG.BeautifierCfg.getConfig().backgroundImage;
		img.classList.add('plus_cbty_image');
		document.body.appendChild(img);

		const cover = $CrE('div');
		cover.classList.add('plus_cbty_cover');
		document.body.appendChild(cover);

		document.body.classList.add('plus_cbty');
		addStyle(CSS_COMMONBEAUTIFIER, 'plus_commonbeautifier')
		return true;
	}

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

		// Provide meta info copy
		metaCopy();

		// Provide read-later button
		laterReads();

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

		// Provide images download
		imagesDownload();

		// Provide tag search
		tagOption();

        // Ctrl+Enter comment submit
        areaReply();

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

			function collectElements() {
				const elements = pageResource.elements;
				elements.content = $('#content');
				elements.bookMain = $(elements.content, 'div');
				elements.header = $(elements.content, 'div>table');
				elements.titleContainer = $(elements.header, 'table td>span');
				elements.bookName = $(elements.header, 'b');
				elements.recommend = $(elements.content, 'a[href^="https://www.wenku8.net/modules/article/uservote.php"]');
				elements.metaContainer = $(elements.header, 'tr+tr');
				elements.metas = $All(elements.metaContainer, 'td');
				elements.info = $(elements.bookMain, 'div+table');
				elements.cover = $(elements.info, 'img');
				elements.infoText = $(elements.info, 'td+td');
				elements.notice = $All(elements.infoText, 'span.hottext>b');
				elements.tags = elements.notice.length > 1 ? elements.notice[0] : null;
				elements.notice = elements.notice[elements.notice.length-1];
				elements.introduce = $All(elements.infoText, 'span');
				elements.introduce = elements.introduce[elements.introduce.length-1];
				elements.downloadContainer = $(pageResource.elements.bookMain, 'div>fieldset');
				elements.downloadPanel = elements.downloadContainer ? elements.downloadContainer.parentElement : null;
			}

			function collectInfos() {
				const info = pageResource.info;
				const elements = pageResource.elements;
				info.bookName = elements.bookName.innerText;
				info.BID = Number(getUrlArgv('id') || 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.cover = elements.cover.src;
				info.dlEnabled = $(elements.content, '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, '.'+CLASSNAME_BUTTON);
				copyBtn.addEventListener('click', function() {
					copyText(value);
					showtip(TEXT_TIP_COPIED);
					alertify.message(TEXT_ALT_META_COPIED.replaceAll('{M}', value));
				});

				settip(copyBtn, TEXT_TIP_COPY);
			}
		}

		// Add to later-reads
		function laterReads() {
			// Make button
			let btn = installBtn(makeBtn(inAfterbooks() ? 'remove' : 'add'));

			// Update book info if in list
			inAfterbooks() && add(false);

			function add(alt=true) {
				// Add to config
				const config = CONFIG.bookcasePrefs.getConfig();
				config.laterbooks.books[pageResource.info.BID] = {
					sort: Object.keys(config.laterbooks.books).length + 1,
					addTime: new Date().getTime(),
					name: pageResource.info.bookName,
					aid: pageResource.info.BID,
					metas: pageResource.info.metas,
					tags: pageResource.info.tags,
					introduce: pageResource.info.introduce,
					cover: pageResource.info.cover
				};
				CONFIG.bookcasePrefs.saveConfig(config);

				// New button
				removeBtn(btn);
				btn = installBtn(makeBtn('remove'));

				// Soft alert
				alt && alertify.success(TEXT_ALT_BOOK_AFTERBOOKS_ADDED);
			}

			function remove() {
				// Remove from config
				const config = CONFIG.bookcasePrefs.getConfig();
				const books = config.laterbooks.books;
				if (!books[pageResource.info.BID]) {return false;}
				delete books[pageResource.info.BID];
				CONFIG.bookcasePrefs.saveConfig(config);

				// New button
				removeBtn(btn);
				btn = installBtn(makeBtn('add'));

				// Soft alert
				alertify.success(TEXT_ALT_BOOK_AFTERBOOKS_REMOVED);
			}

			function makeBtn(type='add') {
				const btn = $CrE('span');
				btn.classList.add(CLASSNAME_BUTTON);
				switch (type) {
					case 'add':
						btn.innerHTML = TEXT_GUI_BOOK_READITLATER;
						btn.addEventListener('click', add);
						break;
					case 'remove':
						btn.innerHTML = TEXT_GUI_BOOK_DONTREADLATER;
						btn.addEventListener('click', remove);
						break;
				}
				return btn;
			}

			function installBtn(btn) {
				pageResource.elements.recommend.previousElementSibling.insertAdjacentElement('afterend', btn);
				btn.insertAdjacentText('beforebegin', '[');
				btn.insertAdjacentText('afterend', ']');
				return btn;
			}

			function removeBtn(btn) {
				const parent = btn.parentElement;
				for (const node of [btn.previousSibling, btn, btn.nextSibling]) {
					parent.removeChild(node);
				}
				return btn;
			}

			function inAfterbooks() {
				return CONFIG.bookcasePrefs.getConfig().laterbooks.books[pageResource.info.BID] ? true : false;
			}
		}

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

			// Download panel
			// Create panel
			let div = $CrE('div');
			pageResource.elements.bookMain.appendChild(div);
			div.outerHTML = HTML_DOWNLOAD_LINKS
				.replaceAll('{ORIBOOKNAME}', pageResource.info.bookName)
				.replaceAll('{BOOKID}', String(pageResource.info.BID))

			// Use about:blank instead of direct url; aims to aviod unnecessary web requests
			const container = pageResource.elements.downloadContainer = $(pageResource.elements.bookMain, 'div>fieldset');
			div = pageResource.elements.downloadPanel = container.parentElement;
			for (const a of $All(container, 'div>a')) {
				//a.addEventListener('click', openDlPage);
			}

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

			function openDlPage(e) {
				e.preventDefault();

				const url = e.target.href;
				const win = window.open('https://www.wenku8.net/');
				win.history.replaceState({...win.history.state}, '', url);
			}
		}

		// All images downloader
		function imagesDownload() {
			const container = pageResource.elements.downloadContainer;
			const divImage = $CrE('div'), a = $CrE('a');
			divImage.setAttribute('style', 'width:164px; float:left; text-align:center');
			a.href = 'javascript:void(0);';
			a.innerHTML = TEXT_GUI_BOOK_IMAGESDOWNLOAD;
			a.addEventListener('click', confirm);
			divImage.appendChild(a);
			container.appendChild(divImage);
			for (const div of $All(container, 'div')) {
				div.style.width = '164px';
			}

			function confirm() {
				const title = TEXT_ALT_DOWNLOADIMG_CONFIRM_TITLE;
				const message = TEXT_ALT_DOWNLOADIMG_CONFIRM_MESSAGE.replace('{N}', pageResource.info.bookName);
				const ok = TEXT_ALT_DOWNLOADIMG_CONFIRM_OK;
				const cancel = TEXT_ALT_DOWNLOADIMG_CONFIRM_CANCEL;
				alertify.confirm(title, message, download, function() {/* oncancel */}).set('labels', {ok: ok, cancel: cancel});
			}

			function download() {
				// GUI
				const delay = alertify.get('notifier','delay');
				alertify.set('notifier','delay', 0);

				let finished = false, CAll, CCur = 0;
				const AM = new AsyncManager();
				AM.onfinish = downloadFinish;
				const box = alertify.message(TEXT_ALT_DOWNLOADIMG_STATUS_INDEX);
				box.ondismiss = function() {return finished;}

				// Start download
				AM.add()
				AndAPI.getNovelIndex({
					aid: pageResource.info.BID,
					lang: 0,
					callback: function(xml) {
						const allChapters = $All(xml, 'chapter');
						const chapters = Array.prototype.filter.call(allChapters, (c) => (c.firstChild.nodeValue.includes('插图')));
						CAll = chapters.length;
						box.setContent(TEXT_ALT_DOWNLOADIMG_STATUS_LOADING.replace('{CCUR}', CCur).replace('{CALL}', CAll));
						for (const chapter of chapters) {
							AM.add();
							getChapter(chapter.getAttribute('cid'), chapter.parentNode);
						}
						AM.finish();
					}
				});
				AM.finishEvent = true;

				function getChapter(cid, volume) {
					AndAPI.getNovelContent({
						aid: pageResource.info.BID,
						cid: cid,
						lang: 0,
						callback: getImgs,
						args: [volume]
					});

					function getImgs(str, volume) {
						const imgs = str.match(/<!--image-->https?:[^<>]+<!--image-->/g);
						const len = imgs.length.toString().length;
						const CAM = new AsyncManager();
						CAM.onfinish = chapterFinish;

						for (let i = 0; i < imgs.length; i++) {
							const img = imgs[i];
							const src = img.match(/<!--image-->(https?:[^<>]+)<!--image-->/)[1];
							const ext = src.match(/\.(\w+)$/) ? src.match(/\.(\w+)$/)[1] : 'jpg';
							const filename = pageResource.info.bookName + '_' + volume.firstChild.nodeValue + ' ' + ['插图', '插圖'][getLang()] + '_' + fillNumber(i+1, len) + '.' + ext;
							CAM.add();
							downloadFile({
								url: src,
								name: filename,
								onload: function() {
									CAM.finish();
								}
							});
						}
						CAM.finishEvent = true;

						function chapterFinish() {
							AM.finish();
							box.setContent(TEXT_ALT_DOWNLOADIMG_STATUS_LOADING.replace('{CCUR}', ++CCur).replace('{CALL}', CAll));
						}
					}
				}

				function downloadFinish() {
					finished = true;
					alertify.set('notifier','delay', delay);
					box.dismiss();
					alertify.success(TEXT_ALT_DOWNLOADIMG_STATUS_FINISH);
				}
			}
		}

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

			let div = $CrE('div');
			pageResource.elements.bookMain.appendChild(div);
			div.outerHTML = HTML_DOWNLOAD_LINKS_OLD
				.replaceAll('{ORIBOOKNAME}', pageResource.info.bookName)
				.replaceAll('{BOOKID}', String(pageResource.info.BID))
				.replaceAll('{BOOKNAME}', encodeURIComponent(pageResource.info.bookName));
			div = $('#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 ($('td > input[name="Submit"]') && !$('#ptitle')) {
            const table = $('form>table');
            const titleText = table.innerHTML.match(/<!--[\s\S]+id="ptitle"[\s\S]+-->/)[0];
            const titleHTML = titleText.replace(/^<!--\s*/, '').replace(/\s*-->$/, '');
			const titleEle = $CrE('tr');
			const caption = $(table, 'caption');
			table.insertBefore(titleEle, caption);
			titleEle.outerHTML = titleHTML;
        }

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

        /* ## Ctrl+Enter comment submit ## */
		let btnSbmtValue = commentSbmt.value;
        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();

		// Provide image upload & insert
		imageplus();

		// At user
		atUser();

		// Comment auto-save
		// GUI
		const asTip = $CrE('span');
		commentbttm.appendChild(asTip);

		// Review-Page: Same rid, same savekey - 'rid123456'
		// Book-Page & Book-Review-List-Page: Same bookid, same savekey - 'bid1234'
		const rid = getUrlArgv({url: commentForm.action, name: 'rid', dealFunc: Number});
		const aid = getUrlArgv({url: commentForm.action, name: 'aid', dealFunc: Number});
		const bid = location.href.match(/\/book\/(\d+).htm/) ? Number(location.href.match(/\/book\/(\d+).htm/)[1]) : 0;
		const key = rid ? 'rid' + String(rid) : 'bid' + String(bid);
		let commentData = CONFIG.commentDrafts.getConfig()[key] || {
			key : key,
			rid : rid,
			aid : aid,
			bid : bid,
			page : getUrlArgv({name: 'rid', dealFunc: Number, defaultValue: 1}),
			time : (new Date()).getTime()
		};
		restoreDraft();
		submitHook();

		const events = ['focus', 'blur', 'mousedown', 'keydown', 'keyup', 'change'];
		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 = CONFIG.commentDrafts.getConfig();

			allCData[commentData.key] = commentData;
			CONFIG.commentDrafts.saveConfig(allCData);
			asTip.innerHTML = TEXT_GUI_AUTOSAVE;
		}

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

		function clearDraft() {
			const allCData = CONFIG.commentDrafts.getConfig();
			if (!allCData[commentData.key]) {return false;};
			delete allCData[commentData.key];
			CONFIG.commentDrafts.saveConfig(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') {
				fixHTTPS.wait = fixHTTPS.wait ? fixHTTPS.wait : 0;
				if (++fixHTTPS.wait > 50) {return false;}
				DoLog('fixHTTPS: UBBEditor not loaded, waiting...');
				setTimeout(fixHTTPS, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
				return false;
			}
			const eid = 'pcontent';

			const menuItemInsertUrl = $(commentForm, '#menuItemInsertUrl');
			const menuItemInsertImage = $(commentForm, '#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 imageplus() {
			if (typeof(UBBEditor) === 'undefined') {
				DoLog('imageplus: UBBEditor not loaded, waiting...');
				setTimeout(imageplus, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
				return false;
			}

			// Imager menu
			const menu = $('#UBB_Menu');
			const elmImage = $(commentForm, '#menuItemInsertImage');
			const onclick = elmImage.onclick;
			const imagers = new PlusList({
				id: 'plus_imager',
				list: [
					{value: TEXT_GUI_REVEIW_IMG_INSERTURL, tip: TEXT_TIP_REVIEW_IMG_INSERTURL, onclick: onclick},
					{value: TEXT_GUI_REVEIW_IMG_SELECTIMG, tip: TEXT_TIP_REVIEW_IMG_SELECTIMG, onclick: pickfile}
				],
				parentElement: menu.parentElement,
				insertBefore: $('#SmileListTable'),
				visible: false,
				onshow: onshow
			});
			elmImage.onclick = (e) => {
				e.stopPropagation();
				imagers.show();
			};
			document.addEventListener('click', imagers.hide);

			// drag-drop & copy-paste
			commentArea.addEventListener('paste', pictureGot);
			commentArea.addEventListener('dragenter', destroyEvent);
			commentArea.addEventListener('dragover', destroyEvent);
			commentArea.addEventListener('drop', pictureGot);

			function onshow() {
				imagers.div.style.left = String(UBBEditor.GetPosition(elmImage).x) + 'px';
				imagers.div.style.top = String(UBBEditor.GetPosition(elmImage).y + 20) + 'px';
			}

			function pickfile() {
				const fileinput = $CrE('input');
				fileinput.type = 'file';
				fileinput.addEventListener('change', pictureGot);
				fileinput.click();
			}

			function pictureGot(e) {
				// Get picture file
				const input = e.dataTransfer || e.clipboardData || window.clipboardData || e.target;
				if (!input.files || input.files.length === 0) {return false;};
				const file = input.files[0];
				const mimetype = file.type;
				const name = file.name;

				// Pasting an unrecognizable file is not a mistake
				// Maybe the user just wants to paste the filename here
				// Otherwise getting an unrecognizable file is a mistake
				if (!mimetype || mimetype.split('/')[0] !== 'image') {
					if (!e.clipboardData && !window.clipboardData) {
						destroyEvent(e);
						alertify.error(TEXT_ALT_IMAGE_FORMATERROR);
					}
					return false;
				} else {
					destroyEvent(e);
				}

				// Insert picture marker
				const marker = '[image_uploading={ID} name={NAME}]'.replace('{ID}', randstr(16, true, commentArea.value)).replace('{NAME}', name);
				insertText(marker);

				// Upload
				alertify.notify(TEXT_ALT_IMAGE_UPLOAD_WORKING);
				uploadImage({
					file: file,
					onerror: (e) => {
						alertify.error(TEXT_ALT_IMAGE_UPLOAD_ERROR);
						DoLog(LogLevel.Error, ['Upload error at imageplus>upload:', e]);
					},
					onload: (json) => {
						const name = json.name;
						const url = json.url;
						commentArea.value = commentArea.value.replace(marker, url);
						alertify.success(TEXT_ALT_IMAGE_UPLOAD_SUCCESS.replaceAll('{NAME}', name).replaceAll('{URL}', url));
					}
				});

			}
		}

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

			function onsubmitForm(e) {
				// Cancel submit while content empty
				if (commentArea.value === '' && commenttitl.value === '') {return false;};

				// Clear Draft
				clearDraft();

				// Restore original submit button value
				if (commentSbmt.value !== btnSbmtValue) {
					commentSbmt.value = btnSbmtValue;
					setTimeout(()=>{commentSbmt.click.call(commentSbmt);}, 0);
					return false;
				}

				// Continue submit
				return onsubmit ? onsubmit() : function() {return true;};
			}
		}

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

			const menu = $('#UBB_Menu');
			const list = new PlusList({
				id: 'plus_AtTable',
				list: [],
				parentElement: menu.parentElement,
				insertBefore: $('#FontSizeTable'),
				visible: false,
				onshow: showlist
			});
			list.onhide = list.clear;
			document.addEventListener('click', list.hide);

			const firstBtn = menu.children[0];
			const atBtn = $CrE('input');
			atBtn.type = 'button';
			atBtn.style.backgroundImage = 'none';
			atBtn.value = '@';
			atBtn.title = TEXT_GUI_AREAREPLY_AT;
			atBtn.id = 'plus_At';
			atBtn.classList.add(CLASSNAME_BUTTON);
			atBtn.classList.add('UBB_MenuItem');
			atBtn.addEventListener('click', (e) => {
				e.stopPropagation();
				list.show();
			});
			menu.insertBefore(atBtn, firstBtn);

			function showlist(shown) {
				if (shown) {return false;};
				if (typeof(ubb_subdiv) === 'string' && typeof(hideeve) === 'function') {
					hideeve(ubb_subdiv);
					ubb_subdiv = 'plus_AtTable';
				}
				makelist();
				list.ul.focus();
				return true;
			}

			function makelist() {
				// Get users
				const allUsers = getAllUsers();

				// Make list
				for (const user of allUsers) {
					const item = list.append({
						value: user.userName,
						tip: ()=>{return 'uid: ' + String(user.userID);},
						onclick: btnClick
					});
					item.li.user = user;
					item.button.user = user;
				}

				// Style
				list.div.style.left = String(UBBEditor.GetPosition(atBtn).x) + 'px';
				list.div.style.top = String(UBBEditor.GetPosition(atBtn).y + 20) + 'px';

				return true;

				function getAllUsers() {
					const pageUsers = $All('#content table strong>a[href^="https://www.wenku8.net/userpage.php"]');
					const friends = getMyUserDetail().userFriends;
					if (!friends) {
						refreshMyUserDetail(refreshList);
						return false;
					}

					// concat to one array
					const allUsers = [];
					for (const pageUser of pageUsers) {
						// Valid check
						if (isNaN(Number(pageUser.href.match(/\?uid=(\d+)/)[1]))) {continue;};
						const user = {
							userName: pageUser.innerText,
							userID: Number(pageUser.href.match(/\?uid=(\d+)/)[1]),
							referred: 0
						}
						if (!userExist(allUsers, user)) {
							const userAsFriend = userExist(friends, user);
							allUsers.push(userAsFriend ? userAsFriend : user);
						}
					}
					for (const friend of friends) {
						if (!userExist(allUsers, friend)) {
							allUsers.push(friend);
						}
					}

					// Sort by referred
					allUsers.sort((a,b)=>{return (b.referred?b.referred:0) - (a.referred?a.referred:0);});

					return allUsers;

					// returns the exist user object found in users, or false if not found
					function userExist(users, user) {
						for (const u of users) {
							if (u.userID === user.userID) {return u;};
						}
						return false;
					}
				}

				function btnClick() {
					const btn = this;
					const user = btn.user;
					const name = btn.user.userName;
					const insertValue = '@' + name;
					insertText(insertValue);

					// referred increase
					const userDetail = getMyUserDetail();
					const friends = userDetail.userFriends;
					user.referred = user.referred ? user.referred+1 : 1;
					for (let i = 0; i < friends.length; i++) {
						if (friends[i].userID === user.userID) {
							friends[i] = user;
							break;
						}
					}
					CONFIG.userDtlePrefs.saveConfig(userDetail);
				}
			}
		}

		function insertText(insertValue) {
			const insertPosition = commentArea.selectionEnd;
			const text = commentArea.value;
			const leftText = text.substr(0, insertPosition);
			const rightText = text.substr(insertPosition);

			// 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(rightText)) ? '' : ' ';

			commentArea.value = leftText + insertValue + rightText;
			const position = insertPosition + insertValue.length;
			commentForm.scrollIntoView(); commentArea.focus(); commentArea.setSelectionRange(position, position);
		}
	}

	// Review link add-on
	function linkReview() {
		// Get all review links and apply add-on functions
		const allRLinks = $All('td>a[href^="https://www.wenku8.net/modules/article/reviewshow.php?"]');
		for (const RLink of allRLinks) {
			lastPage(RLink);
		}

		// Provide button direct to review last page

		// New version. Uses '&page=last' keyword.
		function lastPage(a) {
			const p = a.parentElement;
			const lastpg = $CrE('a');
			const strrid = getUrlArgv({url: a.href, name: 'rid'});
			lastpg.href = URL_REVIEWSHOW_2.replace('{R}', strrid).replace('{P}', 'last');
			lastpg.classList.add(CLASSNAME_BUTTON);
			lastpg.target = '_blank';
			lastpg.innerText = TEXT_GUI_LINK_TOLASTPAGE;
			p.insertBefore(lastpg, a);
		}
	}

	// Side functions area
	function sideFunctions() {
		const SPanel = new SidePanel();
		SPanel.usercss = CSS_SIDEPANEL;
		SPanel.create();
		SPanel.setPosition('bottom-right');

		commonButtons();
		return SPanel;

		function commonButtons() {
			// Button show/hide-all-buttons
			const btnShowHide = SPanel.add({
				faicon: 'fa-solid fa-down-left-and-up-right-to-center',
				className: 'accept-pointer',
				tip: '隐藏面板',
				onclick: (function() {
					let hidden = false;
					return (e) => {
						hidden = !hidden;
						btnShowHide.faicon.className = 'fa-solid ' + (hidden ? 'fa-up-right-and-down-left-from-center' : 'fa-down-left-and-up-right-to-center');
						btnShowHide.classList[hidden ? 'add' : 'remove']('low-opacity');
						btnShowHide.setAttribute('aria-label', (hidden ? '显示面板' : '隐藏面板'));
						SPanel.elements.panel.style.pointerEvents = hidden ? 'none' : 'auto';
						for (const button of SPanel.elements.buttons) {
							if (button === btnShowHide) {continue;}
							//button.style.display = hidden ? 'none' : 'block';
							button.style.pointerEvents = hidden ? 'none' : 'auto';
							button.style.opacity = hidden ? '0' : '1';
						}
					};
				}) ()
			});

			// Button scroll-to-bottom
			const btnDown = SPanel.add({
				faicon: 'fa-solid fa-angle-down',
				tip: '转到底部',
				onclick: (e) => {
					const elms = [document.body.parentElement, $('#content'), $('#contentmain')];

					for (const elm of elms) {
						elm && elm.scrollTo && elm.scrollTo(elm.scrollLeft, elm.scrollHeight);
					}
				}
			});

			// Button scroll-to-top
			const btnUp = SPanel.add({
				faicon: 'fa-solid fa-angle-up',
				tip: '转到顶部',
				onclick: (e) => {
					const elms = [document.body.parentElement, $('#content'), $('#contentmain')];

					for (const elm of elms) {
						elm && elm.scrollTo && elm.scrollTo(elm.scrollLeft, 0);
					}
				}
			});

			// Darkmode
			/*
			const btnDarkmode = SPanel.add({
				faicon: 'fa-solid ' + (DMode.isActivated() ? 'fa-sun' : 'fa-moon'),
				tip: '明暗切换',
				onclick: (e) => {
					DMode.toggle();
					btnDarkmode.faicon.className = 'fa-solid ' + (DMode.isActivated() ? 'fa-sun' : 'fa-moon');
				}
			});
			*/

			// Refresh page
			const btnRefresh = SPanel.add({
				faicon: 'fa-solid fa-rotate-right',
				tip: '刷新页面',
				onclick: (e) => {
					reloadPage();
				}
			});
		}
	}

    // Reviewedit page add-on
    function pageReviewedit() {
        redirectToCorrectPage();

        function redirectToCorrectPage() {
            // Get redirect target rid
            const refreshMeta = $('meta[http-equiv="refresh"]');
            const metaurl = refreshMeta.content.match(/url=(.+)/)[1];
            if (!refreshMeta) {return false;};
            if (getUrlArgv({url: metaurl, name: 'page'})) {return false;};

            // Read correct redirect location
            const rid = Number(getUrlArgv({url: metaurl, name: 'rid'}));
            const config = CONFIG.BkReviewPrefs.getConfig();
            const history = config.history;
            const pageHist = history[rid];
            if (!pageHist) {return false;}
            const url = pageHist.href;

			// Check if time expired (Expire time: 30 seconds)
			if ((new Date()).getTime() - pageHist.time > 30*1000) {
				// Delete expired record
				delete history[rid];
				CONFIG.BkReviewPrefs.saveConfig(config);
			}

			// Redirect link
			$('a').href = url;

            // Redirect
            setTimeout(() => {location.href = url;}, 1500);
        }
    }

    // Review page add-on
    function pageReview() {
		// Elements
		const main = $('#content');
		const headBars = $All(main, 'tr>td[align]');

		// Page Info
        const rid = Number(getUrlArgv('rid'));
		const aid = getUrlArgv('aid') ? Number(getUrlArgv('aid')) : Number($(main, 'td[width]>a').href.match(/(\d+)\.html?$/)[1]);
		const page = Number($('#pagelink strong').innerText);
		const title = $(main, 'th>strong').textContent;

		// URL correction
		correctURL();

		// Enhancements
		pageStatus();
        downloader();
		sideButtons();
		beautifier();
		floorEnhance();
		autoRefresh();
		addFavorite();
		addUnlock();

		function correctURL() {
			(getUrlArgv('page') === 'last' || !getUrlArgv('page')) && setPageUrl(URL_REVIEWSHOW.replace('{A}', aid).replace('{R}', rid).replace('{P}', page));
		}

		function sideButtons() {
			// Last page
			SPanel.add({
				faicon: 'fa-solid fa-angles-right',
				tip: '最后一页',
				onclick: (e) => {findclick('#pagelink>.last');}
			});

			// Next page
			SPanel.add({
				faicon: 'fa-solid fa-angle-right',
				tip: '下一页',
				onclick: (e) => {findclick('#pagelink>.next');}
			});

			// Previous page
			SPanel.add({
				faicon: 'fa-solid fa-angle-left',
				tip: '上一页',
				onclick: (e) => {findclick('#pagelink>.prev');}
			});

			// First page
			SPanel.add({
				faicon: 'fa-solid fa-angles-left',
				tip: '第一页',
				onclick: (e) => {findclick('#pagelink>.first');}
			});

			function findclick(selector) {return $(selector) && $(selector).click();}
		}

		function beautifier() {
			// GUI
			const span  = $CrE('span');
			const check = $CrE('input');
			check.type = 'checkbox';
			check.checked = CONFIG.BeautifierCfg.getConfig().reviewshow.beautiful;
			span.innerHTML = TEXT_GUI_REVIEW_BEAUTIFUL;
			span.classList.add(CLASSNAME_BUTTON);
			span.style.marginLeft = '0.5em';
			span.addEventListener('click', toggleBeautiful);
			check.addEventListener('click', toggleBeautiful);
			settip(span, TEXT_TIP_REVIEW_BEAUTIFUL);
			settip(check, TEXT_TIP_REVIEW_BEAUTIFUL);
			headBars[0].appendChild(span);
			headBars[0].appendChild(check);
			CONFIG.BeautifierCfg.getConfig().reviewshow.beautiful && beautiful();

			function toggleBeautiful(e) {
				// stop event
				destroyEvent(e);

				// Togle & save to config
				const config = CONFIG.BeautifierCfg.getConfig();
				config.reviewshow.beautiful = !config.reviewshow.beautiful;
				CONFIG.BeautifierCfg.saveConfig(config);

				setTimeout(() => {check.checked = config.reviewshow.beautiful;}, 0);
				alertify.notify(config.reviewshow.beautiful ? TEXT_ALT_BEAUTIFUL_ON : TEXT_ALT_BEAUTIFUL_OFF);

				// beautifier
				config.reviewshow.beautiful ? beautiful() : recover();
			}

			function beautiful() {
				const config = CONFIG.BeautifierCfg.getConfig();
				addStyle(CSS_REVIEWSHOW
						 .replaceAll('{BGI}', config.backgroundImage)
						 .replaceAll('{S}', config.textScale)
						 , 'beautifier');
				scaleimgs();
				hookPosition();

				function scaleimgs() {
					const imgs = $All('.divimage>img');
					const w = main.clientWidth * 0.8 - 3; // td.width = "80%", .even {padding: 3px;}
					for (const img of imgs) {
						img.width = w;
					}
				}
			}

			function recover() {
				addStyle('', 'beautifier');
				restorePosition();
			}

			function hookPosition() {
				if (!CONFIG.BeautifierCfg.getConfig().reviewshow.beautiful) {return false;};
				if (typeof(UBBEditor) !== 'object') {
					hookPosition.wait = hookPosition.wait ? hookPosition.wait : 0;
					if (++hookPosition.wait > 50) {return false;}
					DoLog('beautiful/hookPosition: UBBEditor not loaded, waiting...');
					setTimeout(hookPosition, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
					return false;
				}
				UBBEditor.GetPosition_BK = UBBEditor.GetPosition;
				UBBEditor.GetPosition = function (obj) {
					var r = new Array();
					r.x = obj.offsetLeft;
					r.y = obj.offsetTop;
					while (obj = obj.offsetParent) {
						if (unsafeWindow.$(obj).getStyle('position') == 'absolute' || unsafeWindow.$(obj).getStyle('position') == 'relative') break;
						r.x += obj.offsetLeft;
						r.y += obj.offsetTop;
					}
					r.x -= main.scrollLeft;
					r.y -= main.scrollTop;
					return r;
				}
			}

			function restorePosition() {
				if (typeof(UBBEditor) !== 'object') {return false;};
				if (!UBBEditor.GetPosition_BK) {return false;};
				UBBEditor.GetPosition = UBBEditor.GetPosition_BK;
			}
		}

		function pageStatus() {
			window.addEventListener('load', () => {
				// Recover page status
				applyPageStatus();
				// Record the current page status of current review
				setInterval(recordPage, 1000);
			});
		}

		// Apply page status sored in history record
		function applyPageStatus() {
			const config = CONFIG.BkReviewPrefs.getConfig();
            const history = config.history;
            const pageHist = history[rid];

			// Scroll to the last position
			if (pageHist && pageHist.page === page) {
				// Check if time expired
				if (pageHist.time && (new Date()).getTime() - pageHist.time < 30*1000) {
					// Do not scroll when opening a positioned link(http[s]://.../...#yidxxxxxx)
					if (/#yid\d+$/.test(location.href)) {return;}
					// Scroll
					pageHist.scrollX !== undefined && window.scrollTo(pageHist.scrollX, pageHist.scrollY);
					pageHist.contentsclX !== undefined && main.scrollTo(pageHist.contentsclX, pageHist.contentsclY);
				} else {
					// Delete expired record
					delete history[rid];
					CONFIG.BkReviewPrefs.saveConfig(config);
				}
			}
		}

        function recordPage() {
            const config = CONFIG.BkReviewPrefs.getConfig();
            const history = config.history;

            // Save page history
			config.history[rid] = {
				rid: rid,
				aid: aid,
				page: page,
				href: URL_REVIEWSHOW.replace('{R}', String(rid)).replace('{A}', String(aid)).replace('{P}', String(page)),
				scrollX: window.pageXOffset,
				scrollY: window.pageYOffset,
				contentsclX: main.scrollLeft,
				contentsclY: main.scrollTop,
				time: (new Date()).getTime()
			}
            CONFIG.BkReviewPrefs.saveConfig(config);
        }

		function floorEnhance() {
			const floors = getAllFloors();
			floors.forEach((f)=>(correctFloorLink(f)));
			for (const floor of floors) {
				alinkEdit(floor);
				addQuoteBtn(floor);
				addQueryBtn(floor);
				alinktofloor(floor.table);
			}
		}

		function alinktofloor(parent=main) {
			const floorLinks = $All(main, 'a[name][href^="https://www.wenku8.net/modules/article/reviewshow.php"][href*="#yid"]');
			for (const a of $All(parent, 'a')) {
				if (!a.href.match(/^https?:\/\/www\.wenku8\.net\/modules\/article\/reviewshow\.php\?(&?rid=\d+|&?aid=\d+|&?page=\d+){1,4}#yid\d+$/)) {continue;};
				for (const flink of floorLinks) {
					if (isSameReply(a, flink)) {
						// Set scroll target
						a.targetNode = flink;
						while (a.targetNode.nodeName !== 'TABLE') {
							a.targetNode = a.targetNode.parentElement;
						}

						// Scroll when clicked
						a.addEventListener('click', (e) => {
							destroyEvent(e);
							e.currentTarget.targetNode.scrollIntoView();
						})
					};
				}
			}

            function isSameReply(link1, link2) {
                const url1 = link1.href.toLowerCase().replace('http://', 'https://');
                const url2 = link2.href.toLowerCase().replace('http://', 'https://');
                const rid1 = getUrlArgv({url: url1, name: 'rid', defaultValue: null});
                const yid1 = url1.match(/#yid(\d+)/) ? url1.match(/#yid(\d+)/)[1] : null;
                const rid2 = getUrlArgv({url: url2, name: 'rid', defaultValue: null});
                const yid2 = url2.match(/#yid(\d+)/) ? url2.match(/#yid(\d+)/)[1] : null;
                return rid1 === rid2 && yid1 === yid2;
            }
		}

        function alinkEdit(parent=document) {
            const eLinks = $All('a[href^="https://www.wenku8.net/modules/article/reviewedit.php?yid="]');
            for (const eLink of eLinks) {
                eLink.addEventListener('click', (e) => {
                    // NO e.stopPropagation() here. Just hooks the open action.
                    e.preventDefault();

					// Open editor dialog
					openDialog(e.target.href + '&ajax_gets=jieqi_contents');

                    // Show mask if mask not shown
                    !document.getElementById("mask") && showMask();
                })
            }
        }

		function autoRefresh() {
			let working=false, interval=0;
			const pagelink    = $('#pagelink');
			const tdLink      = pagelink.parentElement;
			const trContainer = tdLink.parentElement;
			const tdAutoRefresh  = $CrE('td');
			const chkAutoRefresh = $CrE('input');
			const txtAutoRefresh = $CrE('span');
			const txtPaused = $CrE('span');
			const ptitle    = $('#ptitle');
			const pcontent  = $('#pcontent');
			txtAutoRefresh.innerText  = TEXT_GUI_AUTOREFRESH;
			txtAutoRefresh.classList.add(CLASSNAME_BUTTON);
			txtAutoRefresh.addEventListener('click', toggleRefresh);
			chkAutoRefresh.addEventListener('click', toggleRefresh);
			chkAutoRefresh.type        = 'checkbox';
			chkAutoRefresh.checked     = false;
			txtPaused.innerText        = '';
			txtPaused.classList.add(CLASSNAME_TEXT);
			txtPaused.style.marginLeft = '0.5em';
			tdAutoRefresh.style.align  = 'left';
			tdAutoRefresh.appendChild(txtAutoRefresh);
			tdAutoRefresh.appendChild(chkAutoRefresh);
			tdAutoRefresh.appendChild(txtPaused);
			trContainer.insertBefore(tdAutoRefresh, tdLink);

			// Apply config
			CONFIG.BkReviewPrefs.getConfig().autoRefresh ? toggleRefresh() : function() {};

			/* No pauses after v1.5.7
            // Show pause
            // Note: Blur event triggers after Focus event was triggered
			for (const editElm of [ptitle, pcontent]) {
                if (!editElm) {continue;};
				editElm.addEventListener('blur', (e) => {
					txtPaused.innerText = '';
				});
				editElm.addEventListener('focus', (e) => {
					txtPaused.innerText = TEXT_GUI_AUTOREFRESH_PAUSED;
				});
			}
			*/

			function toggleRefresh(e) {
				// stop event
				destroyEvent(e);

				// Not in last Page, no auto refresh
				if (!isCurLastPage() && !working) {
					const box = alertify.notify(TEXT_ALT_AUTOREFRESH_NOTLAST);
					box.callback = (isClicked) => {isClicked && (location.href = $('#pagelink>a.last').href);};
					return false;
				}

				// toggle
				working = !working;
				working ? interval = setInterval(refresh, 20*1000) : clearInterval(interval);
				working && refresh();

				// Save to config
				const review = CONFIG.BkReviewPrefs.getConfig();
				review.autoRefresh = working;
				CONFIG.BkReviewPrefs.saveConfig(review);

				setTimeout(() => {chkAutoRefresh.checked = working;}, 0);
				alertify.notify(working ? TEXT_ALT_AUTOREFRESH_ON : TEXT_ALT_AUTOREFRESH_OFF);
			}

			function refresh() {
				const box = alertify.notify(TEXT_ALT_AUTOREFRESH_WORKING);
				const url = URL_REVIEWSHOW.replace('{R}', String(rid)).replace('{A}', String(aid)).replace('{P}', 'last');
				getDocument(url, refreshLoaded, url);


				function refreshLoaded(oDoc, pageurl) {
					// Clost alert box
					box.exist ? box.close.apply(box) : function() {};

					// Update all existing floor content (and title)
					const nowfloors = $All('#content>table[class="grid"]');
					const newfloors = $All(oDoc, '#content>table[class="grid"]');
					let i, modified = false;

					for (i = 1; i < Math.min(nowfloors.length, newfloors.length); i++) {
						isFloorTable(nowfloors[i]) && isFloorTable(newfloors[i]) && getFloorNumber(nowfloors[i]) === getFloorNumber(newfloors[i]) && updatefloor(i);
					}
					modified && alertify.notify(TEXT_ALT_AUTOREFRESH_MODIFIED);

					const newtop = getTopFloorNumber(oDoc);
					const nowtop = getTopFloorNumber(document);
					if (unsafeWindow.isPY_DNG && newtop === 9899) {
						sendReviewReply({rid: rid, title: '测试标题', content: '测试内容'});
					}
					if (newtop > nowtop) {
						const newmain = $(oDoc, '#content');
						const eleLastPage = $(oDoc, '#pagelink a.last');
						const urlLastPage = newmain.url = eleLastPage.href;
						const newpage = Number(getUrlArgv({url: urlLastPage, name: 'page'}));
						const newfloors = getAllFloors(newmain);
						const nowfloors = getAllFloors();
						if (newpage === page) {
							// In same page, append floors
							for (let i = nowfloors.length; i < newfloors.length; i++) {
								const floor = newfloors[i];
								appendfloor(floor);
							}
						} else {
							// In New page, remake floors
							let box = alertify.notify(TEXT_ALT_AUTOREFRESH_APPLIED);

							// Remove old floors
							for (const oldfloor of nowfloors) {
								oldfloor.table.parentElement.removeChild(oldfloor.table);
							}

							// Append new floors
							for (const newfloor of newfloors) {
								appendfloor(newfloor);
							}

							// Remake #pagelink
							$(main, '#pagelink').innerHTML = $(newmain, '#pagelink').innerHTML;

							// Reset location.href
							page !== 'last' && setPageUrl(urlLastPage);

							return true;
						}
					} else {
						alertify.message(TEXT_ALT_AUTOREFRESH_NOMORE);
						return false;
					}

					function updatefloor(i) {
						const nowfloor = nowfloors[i];
						const newfloor = newfloors[i];
						const nowTitle = getEleFloorTitle(nowfloor);
						const newTitle = getEleFloorTitle(newfloor);
						const nowContent = getEleFloorContent(nowfloor);
						const newContent = getEleFloorContent(newfloor);

						if (nowTitle.innerHTML !== newTitle.innerHTML) {
							nowTitle.innerHTML = newTitle.innerHTML;
							nowTitle.classList.add(CLASSNAME_MODIFIED);
							nowTitle.addEventListener('click', (e) => {e.currentTarget.classList.remove(CLASSNAME_MODIFIED);});
							modified = true;
						}
						if (getFloorContent(nowContent) !== getFloorContent(newContent)) {
							nowContent.innerHTML = newContent.innerHTML;
							nowContent.classList.add(CLASSNAME_MODIFIED);
							nowContent.addEventListener('click', (e) => {e.currentTarget.classList.remove(CLASSNAME_MODIFIED);});
							modified = true;
						}
						if (modified) {
							alinktofloor(nowfloor);
						}
					}
				}
			}

			function isCurLastPage() {
				return $('#pagelink>strong').innerText === $('#pagelink>a.last').innerText;
			}

			function getTopFloorNumber(oDoc) {
				const tblfloors = $All(oDoc, '#content>table[class="grid"]');
				for (let i = tblfloors.length-1; i >= 0; i--) {
					const tbllast = tblfloors[i];
					if (isFloorTable(tbllast)) {return getFloorNumber(tbllast);}
				}

				return null;
			}
		}

		function correctFloorLink(floor) {
			floor.hrefa.href = floor.href;
		}

		function addFavorite() {
			// Create GUI
			const spliter = $CrE('span');
			const favorBtn = $CrE('span');
			const favorChk = $CrE('input');
			spliter.style.marginLeft = '1em';
			favorBtn.innerText = TEXT_GUI_REVIEW_ADDFAVORITE;
			favorBtn.classList.add(CLASSNAME_BUTTON);
			favorChk.type = 'checkbox';
			favorChk.checked = CONFIG.BkReviewPrefs.getConfig().favorites.hasOwnProperty(rid);
			favorBtn.addEventListener('click', checkChange);
			favorChk.addEventListener('change', checkChange);

			headBars[0].appendChild(spliter);
			headBars[0].appendChild(favorBtn);
			headBars[0].appendChild(favorChk);

			function checkChange(e) {
				if (e && e.target === favorChk) {
					destroyEvent(e);
				}

				let inFavorites;
				const config = CONFIG.BkReviewPrefs.getConfig();
				if (config.favorites.hasOwnProperty(rid)) {
					delete config.favorites[rid];
					inFavorites = false;
				} else {
					config.favorites[rid] = {
						rid: rid,
						name: title,
						href: URL_REVIEWSHOW_3.replace('{R}', rid).replace('{A}', aid),
						time: (new Date()).getTime(), // time added in version 1.6.7
						tiptitle: null
					};
					inFavorites = true;
				}
				CONFIG.BkReviewPrefs.saveConfig(config);
				setTimeout(() => {favorChk.checked = inFavorites;}, 0);
				alertify.notify((inFavorites ? TEXT_GUI_REVIEW_FAVORADDED : TEXT_GUI_REVIEW_FAVORDELED).replace('{N}', title));
			}

			function updateFavorite() {
				const config = CONFIG.BkReviewPrefs.getConfig();
				if (config.favorites.hasOwnProperty(rid)) {
					config.favorites[rid] = {
						rid: rid,
						name: title,
						href: URL_REVIEWSHOW_3.replace('{R}', rid).replace('{A}', aid)
					};
				}
			}
		}

		function addQuoteBtn(floor) {
			const table = floor.table;
			const numberEle = $(table, '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() {
				// Get content textarea
				const pcontent = $('#pcontent');
				const form = $('form[action^="https://www.wenku8.net/modules/article/review"]');

				// Create button
				const btn = $CrE('span');
				btn.classList.add(CLASSNAME_BUTTON);
				btn.addEventListener('click', quoteThisFloor);
				btn.innerHTML = '引用';
				const tip_panel = $CrE('div');
				tip_panel.insertAdjacentText('afterbegin', '或者,');
				const btn_qtnum = $CrE('span');
				btn_qtnum.classList.add(CLASSNAME_BUTTON);
				btn_qtnum.addEventListener('click', quoteFloorNum);
				btn_qtnum.innerHTML = '仅引用序号';
				tip_panel.appendChild(btn_qtnum);
				const panel = tippy(btn, {
					content: tip_panel,
					theme: 'wenku_tip',
					placement: 'top',
					interactive: true,
				});
				return btn;

				function quoteThisFloor() {
					// In DOM Events, <this> keyword points to the Event Element.
					const numberEle = $(this.parentElement, 'a[name]');
					const numberText = numberEle.innerText;
					const url = URL_REVIEWSHOW_4.replace('{R}', rid).replace('{P}', page).replace('{Y}', numberEle.name);
					const contentEle = $(this.parentElement.parentElement, '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(rightText)) ? '' : ' ';

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

				function quoteFloorNum() {
					// In DOM Events, <this> keyword points to the Event Element.
					const numberEle = $(this.parentElement.parentElement.parentElement.parentElement.parentElement, 'a[name]');
					const numberText = numberEle.innerText;
					const url = URL_REVIEWSHOW_4.replace('{R}', rid).replace('{P}', page).replace('{Y}', numberEle.name);
					const contentEle = $(this.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement, 'hr+div');
					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]';
					insertValue = insertValue.replace('U', url).replace('N', numberText);
					// 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(rightText)) ? '' : ' ';

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

		function addQueryBtn(floor) {
				// Get container div
				const div = floor.leftdiv;

				// Create buttons
				const qBtn = $CrE('a'); // Button for query reviews
				const iBtn = $CrE('a'); // Button for query userinfo

				// Get UID
				const user = $(div, '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($CrE('br'));
				div.appendChild(iBtn);
				div.appendChild(qBtn);
				div.insertBefore(spliter, qBtn);
			}

		// Provide a hidden function to reply overtime book-reviews
		function addUnlock() {
			listen();

			function listen() {
				if ($('#pcontent')) {return;}
				const target = $('#content>table>caption+tbody>tr>td:nth-child(2)');
				let count = 0;
				document.addEventListener('click', function hidden_unlocker(e) {
					e.target === target ? count++ : (count = 0);
					count >= 10 && add();
					count >= 10 && document.removeEventListener('click', hidden_unlocker);
					count >= 10 && (target.innerHTML = TEXT_GUI_REVIEW_UNLOCK_WARNING);
				});
			}

			function add() {
				const container = $CrE('div');
				$('#content').appendChild(container);
				makeEditor(container, rid, aid);
			}
		}

		// Reply without refreshing the document
		function hookReply() {
			const form = $('form[name="frmreview"]');
			const onsubmit = form.onsubmit;
			form.onsubmit = function() {
				const title = $(form, '#ptitle').value;
				const content = $(form, '#pcontent').value;
				(onsubmit ? onsubmit() : true) && sendReviewReply({
					rid: rid, title: title, content: content,
					onload: function(oDoc) {
						// Make floor(s)
					},
					onerror: function(e) {
						DoLog(LogLevel.Error, 'pageReview/hookReply: submit onerror.');
					}
				});
			};
		}

		function downloader() {
			// GUI
			const pageCountText = $('#pagelink>.last').href.match(/page=(\d+)/)[1];
			const lefta = $(headBars[0], 'a');
			const lefttext = document.createTextNode('书评回复');
			clearChildnodes(headBars[0]);
			headBars[0].appendChild(lefta);
			headBars[0].appendChild(lefttext);
			headBars[0].width = '45%';
			headBars[1].width = '55%';

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

			const spliter = $CrE('span');
			const bbcdTxt = $CrE('span');
			const bbcdChk = $CrE('input');
			spliter.style.marginLeft = '1em';
			bbcdTxt.innerText = TEXT_GUI_DOWNLOAD_BBCODE;
			bbcdChk.type = 'checkbox';
			bbcdChk.checked = CONFIG.BkReviewPrefs.getConfig().bbcode;
			bbcdTxt.addEventListener('click', bbcodeOnclick);
			bbcdChk.addEventListener('click', bbcodeOnclick);
			settip(bbcdTxt, TEXT_TIP_DOWNLOAD_BBCODE);
			settip(bbcdChk, TEXT_TIP_DOWNLOAD_BBCODE);
			bbcdTxt.classList.add(CLASSNAME_BUTTON);
			headBars[1].appendChild(spliter);
			headBars[1].appendChild(bbcdTxt);
			headBars[1].appendChild(bbcdChk);

			function bbcodeOnclick(e) {
				destroyEvent(e);

				if (downloadWholePost.working) {
					alertify.warning(TEXT_ALT_DOWNLOAD_BBCODE_NOCHANGE);
					return false;
				}
				const cmConfig = CONFIG.BkReviewPrefs.getConfig();
				cmConfig.bbcode = !cmConfig.bbcode;
				setTimeout(() => {bbcdChk.checked = cmConfig.bbcode;}, 0);
				CONFIG.BkReviewPrefs.saveConfig(cmConfig);
			}

			// ## 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, '#content');
				const avatars = $All(main, '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({name: 'rid', dealFunc: Number, defaultValue: 0});
					data.page = getUrlArgv({name: 'page', dealFunc: Number, defaultValue: 1});
					data.title = $(main, '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, 'tr');
					elements.tdUser = $(elements.table, 'td.odd');
					elements.tdReply = $(elements.table, 'td.even');
					elements.divUser = $(elements.tdUser, 'div');
					elements.aUser = $(elements.divUser, 'a');
					elements.attr = $(elements.tdReply, 'div a').parentElement;
					elements.time = elements.attr.childNodes[0];
					elements.number = $(elements.attr, 'a[name]');
					elements.title = $(elements.tdReply, 'div>strong');
					elements.content = $(elements.tdReply, '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 = CONFIG.BkReviewPrefs.getConfig().bbcode ? getFloorContent(elements.content, true) : 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 page 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 = $('#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;
				bbcdTxt.classList.add(CLASSNAME_DISABLED);

				// 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 = '文库贴 - ' + data.title + ' - ' + data.id.toString() + '.txt';

					const a = $CrE('a');
					a.href = url;
					a.download = name;
					a.click();

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

					// Work finish
					downloadWholePost.working = false;
					bbcdTxt.classList.remove(CLASSNAME_DISABLED);
				})
			}
		}

		// Get all floor object
		/* Contains:
		**     floor.table
		**     floor.tbody
		**     floor.tr
		**     floor.lefttd
		**     floor.righttd
		**     floor.leftdiv
		**     floor.titlediv
		**     floor.titlestrong
		**     floor.metadiv
		**     floor.replydiv
		*/
		function getAllFloors(parent=main) {
			const avatars = $All(parent, 'table div img.avatar');
			const floors = [];
			for (const avt of avatars) {
				const floor = {};
				floor.leftdiv = avt.parentElement;
				floor.lefttd  = floor.leftdiv.parentElement;
				floor.tr      = floor.lefttd.parentElement
				floor.righttd = floor.tr.children[1];
				floor.titlediv    = floor.righttd.children[0];
				floor.titlestrong = floor.titlediv.children[0];
				floor.metadiv     = floor.righttd.children[1];
				floor.replydiv    = floor.righttd.children[3];
				floor.hrefa    = $(floor.metadiv, 'a[name]');
				floor.tbody    = floor.tr.parentElement;
				floor.table    = floor.tbody.parentElement;
				floor.rid      = Number(getUrlArgv({url: parent.url || location.href, name: 'rid'}));
				floor.aid      = Number($(parent, 'td[width]>a').href.match(/(\d+)\.html?$/)[1]);
				floor.page     = Number($(avt.ownerDocument, '#pagelink strong').innerText);
				floor.pagehref = URL_REVIEWSHOW.replace('{R}', floor.rid.toString()).replace('{A}', floor.aid.toString()).replace('{P}', floor.page.toString());
				floor.href     = URL_REVIEWSHOW_5.replace('{R}', floor.rid.toString()).replace('{A}', floor.aid.toString()).replace('{P}', floor.page.toString()).replace('{Y}', floor.hrefa.name);
				floors.push(floor);
			}
			return floors;
		}

		// Validate a <table> element whether is a floor
		function isFloorTable(tbl) {
			return $(tbl, 'a[href*="#yid"][name^="yid"]') ? true : false;
		}

		// Get floor title element (<strong>)
		// Argv: <table> element of the floor
		function getEleFloorTitle(tblfloor) {
			return $(tblfloor, 'td.even>div:first-child>strong'); // or :nth-child(1)
		}

		// Get floor content element (<div>)
		// Argv: <table> element of the floor
		function getEleFloorContent(tblfloor) {
			return $(tblfloor, 'td.even>hr+div');
		}

		// Get the floor number
		// Argv: <table> element of the floor
		function getFloorNumber(tblfloor) {
			const eleNumber = $(tblfloor, 'a[name^="yid"]');
			return eleNumber ? Number(eleNumber.innerText.match(/\d+/)[0]) : false;
		}

		// Get floor content by BBCode format (content only, no title)
		// Argv: <div> content Element
		function getFloorContent(contentEle, original=false) {
					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':
								// wenku8 has forbidden [img] tag for secure reason (preventing CSRF)
								//content += '[img]S[/img]'.replace('S', node.src);
								content += original ? '[img]S[/img]'.replace('S', node.src) : ' S '.replace('S', node.src);
								break;
							case 'A':
								content += '[url=U]T[/url]'.replace('U', node.getAttribute('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, original);
								} else {
									content += getFloorContent(node, original);
								}
								break;
							case 'CODE': content += getFloorContent(node, original); break; // Just ignore
							case 'PRE':  content += getFloorContent(node, original); break; // Just ignore
							case 'SPAN': content += getFontedSubcontent(node); break; // Size and color
							case 'P':    content += getFontedSubcontent(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, original); 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, original);
						return '[{T}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{S}', subContent);
					}

					function getFontedSubcontent(node) {
						let tag, value;

						let strSize = node.style.fontSize.match(/\d+/);
						let strColor = node.style.color;
						let strAlign = node.align;
						strSize = strSize ? strSize[0] : null;
						strColor = strColor ? rgbToHex.apply(null, strColor.match(/\d+/g)) : null;

						tag = tag || (strSize  ? 'size'  : null);
						tag = tag || (strColor ? 'color' : null);
						tag = tag || (strAlign ? 'align' : null);
						value = value || strSize || null;
						value = value || strColor || null;
						value = value || strAlign || null;

						const subContent = getFloorContent(node, original);
						if (tag && value) {
							return '[{T}={V}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{V}', value).replaceAll('{S}', subContent);
						} else {
							return subContent;
						}
					}
				}

		// Append floor to #content
		function appendfloor(floor) {
			// Append
			const table = floor.table;
			const elmafter = $(main, 'table.grid+table[border]');
			main.insertBefore(table, elmafter);

			// Enhances
			correctFloorLink(floor);
			alinkEdit(floor);
			addQuoteBtn(floor);
			addQueryBtn(floor);
			alinktofloor(floor.table);
		}
    }

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

		// gowork
		laterReads();
		showBookcases();
		recommendAllGUI();

		function recommendAllGUI() {
			const block = createWenkuBlock({
				type: 'mypage',
				parent: '#left',
				title: TEXT_GUI_BOOKCASE_ATRCMMD,
				items: [
					{innerHTML: arConfig.allCount === 0 ? TEXT_GUI_BOOKCASE_RCMMDNW_NOTASK : (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, '.ulitem');
			const txtst = $(block, '#arstatus');
			const btnAR = $(block, '#autorcmmd');
			const btnRN = $(block, '#rcmmdnow');
			const txtAR = $(block, 'span');
			const checkbox = $CrE('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) {
				destroyEvent(e);
				arConfig.auto = !arConfig.auto;
				setTimeout(function() {checkbox.checked = arConfig.auto;}, 0);
				CONFIG.AutoRecommend.saveConfig(arConfig);
				alertify.notify(arConfig.auto ? TEXT_ALT_ATRCMMDS_AUTO : TEXT_ALT_ATRCMMDS_NOAUTO);
			}

			function rcmmdnow() {
				if (TASK.AutoRecommend.checkRcmmd() && !confirm(TEXT_GUI_BOOKCASE_RCMMDNW_CONFIRM)) {return false;}
				if (arConfig.allCount === 0) {alertify.warning(TEXT_ALT_ATRCMMDS_NOTASK); return false;};
				TASK.AutoRecommend.run(true);
			}
		}

		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
				})
			}
			return {bookcases: lists};
		}

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

			// Hide bookcase selector
			const bcSelector = $(topTitle, '[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, '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, '#checkform');
				form.parentElement.removeChild(form);

				// Find the right place to insert it in
				const forms = $All(content, '.'+CLASSNAME_BOOKCASE_FORM);
				for (let i = 0; i < forms.length; i++) {
					const thisForm = forms[i];
					const cid = typeof thisForm.classid === 'number' ? thisForm.classid : curClassid;
					if (cid > classid) {
						content.insertBefore(form, thisForm);
						break;
					}
				}
				if(!form.parentElement) {$('#laterbooks').insertAdjacentElement('beforebegin', 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, '[name="classlist"]');
			bcSelector.style.display = 'none';

			// Dblclick Change title
			const titleBar = bcSelector.parentElement;
			titleBar.childNodes[0].nodeValue = name;
			titleBar.addEventListener('dblclick', editName);
			// Longpress Change title for mobile
			let touchTimer;
			titleBar.addEventListener('touchstart', () => {touchTimer = setTimeout(editName, 500);});
			titleBar.addEventListener('touchmove', () => {clearTimeout(touchTimer);});
			titleBar.addEventListener('touchend', () => {clearTimeout(touchTimer);});
			titleBar.addEventListener('mousedown', () => {touchTimer = setTimeout(editName, 500);});
			titleBar.addEventListener('mouseup', () => {clearTimeout(touchTimer);});

			// 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, '#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 = $All(content, '.'+CLASSNAME_BOOKCASE_FORM);
					for (const form of forms) {
						renameFormSlctr(form);
					}
				} else {
					renameFormSlctr(form);
				}

				function renameFormSlctr(form) {
					const newclassid = $(form, '#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 = $CrE('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 ? tipobj.innerHTML = tip : function(){};
					const value = nameInput.value.trim();
					if (value) {
						name = value;
						bookcase.name = name;
						CONFIG.bookcasePrefs.saveConfig(bookcases);
					}
					titleBar.childNodes[0].nodeValue = name;
					try {titleBar.removeChild(nameInput)} catch (DOMException) {};
					renameSelectors(true);
				}

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

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

				// Insert auto-recommend option for given row
				function decorateRow(tr) {
					const eleBookLink = $(tr, 'td:nth-child(2)>a');
					const strBookID = eleBookLink.href.match(/aid=(\d+)/)[1];
					const strBookName = eleBookLink.innerText;
					const newTd = $CrE('td');
					const input = $CrE('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 = $All(tr, 'th');
					const width = ARR_GUI_BOOKCASE_WIDTH;
					const newTh = $CrE('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, 'td.foot');
					td.colSpan = ARR_GUI_BOOKCASE_WIDTH.length;
				}

				// auto-recommend onvaluechange
				function onvaluechange(e) {
					arConfig = CONFIG.AutoRecommend.getConfig();
					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];
						CONFIG.AutoRecommend.saveConfig(arConfig);

						// alert
						alertify.notify(
							TEXT_ALT_ATRCMMDS_SAVED
							.replaceAll('{B}', strBookName)
							.replaceAll('{N}', value)
							.replaceAll('{R}', userDetail.vote-arConfig.allCount)
						);
						if (userDetail && arConfig.allCount > userDetail.vote) {
							const alertBox = alertify.warning(
								TEXT_ALT_ATRCMMDS_OVERFLOW
								.replace('{V}', String(userDetail.vote))
								.replace('{C}', String(arConfig.allCount))
							);
							alertBox.callback = function(isClicked) {
								isClicked && refreshMyUserDetail();
							}
						};
					} else {
						// invalid input value, alert
						alertify.error(TEXT_ALT_ATRCMMDS_INVALID.replaceAll('{N}', value));
					}
				}
			}
		}

		function laterReads() {
			// Container
			const container = $CrE('div');
			container.id = 'laterbooks';
			content.appendChild(container);

			// Title div
			const titlebar = $CrE('div');
			titlebar.classList.add('gridtop');
			titlebar.style.display = 'grid';
			titlebar.style['grid-template-columns'] = '1fr 1fr 1fr';
			container.appendChild(titlebar);

			const title = $CrE('span');
			title.innerHTML = '稍后再读';
			title.style['grid-column'] = '2/3';
			titlebar.appendChild(title);

			// Sorter select container
			const sortContainer = $CrE('span');
			sortContainer.style['grid-column'] = '3/4';
			sortContainer.style.textAlign = 'right';
			titlebar.appendChild(sortContainer);

			// Sorter select
			const sltsort = $CrE('select');
			sltsort.style.width = 'max-content';
			sltsort.addEventListener('change', function() {
				const config = CONFIG.bookcasePrefs.getConfig();
				config.laterbooks.sortby = sltsort.value;
				CONFIG.bookcasePrefs.saveConfig(config);
				showBooks();
			});
			sortContainer.appendChild(sltsort);

			// Sorter select options
			const sorttypes = Object.keys(FUNC_LATERBOOK_SORTERS);
			for (const type of sorttypes) {
				const sort = FUNC_LATERBOOK_SORTERS[type];
				const option = $CrE('option');
				option.innerHTML = sort.name;
				option.value = type;
				sltsort.appendChild(option);
			}
			sltsort.selectedIndex = sorttypes.indexOf(CONFIG.bookcasePrefs.getConfig().laterbooks.sortby);

			// Body table
			const body = $CrE('table');
			setAttributes(body, {
				'class': 'grid',
				'width': '100%',
				'align': 'center'
			});
			const tbody = $CrE('tbody');
			body.appendChild(tbody);
			container.appendChild(body);

			// Header & Rows
			showBooks();

			function showBooks() {
				const config = CONFIG.bookcasePrefs.getConfig().laterbooks;
				clearChildnodes(body);

				// headers
				const headtr = $CrE('tr');
				headtr.setAttribute('align', 'center');
				const headers = [{
					name: '名称',
					width: '22%'
				},{
					name: '简介',
					width: '60%'
				},{
					name: '操作',
					width: '18%'
				}];
				for (const head of headers) {
					const th = $CrE('th');
					th.innerHTML = head.name;
					th.style.width = head.width;
					headtr.appendChild(th);
				}
				body.appendChild(headtr);

				// Book rows
				const books = sortLaterReads(config.books, config.sortby);

				for (const book of books) {
					makeRow(book);
				}

				function makeRow(book) {
					const config = CONFIG.bookcasePrefs.getConfig().laterbooks;

					// row
					const row = $CrE('tr');

					// cover & name
					const tdName = $CrE('td');
					tdName.classList.add('odd');
					tdName.style.textAlign = 'center';
					const clink = $CrE('a');
					clink.href = URL_NOVELINDEX.replace('{I}', book.aid);
					clink.target = '_blank';
					tdName.appendChild(clink);
					const cover = $CrE('img');
					cover.src = book.cover;
					cover.style.width = '100px';
					clink.appendChild(cover);
					clink.insertAdjacentHTML('beforeend', '</br>');
					clink.insertAdjacentText('beforeend', book.name);
					row.appendChild(tdName);

					// info
					const tdInfo = $CrE('td');
					tdInfo.classList.add('even');
					tdInfo.insertAdjacentHTML('afterbegin', '<span class="hottext">作品Tags:</span></br>');
					for (const tag of book.tags) {
						const a = $CrE('a');
						a.target = '_blank';
						a.href = URL_TAGSEARCH.replace('{TU}', $URL.encode(tag));
						a.classList.add(CLASSNAME_BUTTON);
						a.innerText = tag + ' ';
						tdInfo.appendChild(a);
					}
					tdInfo.insertAdjacentHTML('beforeend', '</br></br><span class="hottext">内容简介:</span></br>');
					tdInfo.insertAdjacentText('beforeend', book.introduce);
					row.appendChild(tdInfo);

					// operator
					const tdOprt = $CrE('td');
					tdOprt.classList.add('odd');
					tdOprt.style.textAlign = 'center';
					const btnDel = makeBtn();
					btnDel.innerHTML = '删除';
					btnDel.addEventListener('click', del);
					tdOprt.appendChild(btnDel);
					const btnAbc = makeBtn('a'); // Abc ==> AddBookCase
					btnAbc.innerHTML = '加入书架';
					btnAbc.href = URL_ADDBOOKCASE.replace('{A}', book.aid);
					btnAbc.target = '_blank';
					tdOprt.appendChild(btnAbc);
					if (config.sortby === 'sort') {
						tdOprt.appendChild($CrE('br'));
						const btnUp = makeBtn();
						btnUp.innerHTML = '上移';
						btnUp.addEventListener('click', function () {
							const config = CONFIG.bookcasePrefs.getConfig();
							const books = Object.values(config.laterbooks.books);
							const cur = books.filter((b) => (b.sort === book.sort));
							const previous = books.filter((b) => (b.sort === book.sort-1));

							if (cur) {
								if (previous.length > 0) {
									previous[0].sort++;
									cur[0].sort--;
									CONFIG.bookcasePrefs.saveConfig(config);
									showBooks();
								}
							} else {
								alertify.warning(TEXT_ALT_BOOKCASE_AFTERBOOKS_MISSING);
							}
						});
						tdOprt.appendChild(btnUp);
						const btnDown = makeBtn();
						btnDown.innerHTML = '下移';
						btnDown.addEventListener('click', function () {
							const config = CONFIG.bookcasePrefs.getConfig();
							const books = Object.values(config.laterbooks.books);
							const cur = books.filter((b) => (b.sort === book.sort));
							const after = books.filter((b) => (b.sort === book.sort+1));

							if (cur) {
								if (after.length > 0) {
									after[0].sort--;
									cur[0].sort++;
									CONFIG.bookcasePrefs.saveConfig(config);
									showBooks();
								}
							} else {
								alertify.warning(TEXT_ALT_BOOKCASE_AFTERBOOKS_MISSING);
							}
						});
						tdOprt.appendChild(btnDown);
					}
					row.appendChild(tdOprt);

					body.appendChild(row);

					function del() {
						const config = CONFIG.bookcasePrefs.getConfig();
						delete config.laterbooks.books[book.aid];
						CONFIG.bookcasePrefs.saveConfig(config);
						body.removeChild(row);
					}

					function makeBtn(tagName='span') {
						const btn = $CrE(tagName);
						btn.classList.add(CLASSNAME_BUTTON);
						btn.style.margin = '0 1em';
						return btn;
					}
				}
			}
		}

		// Set attributes to an element
		function setAttributes(elm, attributes) {
			for (const [name, attr] of Object.entries(attributes)) {
				elm.setAttribute(name, attr);
			}
		}
	}

	// Novel ads remover
	function removeTopAds() {
		const ads = []; $All('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();
		//downloader();

		function downloader() {
			AndAPI.getNovelIndex({
				aid: unsafeWindow.article_id,
				lang: 0,
				callback: indexGot
			});

			function indexGot(xml) {
				const volumes = $All(xml, 'volume');
				const vtitles = $All('.vcss');
				if (volumes.length !== vtitles.length) {return false;}

				for (let i = 0; i < volumes.length; i++) {
					const volume = volumes[i];
					const vtitle = vtitles[i];
					const vname = volume.childNodes[0].nodeValue;

					// Title element
					const elmTitle = $CrE('span');
					elmTitle.innerText = vname;

					// Spliter element
					const elmSpliter = $CrE('span');
					elmSpliter.style.margin = '0 0.5em';

					// Download button
					const elmDlBtn = $CrE('span');
					elmDlBtn.classList.add(CLASSNAME_BUTTON);
					elmDlBtn.innerHTML = TEXT_GUI_DOWNLOAD_THISVOLUME;
					elmDlBtn.addEventListener('click', function() {
						// getAttribute returns string rather than number,
						// but downloadVolume accepts both string and number as vid
						downloadVolume(volume.getAttribute('vid'), vname, ['utf-8', 'big5'][getLang()]);
					});

					clearChildnodes(vtitle);
					vtitle.appendChild(elmTitle);
					vtitle.appendChild(elmSpliter);
					vtitle.appendChild(elmDlBtn);
				}
			}

			function downloadVolume(vid, vname, charset='utf-8') {
				const url = URL_DOWNLOAD1.replace('{A}', unsafeWindow.article_id).replace('{V}', vid).replace('{C}', charset);
				downloadFile({
					url: url,
					name: TEXT_GUI_SDOWNLOAD_FILENAME
						.replace('{NovelName}', $('#title').innerText)
						.replace('{VolumeName}', vname)
						.replace('{Extension}', 'txt')
				});
			}
		}
	}

    // Novel page add-on
    function pageNovel() {
		const CSM = new ConfigSetManager();
		CSM.install();
		const pageResource = {elements: {}, infos: {}, download: {}};
		collectPageResources();

		// Remove ads
		removeTopAds();

		// Side-Panel buttons
		sideButtons();

		// Provide download GUI
		downloadGUI();

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

		// Font changer
		fontChanger();

		// More font-sizes
		moreFontSizes();

		// Fill content if need
		fillContent();

		// Beautifier page
		beautifier();

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

			function collectElements() {
				const elements = pageResource.elements;
				elements.title          = $('#title');
				elements.images         = $All('.imagecontent');
				elements.rightButtonDiv = $('#linkright');
				elements.rightNodes     = elements.rightButtonDiv.childNodes;
				elements.rightBlank     = elements.rightNodes[elements.rightNodes.length-1];
				elements.content        = $('#content');
				elements.contentmain    = $('#contentmain');
				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);
			}
		}

		// Side-Panel buttons
		function sideButtons() {
			// Download
			SPanel.add({
				faicon: 'fa-solid fa-download',
				tip: TEXT_GUI_DOWNLOAD_THISCHAPTER,
				onclick: dlNovel
			});

			// Next page
			SPanel.add({
				faicon: 'fa-solid fa-angle-right',
				tip: '下一页',
				onclick: (e) => {$('#foottext>a:nth-child(4)').click();}
			});

			// Previous page
			SPanel.add({
				faicon: 'fa-solid fa-angle-left',
				tip: '上一页',
				onclick: (e) => {$('#foottext>a:nth-child(3)').click();}
			});
		}

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

			// Create donwload button
			const dlBtn = elements.downloadBtn = $CrE('span');
			dlBtn.classList.add(CLASSNAME_BUTTON);
			dlBtn.addEventListener('click', dlNovel);
			dlBtn.innerText = TEXT_GUI_DOWNLOAD_THISCHAPTER;

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

		// Page beautifier
		function beautifier() {
			CONFIG.BeautifierCfg.getConfig().novel.beautiful && beautiful();

			function beautiful() {
				const config = CONFIG.BeautifierCfg.getConfig();
				const usedHeight = getRestHeight();

				addStyle(CSS_NOVEL
						 .replaceAll('{BGI}', config.backgroundImage)
						 .replaceAll('{S}', config.textScale)
						 .replaceAll('{H}', usedHeight), 'beautifier'
				);

				unsafeWindow.scrolling = beautiful_scrolling;

				// Get rest height without #contentmain
				function getRestHeight() {
					let usedHeight = 0;
					['adv1', 'adtop', 'headlink', 'footlink', 'adbottom'].forEach((id) => {
						const node = $('#'+id);
						if (node instanceof Element && node.id !== 'contentmain') {
							const cs = getComputedStyle(node);
							['height', 'marginTop', 'marginBottom', 'paddingTop', 'paddingBottom', 'borderTop', 'borderBottom'].forEach((style) => {
								const reg = cs[style].match(/([\.\d]+)px/);
								reg && (usedHeight += Number(reg[1]));
							});
						};
					});
					usedHeight = usedHeight.toString() + 'px';
					return usedHeight;
				}

				// Mouse dblclick scroll with beautifier applied
				function beautiful_scrolling() {
					var contentmain = pageResource.elements.contentmain;
					var currentpos = contentmain.scrollTop || 0;
					contentmain.scrollTo(0, ++currentpos);
					var nowpos = contentmain.scrollTop || 0;
					if(currentpos != nowpos) unsafeWindow.clearInterval(timer)
				}
			}
		}

		// Provide font changer
		function fontChanger() {
			// Button
			const bcolor = $('#bcolor');
			const txtfont = $CrE('select');
			txtfont.id = 'txtfont';
			txtfont.addEventListener('change', applyFont);
			bcolor.insertAdjacentElement('afterend', txtfont);
			bcolor.insertAdjacentText('afterend', '\t\t\t  字体选择');

			// Provided fonts
			const FONTS = [{"name":"默认","value":"unset"}, {"name":"微软雅黑","value":"Microsoft YaHei"},{"name":"黑体","value":"SimHei"},{"name":"微软正黑体","value":"Microsoft JhengHei"},{"name":"宋体","value":"SimSun"},{"name":"仿宋","value":"FangSong"},{"name":"新宋体","value":"NSimSun"},{"name":"细明体","value":"MingLiU"},{"name":"新细明体","value":"PMingLiU"},{"name":"楷体","value":"KaiTi"},{"name":"标楷体","value":"DFKai-SB"}]
			for (const font of FONTS) {
				const option = $CrE('option');
				option.innerText = font.name;
				option.value = font.value;
				txtfont.appendChild(option);
			}

			// Function
			CSM.ConfigSets.txtfont = {
				save: () => (setCookies('txtfont', txtfont[txtfont.selectedIndex].value)),
				load: () => {
					const tmpstr = ReadCookies("txtfont");
					if (tmpstr != "") {
						for (let i = 0; i < txtfont.length; i++) {
							if (txtfont.options[i].value == tmpstr) {
								txtfont.selectedIndex = i;
								break;
							}
						}
					}
					applyFont();
				}
			};

			// Load saved font
			CSM.ConfigSets.txtfont.load();

			function applyFont() {
				$('#content').style['font-family'] = txtfont[txtfont.selectedIndex].value;
			}
		}

		// Provide more font-sizes
		function moreFontSizes() {
			const select = $('#fonttype');
			const savebtn = $('#saveset');
			const sizes = [
				{
					name: '更小',
					size: '10px'
				},
				{
					name: '更大',
					size: '28px'
				},
				{
					name: '很大',
					size: '32px'
				},
				{
					name: '超大',
					size: '36px'
				},
				{
					name: '极大',
					size: '40px'
				},
				{
					name: '过大',
					size: '44px'
				},
			];

			for (const size of sizes) {
				const option = $CrE('option');
				option.innerHTML = size.name;
				option.value = size.size;

				// Insert with sorting
				for (const opt of select.children) {
					const sizeNum1 = getSizeNum(opt.value);
					const sizeNum2 = getSizeNum(option.value);
					if (isNaN(sizeNum1) || isNaN(sizeNum2)) {continue;} // Code shouldn't be here in normal cases
					if (sizeNum1 > sizeNum2) {
						select.insertBefore(option, opt);
						break;
					}
				}
				option.parentElement !== select && select.appendChild(option);
			}

			// Load saved fonttype
			CSM.ConfigSets.fonttype.load();

			function getSizeNum(size) {
				return Number(size.match(/(\d+)px/)[1]);
			}
		}

		// Provide content using AndroidAPI
		function fillContent() {
			// Check whether needs filling
			if ($('#contentmain>span')) {
				if ($('#contentmain>span').innerText.trim() !== 'null') {
					return false;
				}
			} else {return false;}

			// prepare
			const content = pageResource.elements.content;
			content.innerHTML = TEXT_GUI_NOVEL_FILLING;

			// Get content xml
			AndAPI.getNovelContent({
				aid: unsafeWindow.article_id,
				cid: unsafeWindow.chapter_id,
				lang: 0,
				callback: function(text) {
					const imgModel = '<div class="divimage"><a href="{U}" target="_blank"><img src="{U}" border="0" class="imagecontent"></a></div>';

					// Trim whitespaces
					text = text.trim();

					// Get images like <!--image-->http://pic.wenku8.com/pictures/0/716/24406/11588.jpg<!--image-->
					const imgUrls = text.match(/<!--image-->[^<>]+?<!--image-->/g) || [];

					// Parse <img> for every image url
					let html = '';
					for (const url of imgUrls) {
						const index = text.indexOf(url);
						const src = htmlEncode(url.match(/<!--image-->([^<>]+?)<!--image-->/)[1]);
						html += htmlEncode(text.substring(0, index)).replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\n', '</br>');
						html += imgModel.replaceAll('{U}', src);
						text = text.substring(index + url.length);
					}
					html += htmlEncode(text);

					// Set content
					pageResource.elements.content.innerHTML = html;

					// Reset pageResource-image if need
					pageResource.infos.isImagePage = imgUrls.length > 0;
					pageResource.elements.images = $All('.imagecontent');
					pageResource.download.all = pageResource.elements.images.length;
				}
			})

			return true;
		}

		// Download button onclick
		function dlNovel() {
			pageResource.infos.isImagePage ? dlNovelImages() : dlNovelText();
		}

		// Download Images
		function dlNovelImages() {
			const elements = pageResource.elements;
			const infos    = pageResource.infos;
			const download = pageResource.download;

			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 = $CrE('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() {
							throw new Error('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;
				}
			}
		}

		// Download Text
		function dlNovelText() {
			const infos = pageResource.infos;
			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 = $CrE('canvas');
            cvs.width = image.width;
		    cvs.height = image.height;
            const ctx = cvs.getContext('2d');
            ctx.drawImage(image, 0, 0);
            return cvs.toDataURL(format);
        }

		function ConfigSetManager() {
			const CSM = this;
			/*const setCookies = unsafeWindow.setCookies,
				  ReadCookies = unsafeWindow.ReadCookies,
				  bcolor = unsafeWindow.bcolor,
				  txtcolor = unsafeWindow.txtcolor,
				  fonttype = unsafeWindow.fonttype,
				  scrollspeed = unsafeWindow.scrollspeed,
				  setSpeed = unsafeWindow.setSpeed,
				  contentobj = unsafeWindow.contentobj;*/

			CSM.ConfigSets = {
				'bcolor': {
					save: () => (setCookies("bcolor", bcolor.options[bcolor.selectedIndex].value)),
					load: () => {
						const tmpstr = ReadCookies("bcolor");
						bcolor.selectedIndex = 0;
						if (tmpstr != "") {
							for (let i = 0; i < bcolor.length; i++) {
								if (bcolor.options[i].value == tmpstr) {
									bcolor.selectedIndex = i;
									break;
								}
							}
						}
						document.bgColor = bcolor.options[bcolor.selectedIndex].value;
					}
				},
				'txtcolor': {
					save: () => (setCookies("txtcolor", txtcolor.options[txtcolor.selectedIndex].value)),
					load: () => {
						const tmpstr = ReadCookies("txtcolor");
						txtcolor.selectedIndex = 0;
						if (tmpstr != "") {
							for (let i = 0; i < txtcolor.length; i++) {
								if (txtcolor.options[i].value == tmpstr) {
									txtcolor.selectedIndex = i;
									break;
								}
							}
						}
						$('#content').style.color = txtcolor.options[txtcolor.selectedIndex].value;
					}
				},
				'fonttype': {
					save: () => (setCookies("fonttype", fonttype.options[fonttype.selectedIndex].value)),
					load: () => {
						const tmpstr = ReadCookies("fonttype");
						fonttype.selectedIndex = 2;
						if (tmpstr != "") {
							for (let i = 0; i < fonttype.length; i++) {
								if (fonttype.options[i].value == tmpstr) {
									fonttype.selectedIndex = i;
									break;
								}
							}
						}
						$('#content').style.fontSize = fonttype.options[fonttype.selectedIndex].value;
					}
				},
				'scrollspeed': {
					save: () => (setCookies("scrollspeed", scrollspeed.value)),
					load: () => {
						const tmpstr = ReadCookies("scrollspeed");
						if (tmpstr == '') {tmpstr = 5;}
						scrollspeed.value = tmpstr;
						setSpeed();
					}
				}
			};

			CSM.saveSet = function() {
				for (const [name, set] of Object.entries(CSM.ConfigSets)) {
					set.save();
				}
			};

			CSM.loadSet = function() {
				for (const [name, set] of Object.entries(CSM.ConfigSets)) {
					set.load();
				}
			};

			CSM.install = function() {
				Object.defineProperty(unsafeWindow, 'saveSet', {
					configurable: false,
					enumerable: true,
					value: CSM.saveSet,
					writable: false
				});
				Object.defineProperty(unsafeWindow, 'loadSet', {
					configurable: false,
					enumerable: true,
					value: CSM.loadSet,
					writable: false
				});
			};
		}
    }

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

		let optionTags;
		provideTagOption();
		onsubmitHOOK();

		function provideTagOption() {
			optionTags = $CrE('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() {
	}

	// Mylink page add-on
	function pageMylink() {
		// Get elements
		const main = $('#content');
		const tbllink = $('#content>table');

		linkEnhance();

		function fixEdit(link) {
			const aedit = link.aedit;
			aedit.setAttribute('onclick', "editlink({ULID},'{NAME}','{HREF}','{INFO}')".replace('{ULID}', deal(link.ulid)).replace('{NAME}', deal(link.name)).replace('{HREF}', deal(link.href)).replace('{INFO}', deal(link.info)));

			function deal(str) {
				return str.replaceAll("'", "\\'");
			}
		}

		function linkEnhance() {
			const links = getAllLinks();
			for (const link of links) {
				fixEdit(link);
			}
		}

		function getAllLinks() {
			const links = [];
			const trs = $All(tbllink, 'tbody>tr+tr');
			for (const tr of trs) {
				const link = {};

				// All <td>
				link.tdlink = tr.children[0];
				link.tdinfo = tr.children[1];
				link.tdtime = tr.children[2];
				link.tdoprt = tr.children[3];

				// Inside <td>
				link.alink = link.tdlink.children[0];
				link.aedit = link.tdoprt.children[0];
				link.apos  = link.tdoprt.children[1];
				link.adel  = link.tdoprt.children[2];

				// Infos
				link.href = link.alink.href;
				link.ulid = getUrlArgv({url: link.apos.href, name: 'ulid'});
				link.name = link.alink.innerText;
				link.info = link.tdinfo.innerText;
				link.time = link.tdtime.innerText;
				link.purl = link.apos.href;

				links.push(link);
			}

			return links;
		}
	}

	// 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 = $All('.blockcontent .userinfo')[0].parentElement;
			const container = oriContainer.cloneNode(true);
			const button = $(container, 'a');
			button.innerText = TEXT_GUI_USER_REVIEWSEARCH;
			button.href = URL_REVIEWSEARCH.replaceAll('{K}', String(UID));
			oriContainer.parentElement.appendChild(container);
		}
	}

	// Detail page add-on
	function pageDetail() {
		// Get elements
		const content = $('#content');
		const tbody = $(content, 'table>tbody');

		insertSettings();

		// Insert Settings GUI
		function insertSettings() {
			let elements = GUI();

			function GUI() {
				const review = CONFIG.BkReviewPrefs.getConfig();
				const settings = [
					[{html: TEXT_GUI_DETAIL_TITLE_SETTINGS, colSpan: 3, class: 'foot'}],
					[{html: TEXT_GUI_DETAIL_TITLE_BGI}, {colSpan: 2, key: 'bgimage', tiptitle: TEXT_TIP_IMAGE_FIT}],
					[{html: TEXT_GUI_DETAIL_BGI_UPLOAD}, {colSpan: 2, key: 'bgupload'}],
					[{html: TEXT_GUI_DETAIL_GUI_IMAGER}, {colSpan: 2, key: 'imager'}],
					[{html: TEXT_GUI_DETAIL_GUI_SCALE}, {colSpan: 2, key: 'scalectnr'}],
					[{html: TEXT_GUI_DETAIL_BTF_NOVEL}, {colSpan: 2, key: 'btfnvlctnr'}],
					[{html: TEXT_GUI_DETAIL_BTF_REVIEW}, {colSpan: 2, key: 'btfrvwctnr'}],
					[{html: TEXT_GUI_DETAIL_BTF_COMMON}, {colSpan: 2, key: 'btfcmnctnr'}],
					[{html: TEXT_GUI_DETAIL_FVR_LASTPAGE}, {colSpan: 2, key: 'favoropen'}],
					[{html: TEXT_GUI_DETAIL_VERSION_CURVER}, {colSpan: 2, key: 'curversion'}],
					[{html: TEXT_GUI_DETAIL_VERSION_CHECKUPDATE}, {colSpan: 2, key: 'updatecheck'}],
					[{html: TEXT_GUI_DETAIL_FEEDBACK_TITLE, colSpan: 1, key: 'feedbackttle'}, {html: TEXT_GUI_DETAIL_FEEDBACK, colSpan: 2, key: 'feedback'}],
					[{html: TEXT_GUI_DETAIL_UPDATEINFO_TITLE, colSpan: 1, key: 'feedbackttle'}, {html: TEXT_GUI_DETAIL_UPDATEINFO, colSpan: 2, key: 'updateinfo'}],
					[{html: TEXT_GUI_DETAIL_CONFIG_EXPORT}, {html: TEXT_GUI_DETAIL_EXPORT_CLICK, colSpan: 2, key: 'exportcfg'}],
					[{html: TEXT_GUI_DETAIL_CONFIG_EXPORT_NOPASS}, {html: TEXT_GUI_DETAIL_EXPORT_CLICK, colSpan: 2, key: 'exportcfgnp'}],
					[{html: TEXT_GUI_DETAIL_CONFIG_IMPORT, colSpan: 1, key: 'importcfgttle'}, {html: TEXT_GUI_DETAIL_IMPORT_CLICK, colSpan: 2, key: 'importcfg'}],
					[{html: TEXT_GUI_DETAIL_CONFIG_MANAGE, colSpan: 1, key: 'managecfgttle'}, {html: TEXT_GUI_DETAIL_MANAGE_CLICK, colSpan: 2, key: 'managecfg'}],
					//[{html: TEXT_GUI_DETAIL_XXXXXX_XXXXXX, colSpan: 1, key: 'xxxxxxxx'}, {html: TEXT_GUI_DETAIL_XXXXXX_XXXXXX, colSpan: 2, key: 'xxxxxxxx'}],
				]
				const elements = createTableGUI(settings);
				const tdBgi = elements.bgimage;
				const imageinput = elements.imageinput = $CrE('input');
				const bgioprt = elements.bgioprt = $CrE('span');
				const bgiupld = elements.bgupload;
				const ckbgiup = elements.ckbgiup = $CrE('input');
				ckbgiup.type = 'checkbox';
				ckbgiup.checked = CONFIG.BeautifierCfg.getConfig().upload;
				ckbgiup.addEventListener('change', uploadChange);
				settip(ckbgiup, TEXT_GUI_DETAIL_BGI_LEGAL);
				bgiupld.appendChild(ckbgiup);
				imageinput.type = 'file';
				imageinput.style.display = 'none';
				imageinput.addEventListener('change', pictureGot);
				bgioprt.innerHTML = TEXT_GUI_DETAIL_DEFAULT_BGI + '</br>' + TEXT_GUI_DETAIL_BGI.replace('{N}', CONFIG.BeautifierCfg.getConfig().bgiName);
				bgioprt.style.color = 'grey';
				settip(bgioprt, TEXT_TIP_IMAGE_FIT);
				tdBgi.addEventListener("dragenter", destroyEvent);
				tdBgi.addEventListener("dragover", destroyEvent);
				tdBgi.addEventListener('drop', pictureGot);
				tdBgi.style.textAlign = 'center';
				tdBgi.addEventListener('click', ()=>{elements.imageinput.click();});
				tdBgi.appendChild(imageinput);
				tdBgi.appendChild(bgioprt);

				// Imager
				const curimager = CONFIG.UserGlobalCfg.getConfig().imager;
				elements.imager.style.padding = '0px 0.5em';
				for (const [key, imager] of Object.entries(DATA_IMAGERS)) {
					if (typeof(imager) !== 'object' || !imager.isImager) {continue;}

					const span = $CrE('span');
					const radio = $CrE('input');
					const text = $CrE('span');
					radio.type = 'radio';
					radio.value = '';
					radio.id = 'imager_'+key;
					radio.imagerkey = key;
					radio.name = 'imagerselect';
					radio.style.cursor = 'pointer';
					radio.addEventListener('change', imagerChange);
					radio.disabled = !imager.available;
					text.innerText = imager.name + (imager.available ? '' : '(已失效)');
					text.style.marginRight = '1em';
					text.style.cursor = 'pointer';
					text.addEventListener('click', function() {radio.click();});
					span.style.display = 'inline-block';
					span.appendChild(radio);
					span.appendChild(text);
					if (imager.tip) {
						let tip = imager.tip;
						DATA_IMAGERS.default === key && (tip += TEXT_TIP_IMAGER_DEFAULT);
						!imager.available && (tip = '<del>{T}</del></br>已失效'.replace('{T}', tip));
						settip(radio, tip);
						settip(text, tip);
						//settip(span, imager.tip);
					}
					elements.imager.appendChild(span);
				}
				$(elements.imager, '#imager_'+curimager).checked = true;

				// Text scale
				const textScale = CONFIG.BeautifierCfg.getConfig().textScale;
				const scalectnr = elements.scalectnr;
				const elmscale = elements.scale = $CrE('input');
				elmscale.type = 'number';
				elmscale.id = 'textScale';
				elmscale.value = textScale;
				elmscale.addEventListener('change', scaleChange);
				elmscale.addEventListener('keydown', (e) => {e.keyCode === 13 && scaleChange();});
				scalectnr.appendChild(elmscale);
				scalectnr.appendChild(document.createTextNode('%'));

				// Beautifier
				const btfnvlctnr = elements.btfnvlctnr;
				const btfrvwctnr = elements.btfrvwctnr;
				const btfcmnctnr = elements.btfcmnctnr;
				const ckbtfnvl = elements.ckbtfnvl = $CrE('input');
				const ckbtfrvw = elements.ckbtfrvw = $CrE('input');
				const ckbtfcmn = elements.ckbtfcmn = $CrE('input');
				ckbtfnvl.type = ckbtfrvw.type = ckbtfcmn.type = 'checkbox';
				ckbtfnvl.page = 'novel';
				ckbtfrvw.page = 'reviewshow';
				ckbtfcmn.page = 'common';
				ckbtfnvl.checked = CONFIG.BeautifierCfg.getConfig().novel.beautiful;
				ckbtfrvw.checked = CONFIG.BeautifierCfg.getConfig().reviewshow.beautiful;
				ckbtfcmn.checked = CONFIG.BeautifierCfg.getConfig().common.beautiful;
				ckbtfnvl.addEventListener('change', beautifulChange);
				ckbtfrvw.addEventListener('change', beautifulChange);
				ckbtfcmn.addEventListener('change', beautifulChange);
				btfnvlctnr.appendChild(ckbtfnvl);
				btfrvwctnr.appendChild(ckbtfrvw);
				btfcmnctnr.appendChild(ckbtfcmn);

				// Favorite open
				const favoropen = elements.favoropen;
				const favorlast = elements.favorlast = $CrE('input');
				favorlast.type = 'checkbox';
				favorlast.checked = CONFIG.BkReviewPrefs.getConfig().favorlast;
				favorlast.addEventListener('change', favorlastChange);
				favoropen.appendChild(favorlast);

				// Version control
				const curversion = elements.curversion;
				const updatecheck = elements.updatecheck;
				const versiondisplay = $CrE('span');
				versiondisplay.innerText = 'v' + GM_info.script.version;
				updatecheck.innerText = TEXT_GUI_DETAIL_VERSION_CHECK;
				updatecheck.style.color = 'grey';
				updatecheck.style.textAlign = 'center';
				updatecheck.addEventListener('click', updateOnclick);
				curversion.appendChild(versiondisplay);

				// Feedback
				const feedback = elements.feedback;
				feedback.style.color = 'grey';
				feedback.style.textAlign = 'center';
				feedback.addEventListener('click', function() {
					window.open('https://greasyfork.org/scripts/416310/feedback');
				});

				// Update info
				const updateinfo = elements.updateinfo;
				updateinfo.style.color = 'grey';
				updateinfo.style.textAlign = 'center';
				updateinfo.addEventListener('click', function() {
					window.open('https://greasyfork.org/scripts/416310#updateinfo');
				})

				// Config export/import
				const exportcfg = elements.exportcfg;
				const exportcfgnp = elements.exportcfgnp;
				const importcfg = elements.importcfg;
				const configinput = elements.configinput = $CrE('input');
				configinput.type = 'file';
				configinput.style.display = 'none';
				importcfg.style.color = exportcfgnp.style.color = exportcfg.style.color = 'grey';
				importcfg.style.textAlign = exportcfgnp.style.textAlign = exportcfg.style.textAlign = 'center';
				exportcfg.addEventListener('click', ()=>{exportConfig(false);});
				exportcfgnp.addEventListener('click', ()=>{exportConfig(true);});
				importcfg.addEventListener('click', () => {configinput.click()});
				configinput.addEventListener('change', configfileGot);
				importcfg.addEventListener("dragenter", destroyEvent);
				importcfg.addEventListener("dragover", destroyEvent);
				importcfg.addEventListener('drop', configfileGot);
				//importcfg.appendChild(configinput);

				// Config management
				const managecfg = elements.managecfg;
				managecfg.style.color = 'grey';
				managecfg.style.textAlign = 'center';
				managecfg.addEventListener('click', openManagePanel);

				// Paste event
				window.addEventListener('paste', filePasted);

				return elements;
			}

			function filePasted(e) {
				const input = e.dataTransfer || e.clipboardData || window.clipboardData || e.target;
				if (!input.files || input.files.length === 0) {return false;};

				for (const file of input.files) {
					switch (file.type) {
						case 'image/bmp':
						case 'image/gif':
						case 'image/vnd.microsoft.icon':
						case 'image/jpeg':
						case 'image/png':
						case 'image/svg+xml':
						case 'image/tiff':
						case 'image/webp':
							confirm(TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_SELECT.replace('{N}', file.name)) && pictureGot(e);
							break;
						case '': {
							const splited = file.name.split('.');
							const ext = splited[splited.length-1];
							switch (ext) {
								case 'wkp':
									confirm(TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_PASTE.replace('{N}', file.name)) && configfileGot(e);
							}
						}
					}
				}
			}

			function pictureGot(e) {
				e.preventDefault();

				// Get file
				const input = e.dataTransfer || e.clipboardData || window.clipboardData || e.target;
				if (!input.files || input.files.length === 0) {return false;};
				const fileObj = input.files[0];
				const mimetype = fileObj.type;
				const name = fileObj.name;

				// Create a new file input
				elements.bgimage.removeChild(elements.imageinput);
				const imageinput = elements.imageinput = $CrE('input');
				imageinput.type = 'file';
				imageinput.style.display = 'none';
				imageinput.addEventListener('change', pictureGot);
				elements.bgimage.appendChild(imageinput);

				if (!mimetype || mimetype.split('/')[0] !== 'image') {
					alertify.error(TEXT_ALT_IMAGE_FORMATERROR);
					return false;
				}
				elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_WORKING;

				// Get object url
				const objurl = URL.createObjectURL(fileObj);

				// Get image url(format base64)
				getImageUrl(objurl, true, true, (url) => {
					if (!url) {return false;};

					// Save to config
					const config = CONFIG.BeautifierCfg.getConfig();
					config.backgroundImage = url;
					config.bgiName = name;
					CONFIG.BeautifierCfg.saveConfig(config);
					elements.bgioprt.innerHTML = name;
					URL.revokeObjectURL(objurl);

					// Upload if need
					if (config.upload) {
						alertify.notify(TEXT_ALT_IMAGE_UPLOAD_WORKING);
						elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_UPLOADING.replace('{NAME}', name);
						const file = dataURLtoFile(url, name);
						uploadImage({
							file: file,
							onerror: (e) => {
								alertify.error(TEXT_ALT_IMAGE_UPLOAD_ERROR);
								DoLog(LogLevel.Error, ['Upload error at pictureGot:', e]);
								elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_UPLOADFAILED.replace('{NAME}', name);
								const config = CONFIG.BeautifierCfg.getConfig();
								config.upload = elements.ckbgiup.checked = /^https?:\/\//.test(config.reveiwshow.backgroundImage);
								CONFIG.BeautifierCfg.saveConfig(config);
							},
							onload: (json) => {
								const config = CONFIG.BeautifierCfg.getConfig();
								config.backgroundImage = json.url;
								CONFIG.BeautifierCfg.saveConfig(config);
								elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_DEFAULT_BGI + '</br>' + TEXT_GUI_DETAIL_BGI.replace('{N}', CONFIG.BeautifierCfg.getConfig().bgiName);
								alertify.success(TEXT_ALT_IMAGE_UPLOAD_SUCCESS.replace('{NAME}', json.name).replace('{URL}', json.url));
							}
						})
					}
				});
			}

			function uploadChange(e) {
				e.preventDefault();

				const config = CONFIG.BeautifierCfg.getConfig();
				config.upload = !config.upload;
				CONFIG.BeautifierCfg.saveConfig(config);
				const name = config.bgiName ? config.bgiName : 'image.jpeg';

				if (config.upload) {
					// Upload
					const url = config.backgroundImage;
					if (!/^https?:\/\//.test(url)) {
						alertify.notify(TEXT_ALT_IMAGE_UPLOAD_WORKING);
						elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_UPLOADING.replace('{NAME}', name);
						const file = dataURLtoFile(url, name);
						uploadImage({
							file: file,
							onerror: (e) => {
								alertify.error(TEXT_ALT_IMAGE_UPLOAD_ERROR);
								DoLog(LogLevel.Error, ['Upload error at uploadChange:', e]);
								elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_UPLOADFAILED.replace('{NAME}', name);
								const config = CONFIG.BeautifierCfg.getConfig();
								config.upload = elements.ckbgiup.checked = /^https?:\/\//.test(config.backgroundImage);
								CONFIG.BeautifierCfg.saveConfig(config);
							},
							onload: (json) => {
								const config = CONFIG.BeautifierCfg.getConfig();
								config.backgroundImage = json.url;
								config.bgiName = elements.bgioprt.innerHTML = json.name;
								CONFIG.BeautifierCfg.saveConfig(config);
								alertify.success(TEXT_ALT_IMAGE_UPLOAD_SUCCESS.replace('{NAME}', json.name).replace('{URL}', json.url));
							}
						});
					}
				} else {
					// Download
					const url = config.backgroundImage;
					if (/^https?:\/\//.test(url)) {
						alertify.notify(TEXT_ALT_IMAGE_DOWNLOAD_WORKING);
						elements.bgioprt.innerHTML = TEXT_GUI_DETAIL_BGI_DOWNLOADING.replace('{NAME}', name);
						getImageUrl(url, true, true, (dataurl) => {
							if (!dataurl) {
								const config = CONFIG.BeautifierCfg.getConfig();
								config.upload = elements.ckbgiup.checked = /^https?:\/\//.test(config.backgroundImage);
								CONFIG.BeautifierCfg.saveConfig(config);
								return false;
							};

							// Save to config
							const config = CONFIG.BeautifierCfg.getConfig();
							config.backgroundImage = dataurl;
							CONFIG.BeautifierCfg.saveConfig(config);
							alertify.success(TEXT_ALT_IMAGE_DOWNLOAD_SUCCESS.replace('{NAME}', name));
							elements.bgioprt.innerHTML = name;
						});
					}
				}

				setTimeout(()=>{elements.ckbgiup.checked = config.upload;}, 0);
			}

			function imagerChange(e) {
				e.stopPropagation();
				const radio = e.target;
				if (radio.checked) {
					const imager = DATA_IMAGERS[radio.imagerkey];
					const config = CONFIG.UserGlobalCfg.getConfig();
					config.imager = radio.imagerkey;
					CONFIG.UserGlobalCfg.saveConfig(config);
					alertify.message('图床已切换到{NAME}'.replace('{NAME}', imager.name));
					imager.warning && alertify.warning(imager.warning);
				}
			}

			function scaleChange(e) {
				e.stopPropagation();
				const config = CONFIG.BeautifierCfg.getConfig();
				config.textScale = e.target.value;
				CONFIG.BeautifierCfg.saveConfig(config);
				alertify.message(TEXT_ALT_TEXTSCALE_CHANGED.replaceAll('{S}', config.textScale));
			}

			function beautifulChange(e) {
				e.stopPropagation();
				const checkbox = e.target;
				const config = CONFIG.BeautifierCfg.getConfig();
				config[checkbox.page].beautiful = checkbox.checked;
				CONFIG.BeautifierCfg.saveConfig(config);
				alertify.message(checkbox.checked ? TEXT_ALT_BEAUTIFUL_ON : TEXT_ALT_BEAUTIFUL_OFF);
			}

			function favorlastChange(e) {
				e.stopPropagation();
				const checkbox = e.target;
				const config = CONFIG.BkReviewPrefs.getConfig();
				config.favorlast = checkbox.checked;
				CONFIG.BkReviewPrefs.saveConfig(config);
				alertify.message(checkbox.checked ? TEXT_ALT_FAVORITE_LAST_ON : TEXT_ALT_FAVORITE_LAST_OFF);
			}

			function updateOnclick(e) {
				TASK.Script.update(true);
			}

			function configfileGot(e) {
				e.preventDefault();

				// Get file
				const input = e.dataTransfer || e.clipboardData || window.clipboardData || e.target;
				if (!input.files || input.files.length === 0) {return false;};
				const fileObj = input.files[0];
				const splitedname = fileObj.name.split('.');
				const ext = splitedname[splitedname.length-1].toLowerCase();
				if (ext !== 'wkp') {
					alertify.error(TEXT_ALT_DETAIL_CONFIG_IMPORT_ERROR_SELECT);
					DoLog(LogLevel.Warning, 'pageDetail.insertSettings.GUI.configfileGot: userinput error.')
					return false;
				}

				// Read config from file
				try {
					const FR = new FileReader();
					FR.onload = fileOnload;
					FR.readAsText(fileObj);
				} catch(e) {
					fileError(e);
				}

				function fileOnload(e) {
					try {
						// Get json
						const json = JSON.parse(e.target.result);

						// Import
						importConfig(json);

						alertify.success(TEXT_ALT_DETAIL_IMPORTED);
					} catch(err) {
						fileError(err);
					}
				}

				function fileError(e) {
					DoLog(LogLevel.Error, ['pageDetail.insertSettings.GUI.configfileGot:', e]);
					alertify.error(TEXT_ALT_DETAIL_CONFIG_IMPORT_ERROR_READ);
				}
			}

			function openManagePanel(e) {
				const settings = {
					id: 'ConfigPanel'
				};
				const SetPanel = new SettingPanel(settings);
				const tblAccount = SetPanel.tables[0];

				account();
				drafts();
				review_favorites();
				pending_tip();

				SetPanel.usercss += '.settingpanel-block.sp-center {text-align: center;} .settingpanel-block{overflow-wrap: anywhere;}';

				function account() {
					const userConfig = CONFIG.GlobalConfig.getConfig();
					const users = userConfig.users ? userConfig.users : {};

					// Create table
					const table = new SetPanel.PanelTable({
						rows: [{
							blocks: [{
								className: 'sp-center',
								innerHTML: '账号管理',
								colSpan: 3
							}]
						},{
							blocks: [{
								className: 'sp-center',
								innerHTML: '用户名'
							},{
								className: 'sp-center',
								innerHTML: '密码'
							},{
								className: 'sp-center',
								innerHTML: '操作'
							}]
						}]
					});
					SetPanel.appendTable(table);

					for (const [name, user] of Object.entries(users)) {
						// Get account
						const username = user.username;
						const password = user.password;

						// Row
						const row = new SetPanel.PanelRow();
						table.appendRow(row);

						// Block username
						const block_username = new SetPanel.PanelBlock({
							className: 'sp-center',
							innerHTML: username
						});

						// Block password
						const spanpswd = $CrE('span');;
						spanpswd.innerHTML = '*'.repeat(password.length);
						const block_password = new SetPanel.PanelBlock({
							className: 'sp-center',
							children: [spanpswd]
						});

						// Block operator
						const btndel = _createBtn('删除', make_del_callback(row, username));
						const elmshow = $CrE('span'); elmshow.innerHTML = '查看';
						const btnshow = _createBtn(elmshow, make_show_callback(elmshow, spanpswd, password));
						const block_operator = new SetPanel.PanelBlock({
							className: 'sp-center',
							children: [btnshow, btndel]
						});

						// Append row to SettingPanel
						row.appendBlock(block_username).appendBlock(block_password).appendBlock(block_operator);
					}

					function make_del_callback(row, username) {
						return function(e) {
							const userConfig = CONFIG.GlobalConfig.getConfig();
							delete userConfig.users[username];
							CONFIG.GlobalConfig.saveConfig(userConfig);
							row.remove();
						}
					}

					function make_show_callback(btn, span, password) {
						let show = false;
						let timeout;
						return function toggle(e) {
							show = !show;
							span.innerHTML = show ? password : '*'.repeat(password.length);
							btn.innerHTML = show ? '隐藏' : '查看';
						}
					}
				}

				function drafts() {
					// Get config
					const allCData = CONFIG.commentDrafts.getConfig();

					// Create table
					const table = new SetPanel.PanelTable({
						rows: [{
							blocks: [{
								className: 'sp-center',
								innerHTML: '书评草稿管理',
								colSpan: 3
							}]
						},{
							blocks: [{
								className: 'sp-center',
								innerHTML: '标题'
							},{
								className: 'sp-center',
								innerHTML: '内容'
							},{
								className: 'sp-center',
								innerHTML: '操作'
							}]
						}]
					});
					SetPanel.appendTable(table);

					// Append rows
					for (const [propkey, commentData] of Object.entries(allCData)) {
						if (propkey === KEY_DRAFT_VERSION) {continue;}
						const title = commentData.title;
						const content = commentData.content;
						const key = commentData.key;

						// Row
						const row = new SetPanel.PanelRow();
						table.appendRow(row);

						// Block title
						const span_title = $CrE('span');
						span_title.innerHTML = _decorate(title);
						const block_title = new SetPanel.PanelBlock({className: 'draft-title sp-center', children: [span_title]});

						// Block content
						const span_content = $CrE('span');
						span_content.innerHTML = _decorate(content);
						const block_content = new SetPanel.PanelBlock({className: 'draft-content', children: [span_content]});

						// Block operator
						const elmshow = $CrE('span'); elmshow.innerHTML = '展开';
						const btnshow = _createBtn(elmshow, make_show_callback(elmshow, key, row, span_title, span_content));
						//const btnedit = _createBtn('编辑', make_edit_callback(key, row));
						const btnopen = _createBtn('打开', make_open_callback(key));
						const btndel = _createBtn('删除', make_del_callback(key, row));
						const block_operator = new SetPanel.PanelBlock({className: 'draft-operator sp-center', children: [btnshow, btnopen, btndel]});

						// Append to row
						row.appendBlock(block_title).appendBlock(block_content).appendBlock(block_operator);
					}

					// Append css
					SetPanel.usercss += '.settingpanel-block.draft-title {width: 20%;} .settingpanel-block.draft-content {width: 50%;} .settingpanel-block.draft-operator {width: 30%}';

					function make_show_callback(btn, key, row, span_title, span_content) {
						let show = false;
						return function() {
							const allCData = CONFIG.commentDrafts.getConfig();
							const data = allCData[key];
							if (!data) {
								alertify.warning(TEXT_ALT_DETAIL_MANAGE_NOTFOUND);
								row.remove();
								return false;
							}
							show = !show;
							btn.innerHTML = show ? '收起' : '展开';
							span_title.innerHTML = _decorate(show ? {text: data.title, length: -1} : data.title);
							span_content.innerHTML = _decorate(show ? {text: data.content, length: -1} : data.content);
						};
					}

					function make_edit_callback(key, row) {
						return function() {
							// Get data
							const allCData = CONFIG.commentDrafts.getConfig();
							const data = allCData[key];
							if (!data) {
								alertify.warning(TEXT_ALT_DETAIL_MANAGE_NOTFOUND);
								row.remove();
								return false;
							}

							// Create box gui
							const box = alertify.alert();
							const container = box.elements.content;
							makeEditor(container, data.rid.toString());
							const form = $(container, 'form');
							const ptitle = $(container, '#ptitle');
							const pcontent = $(container, '#pcontent');
							ptitle.value = data.title;
							pcontent.value = data.content;
							box.setting({
								maximizable: false,
								resizable: true
							});
							box.resizeTo('80%', '60%');
							box.show();
						};
					}

					function make_open_callback(key, row) {
						return function() {
							const allCData = CONFIG.commentDrafts.getConfig();
							const data = allCData[key];
							if (!data) {
								alertify.warning(TEXT_ALT_DETAIL_MANAGE_NOTFOUND);
								row.remove();
								return false;
							}
							const url = data.rid ? URL_REVIEWSHOW_1.replace('{R}', data.rid.toString()) : URL_NOVELINDEX.replace('{I}', data.bid.toString());
							window.open(url);
						}
					}

					function make_del_callback(key, row) {
						return function() {
							const allCData = CONFIG.commentDrafts.getConfig();
							delete allCData[key];
							CONFIG.commentDrafts.saveConfig(allCData);
							row.remove();
						};
					}
				}

				function review_favorites() {
					// Get config
					const config = CONFIG.BkReviewPrefs.getConfig();
					const favs = config.favorites;

					// Create table
					const table = new SetPanel.PanelTable({
						rows: [{
							blocks: [{
								className: 'sp-center',
								innerHTML: '书评收藏管理',
								colSpan: 3
							}]
						},{
							blocks: [{
								className: 'sp-center',
								innerHTML: '主题'
							},{
								className: 'sp-center',
								innerHTML: '备注'
							},{
								className: 'sp-center',
								innerHTML: '操作'
							}]
						}]
					});
					SetPanel.appendTable(table);

					// Append rows
					for (const [rid, fav] of Object.entries(favs)) {
						// Row
						const row = new SetPanel.PanelRow();
						table.appendRow(row);

						// Title block
						const span_title = $CrE('span');
						span_title.innerHTML = _decorate({text: fav.name, length: 0});
						const block_title = new SetPanel.PanelBlock({className: 'fav-title sp-center', children: [span_title]});

						// Note block
						const span_note = $CrE('span');
						span_note.innerHTML = _decorate({text: fav.tiptitle, length: 0});
						const block_note = new SetPanel.PanelBlock({className: 'fav-note sp-center', children: [span_note]});

						// Operator block
						const btn_open = _makeBtn({
							tagName: 'a',
							innerHTML: TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_OPEN,
							props: {
								href: fav.href + (config.favorlast ? '&page=last' : ''),
								target: '_blank'
							}
						});
						const btn_edit = _makeBtn({
							innerHTML: TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_NOTE,
							onclick: edit.bind(null, fav, row)
						});
						const btn_delete = _makeBtn({
							innerHTML: TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_BTN_DELETE,
							onclick: del.bind(null, rid, row)
						});
						const block_oprt = new SetPanel.PanelBlock({className: 'fav-operator sp-center', children: [btn_open, btn_edit, btn_delete]});

						// Append to row
						row.appendBlock(block_title).appendBlock(block_note).appendBlock(block_oprt);
					}

					// Append css
					SetPanel.usercss += '.settingpanel-block.fav-title {width: 35%;} .settingpanel-block.fav-note {width: 35%;} .settingpanel-block.fav-operator {width: 30%}';

					function edit(fav, row) {
						alertify.prompt(TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_TITLE, TEXT_GUI_DETAIL_MANAGE_FAV_NOTE_TIP.replace('{TITLE}', fav.name), fav.tiptitle || '', onok, function() {});

						function onok(e, value) {
							// Save empty value as null
							value === value || null;
							fav.tiptitle = value;
							CONFIG.BkReviewPrefs.saveConfig(config);
							row.blocks[1].element.firstChild.innerHTML = _decorate({text: value, length: 0});
							alertify.success(TEXT_GUI_DETAIL_MANAGE_FAV_SAVED);
						}
					}

					function del(rid, row) {
						alertify.confirm(TEXT_GUI_DETAIL_MANAGE_FAV_DELETE_TITLE, TEXT_GUI_DETAIL_MANAGE_FAV_DELETE_TIP.replace('{TITLE}', favs[rid].name), onok, function() {});

						function onok() {
							delete favs[rid];
							CONFIG.BkReviewPrefs.saveConfig(config);
							row.remove();
							alertify.success(TEXT_GUI_DETAIL_MANAGE_FAV_DELETED);
						}
					}
				}

				function pending_tip() {
					const span = $CrE('span');
					span.innerHTML = '*其他管理项尚在开发中,请耐心等待';
					span.classList.add(CLASSNAME_TEXT);
					SetPanel.element.appendChild(span);
				}

				function _createBtn(htmlorbtn, onclick) {
					const innerHTML = typeof htmlorbtn === 'string' ? htmlorbtn : htmlorbtn.innerHTML;
					const btn = htmlorbtn instanceof HTMLElement ? htmlorbtn : $CrE('span');
					!btn.classList.contains(CLASSNAME_BUTTON) && btn.classList.add(CLASSNAME_BUTTON);
					btn.innerHTML = innerHTML;
					btn.style.margin = '0px 0.5em';
					onclick && btn.addEventListener('click', onclick);
					return btn;
				}

				function _makeBtn(details) {
					// Create element
					const elm = $CrE(details.tagName || 'span');

					// Write innerHTML
					copyProp(details, elm, 'innerHTML');

					// Write other properties
					details.props && copyProps(details.props, elm, Object.keys(details.props));

					// Make onclick
					const onclick = details.onclick || (details.onclickMaker ? details.onclickMaker.apply(null, details.onclickArgs || []) : null);

					// Create button
					const btn = _createBtn(elm, onclick);

					// Custom classes
					details.classes && details.classes.forEach(function(c) {!btn.classList.contains(c) && btn.classList.add(c);});

					return btn;
				}

				// details: 'string' or {text: '', length: 16}
				function _decorate(details) {
					// Get Args
					details = !details ? '' : details;
					details = typeof details === 'string' ? {text: details} : details;
					const text = details.text || '';
					const length = typeof details.length === 'number' ? (details.length > 0 ? details.length : Infinity) : 16;

					const len = length > 0 ? length : 9999999999999;
					const overflow = (text.length - len) > length;
					const cut = overflow ? text.substr(0, len) : text;
					const encoded = htmlEncode(cut).replaceAll('\n', '</br>');
					const filled = text.length === 0 ? TEXT_GUI_DETAIL_CONFIG_MANAGE_EMPTY : (overflow ? encoded + TEXT_GUI_DETAIL_CONFIG_MANAGE_MORE : encoded);
					return filled;
				}
			}
		}

		function createTableGUI(lines) {
			const elements = {};
			for (const line of lines) {
				const tr = $CrE('tr');
				for (const item of line) {
					const td = $CrE('td');
					item.html && (td.innerHTML = item.html);
					item.colSpan && (td.colSpan = item.colSpan);
					item.class && (td.className = item.class);
					item.id && (td.id = item.id);
					item.tiptitle && settip(td, item.tiptitle);
					item.key && (elements[item.key] = td);
					td.style.padding = '3px';
					tr.appendChild(td);
				}
				tbody.appendChild(tr);
			}
			return elements;

			function ElementObject(element) {
				const p = new Proxy(element, {
					get: function(elm, id, receiver) {
						return elm[id] || $(elm, '#'+id);
					}
				});

				return p;
			}
		}
	}

	// Index page add-on
    function pageIndex() {
		insertStatus();
		showFavorites();
		showLaterReads();

		// Insert usersript inserted tip
		function insertStatus() {
			const blockcontent = $('#centers>.block:nth-child(1)>.blockcontent');
			blockcontent.appendChild($CrE('br'));
			const textNode = $CrE('span');
			textNode.innerText = TEXT_GUI_INDEX_STATUS;
			textNode.classList.add(CLASSNAME_TEXT);
			blockcontent.appendChild(textNode);
		}

		// Show favorite reviews
		function showFavorites() {
			const links = [];
			const config = CONFIG.BkReviewPrefs.getConfig();

			for (const [rid, favorite] of Object.entries(config.favorites)) {
				const href = favorite.href + (config.favorlast ? '&page=last' : '');
				const tiptitle = favorite.tiptitle ? favorite.tiptitle : href;
				const innerHTML = favorite.name.substr(0, 12) // prevent overflow
				links.push({
					innerHTML: innerHTML,
					tiptitle: tiptitle,
					href: href
				});
			}

			const block = createWenkuBlock({
				type: 'toplist',
				parent: '#left',
				title: TEXT_GUI_INDEX_FAVORITES,
				items: links
			});
		}

		// Show top-6 read-later books
		function showLaterReads() {
			const config = CONFIG.bookcasePrefs.getConfig().laterbooks;
			const books = sortLaterReads(config.books, config.sortby).filter((e,i,a)=>(i<6));
			const items = books.map(function(book, i) {
				return {
					href: URL_NOVELINDEX.replace('{I}', book.aid),
					src: book.cover,
					tiptitle: book.name,
					text: book.name
				}
			});
			const block = createWenkuBlock({
				type: 'imagelist',
				parent: '#centers',
				title: TEXT_GUI_INDEX_LATERBOOKS,
				items: items
			});
			settip($(block, '.blocktitle'), TEXT_TIP_INDEX_LATERREADS);
		}
    }

    // 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 = $('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() {
			switch (novelInfo.type) {
				case 'txt':
					downloadGUI_txt();
					break;
				case 'txtfull':
					downloadGUI_txtfull();
					break;
				case 'umd':
					downloadGUI_umd();
					break;
				case 'jar':
					downloadGUI_jar();
					break;
				default:
					DoLog(LogLevel.Warning, 'pageDownload.downloadGUI: Unknown download type');
			}
		}

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

			// define vars
			let i;

			const tbody = $('table>tbody');
			const header = $(tbody, 'th').parentElement;
			const thead = $(header, '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 = $All(tbody, '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, 'td.even').cloneNode(true);
				const links = $All(newTd, 'a');
				for (const a of links) {
					a.classList.add(CLASSNAME_BUTTON);
					a.info = {
						description: 'volume download button',
						name: novelInfo.volumeNames[index],
						filename: TEXT_GUI_SDOWNLOAD_FILENAME
							.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 = $All(newTr, '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 = $All(newTds[3], '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;
			}
		}

		// Donwload GUI for txtfull
		function downloadGUI_txtfull() {
			const container = $('#content>table tr>td:nth-child(3)');
			const links = arrfilter(container.children, (e,i)=>([1,3,5].includes(i)));
			const TEXTS = ['简体(G)', '简体(U)', '繁体(U)'];
			const elms = [];

			elms.push($CrE('br'));
			elms.push(document.createTextNode('程序重命名('));
			for (let i = 0; i < links.length; i++) {
				const a = links[i];
				const btn = $CrE('a');
				btn.href = a.previousElementSibling.href;
				btn.download = novelInfo.novelName + '.txt';
				btn.innerHTML = TEXTS[i];
				btn.classList.add(CLASSNAME_BUTTON);
				btn.addEventListener('click', downloadFromA);
				elms.push(btn);
				i+1 < links.length && elms.push(a.previousSibling.cloneNode());
			}
			elms.push(document.createTextNode(')'));

			for (const elm of elms) {
				container.appendChild(elm);
			}
		}

		// Donwload GUI for umd
		function downloadGUI_umd() {
			const container = $('#content>table tr>td:nth-child(5)');
			const a = container.firstChild;
			const btn = $CrE('a');
			btn.href = a.href;
			btn.download = novelInfo.novelName + '.umd';
			btn.innerHTML = '重命名下载';
			btn.classList.add(CLASSNAME_BUTTON);
			btn.addEventListener('click', downloadFromA);
			a.insertAdjacentElement('afterend', btn);
			a.insertAdjacentElement('afterend', $CrE('br'));
		}

		// Donwload GUI for jar
		function downloadGUI_jar() {
			const container = $('#content>table tr>td:nth-child(5)');
			const links = arrfilter(container.children, ()=>(true));
			const TEXTS = ['重命名JAR', '重命名JAD'];
			const EXTS = ['.jar', '.jad'];
			const elms = [];

			elms.push($CrE('br'));
			for (let i = 0; i < links.length; i++) {
				const a = links[i];
				const btn = $CrE('a');
				btn.href = a.href;
				btn.download = novelInfo.novelName + EXTS[i];
				btn.innerHTML = TEXTS[i];
				btn.classList.add(CLASSNAME_BUTTON);
				btn.addEventListener('click', downloadFromA);
				elms.push(btn);
				i+1 < links.length && elms.push(a.nextSibling.cloneNode());
			}

			for (const elm of elms) {
				container.appendChild(elm);
			}

			$('#content>table tr>th:nth-child(4)').setAttribute('width', '47%');
			$('#content>table tr>th:nth-child(5)').setAttribute('width', '20%');
		}

		function downloadFromA(e) {
			e.preventDefault();

			const btn = e.target;
			const url = btn.href;

			downloadFile({
				url: url,
				name: btn.download
			});
		}

		// Get all name display elements
		function getAllNameEles() {
            return $All('.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 = $All('#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);
				});
				settip(serverEles[i], TEXT_TIP_SERVERCHANGE);
			}
		}

        // Change all server elements
        function changeAllServers(server) {
            let i;
            const allA = $All('.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 + '/');
        }

		// Array.prototype.filter
		function arrfilter(arr, callback) {
			return Array.prototype.filter.call(arr, callback);
		}
    }

	// Login page add-on
	function pageLogin() {
		const form = $('form[name="frmlogin"]');
		if (!form) {return false;}
		const eleUsername = $(form, 'input.text[name="username"]');
		const elePassword = $(form, 'input.text[name="password"]')

		catchAccount();

		// Save account info
		function catchAccount() {
			form.addEventListener('submit', () => {
				const config = CONFIG.GlobalConfig.getConfig();
				const username = eleUsername.value;
				const password = elePassword.value;
				config.users = config.users ? config.users : {};
				config.users[username] = {
					username: username,
					password: password
				}
				CONFIG.GlobalConfig.saveConfig(config);
			});
		}
	}

	// Account fast switching
	function multiAccount() {
		if (!$('.fl')) {return false;};
		GUI();

		function GUI() {
			// Add switch select
			const eleTopLeft = $('.fl');
			const eletext    = $CrE('span');
			const sltSwitch  = $CrE('select');
			eletext.innerText = TEXT_GUI_ACCOUNT_SWITCH;
			eletext.classList.add(CLASSNAME_TEXT);
			eletext.style.marginLeft = '0.5em';
			eleTopLeft.appendChild(eletext);
			eleTopLeft.appendChild(sltSwitch);

			// Not logged in, create and select an empty option
			// Select current user's option
			if (!getUserName()) {
				appendOption(TEXT_GUI_ACCOUNT_NOTLOGGEDIN, '').selected = true;
			};

			// Add select options
			const userConfig = CONFIG.GlobalConfig.getConfig();
			const users = userConfig.users ? userConfig.users : {};
			const names = Object.keys(users);
			if (names.length === 0) {
				appendOption(TEXT_GUI_ACCOUNT_NOACCOUNT, '');
				settip(sltSwitch, TEXT_TIP_ACCOUNT_NOACCOUNT);
			}
			for (const username of names) {
				appendOption(username, username)
			}

			// Select current user's option
			if (getUserName()) {selectCurUser();};

			// onchange: switch account
			sltSwitch.addEventListener('change', (e) => {
				const select = e.target;
				if (!select.value || !confirm(TEXT_GUI_ACCOUNT_CONFIRM.replace('{N}', select.value))) {
					selectCurUser();
					destroyEvent(e);
					return;
				}

				switchAccount(select.value);
			});

			function appendOption(text, value) {
				const option = $CrE('option');
				option.innerText = text;
				option.value = value;
				sltSwitch.appendChild(option);
				return option;
			}

			function selectCurUser() {
				for (const option of $All(sltSwitch, 'option')) {
					option.selected = getUserName().toLowerCase() === option.value.toLowerCase();
				}
			}
		}

		function switchAccount(username) {
			// Logout
			alertify.notify(TEXT_ALT_ACCOUNT_WORKING_LOGOFF);
			GM_xmlhttpRequest({
				method: 'GET',
				url: URL_USRLOGOFF,
				onload: function(response) {
					// Login
					alertify.notify(TEXT_ALT_ACCOUNT_WORKING_LOGIN);
					const account = CONFIG.GlobalConfig.getConfig().users[username];
					const data = DATA_XHR_LOGIN
						.replace('{U}', $URL.encode(account.username))
						.replace('{P}', $URL.encode(account.password))
						.replace('{C}', $URL.encode('315360000')) // Expire time: 1 year
					GM_xmlhttpRequest({
						method: 'POST',
						url: URL_USRLOGIN,
						data: data,
						headers: {
							"Content-Type": "application/x-www-form-urlencoded"
						},
						onload: function() {
							let box = alertify.success(TEXT_ALT_ACCOUNT_SWITCHED.replace('{N}', username));
							redirectGMStorage(getUserID());
							DoLog(LogLevel.Info, 'GM_storage redirected to ' + String(getUserID()));
							const timeout = setTimeout(()=>{location.href=location.href;}, 3000);
							box.callback = (isClicked) => {
								isClicked && clearTimeout(timeout);
							};
						}
					})
				}
			})
		}
	}

	// API page and its sub pages add-on
	function pageAPI(API) {
		addStyle(CSS_PAGE_API, 'plus_api_css');
		//logAPI();

		let result;
		switch(API) {
			case 'modules/article/addbookcase.php':
				result = pageAddbookcase();
				break;
			case 'modules/article/packshow.php':
				result = pagePackshow();
				break;
			default:
				result = logAPI();
		}

		return result;

		function logAPI() {
			DoLog('This is wenku API page.');
			DoLog('API is: [' + API + ']');
			DoLog('There is nothing to do. Quiting...');
		}

		function pageAddbookcase() {

			// Append link to bookcase page
			addBottomButton({
				href: 'https://www.wenku8.net/modules/article/bookcase.php',
				innerHTML: TEXT_GUI_API_ADDBOOKCASE_TOBOOKCASE
			});

			// Append link to remove from bookcase (not finished)
			/*addBottomButton({
				href: 'https://www.wenku8.net/modules/article/bookcase.php?delid=' + getUrlArgv('bid'),
				innerHTML: TEXT_GUI_API_ADDBOOKCASE_REMOVE,
				onclick: function() {
					confirm('确实要将本书移出书架么?')
				}
			});*/
		}

		function pagePackshow() {
			// Load packshow page
			loadPage();

			// Packshow page loader
			function loadPage() {
				// Data
				const language = getLang();
				const aid = getUrlArgv('id');
				const type = getUrlArgv('type');

				if (!['txt', 'txtfull', 'umd', 'jar'].includes(type)) {
					return false;
				}

				// Hide api box
				const apiBox = $('body>div:nth-child(1)');
				apiBox.style.display = 'none';

				// Disable api css
				addStyle('', 'plus_api_css');

				// AsyncManager
				const resource = {xmlIndex: null, xmlInfo: null, oDoc: null};
				const AM = new AsyncManager();
				AM.onfinish = fetchFinish;

				// Show soft alert
				alertify.message(TEXT_TIP_API_PACKSHOW_LOADING);

				// Set Title
				document.title = TEXT_GUI_API_PACKSHOW_TITLE_LOADING;

				// Load model page
				const bgImage = $('body>.plus_cbty_image');
				AM.add();
				getDocument(URL_PACKSHOW.replace('{A}', "1").replace('{T}', type), function(oDoc) {
					resource.oDoc = oDoc;

					// Insert body elements
					const nodes = Array.prototype.map.call(oDoc.body.childNodes, (elm) => (elm));
					for (const node of nodes) {
						document.body.insertBefore(node, bgImage);
					}

					// Insert css link and scripts
					const links = Array.prototype.map.call($All(oDoc, 'link[rel="stylesheet"][href]'), (elm) => (elm));
					const olinks = Array.prototype.map.call($All('link[rel="stylesheet"][href]'), (elm) => (elm));
					for (const link of links) {
						if (!link.href.startsWith('http')) {continue;}
						for (const olink of Array.prototype.filter.call(olinks, (l) => (l.href === link.href))) {olink.parentElement.removeChild(olink);}
						document.head.appendChild(link);
					}
					const scripts = Array.prototype.map.call($All(oDoc, 'script[src]'), (elm) => (elm));
					for (const script of scripts) {
						if (!script.src.startsWith('http')) {continue;}
						if (Array.prototype.filter.call($All('script[src]'), (s) => (s.src === script.src)).length > 0) {continue;}
						document.head.appendChild(script);
					}

					AM.finish();
				});

				// Load novel index
				AM.add();
				AndAPI.getNovelIndex({
					aid: aid,
					lang: language,
					callback: function(xml) {
						resource.xmlIndex = xml;
						AM.finish();
					}
				});
				AM.add();
				AndAPI.getNovelShortInfo({
					aid: aid,
					lang: language,
					callback: function(xml) {
						resource.xmlInfo = xml;
						AM.finish();
					}
				});

				AM.finishEvent = true;

				function fetchFinish() {
					// Resources
					const xmlIndex = resource.xmlIndex;
					const xmlInfo = resource.xmlInfo;
					const oDoc = resource.oDoc;

					// Elements
					const content = $('#content');
					const table = $(content, 'table');
					const tbody = $(table, 'tbody');

					// Data
					const name = $(xmlInfo, 'data[name="Title"]').childNodes[0].nodeValue;
					const lastupdate = $(xmlInfo, 'data[name="LastUpdate"]').getAttribute('value');
					const aBook = $(table, 'caption>a:first-child');
					const charsets = ['gbk', 'utf-8', 'big5', 'gbk', 'utf-8', 'big5'];
					const innerTexts = ['简体(G)', '简体(U)', '繁体(U)', '简体(G)', '简体(U)', '繁体(U)'];

					// Set Title
					document.title = TEXT_GUI_API_PACKSHOW_TITLE.replace('{N}', name);

					// Set book
					aBook.innerText = name;
					aBook.href = URL_BOOKINTRO.replace('{A}', aid);

					// Load book index
					loadIndex();

					// Soft alert
					alertify.success(TEXT_TIP_API_PACKSHOW_LOADED);

					// Enter common download page enhance
					pageDownload();

					// Book index loader
					function loadIndex() {
						switch (type) {
							case 'txt':
								loadIndex_txt();
								break;
							case 'txtfull':
								loadIndex_txtfull();
								break;
							case 'umd':
								loadIndex_umd();
								break;
							case 'jar':
								loadIndex_jar();
								break;
						}
					}

					// Book index loader for type txt
					function loadIndex_txt() {
						// Clear tbody trs
						for (const tr of $All(table, 'tr+tr')) {
							tbody.removeChild(tr);
						}

						// Make new trs
						for (const volume of $All(xmlIndex, 'volume')) {
							const tr = makeTr(volume);
							tbody.appendChild(tr);
						}

						function makeTr(volume) {
							const tr = $CrE('tr');
							const [tdName, td1, td2] = [$CrE('td'), $CrE('td'), $CrE('td')];
							const a = Array(6);
							const vid = volume.getAttribute('vid');
							const vname = volume.childNodes[0].nodeValue;

							// init tds
							tdName.classList.add('odd');
							td1.classList.add('even');
							td2.classList.add('even');
							td1.align = td2.align = 'center';

							// Set volume name
							tdName.innerText = vname;

							// Make <a> links
							for (let i = 0; i < a.length; i++) {
								a[i] = $CrE('a');
								a[i].target = '_blank';
								a[i].href = 'http://dl.wenku8.com/packtxt.php?aid=' + aid +
									'&vid=' + vid +
									(i >= 3 ? '&aname=' + $URL.encode(name) : '') +
									(i >= 3 ? '&vname=' + $URL.encode(vname) : '') +
									'&charset=' + charsets[i];
								a[i].innerText = innerTexts[i];
								(i < 3 ? td1 : td2).appendChild(a[i]);
							}

							// Insert whitespace textnode
							for (const i of [1, 2, 4, 5]) {
								(i < 3 ? td1 : td2).insertBefore(document.createTextNode('\n'), a[i]);
							}

							tr.appendChild(tdName);
							tr.appendChild(td1);
							tr.appendChild(td2);

							return tr;
						}
					}

					// Book index loader for type txtfull
					function loadIndex_txtfull() {
						const tr = $(tbody, 'tr+tr');
						const tds = Array.prototype.map.call(tr.children, (elm) => (elm));

						tds[0].innerText = lastupdate;
						tds[1].innerText = TEXT_GUI_UNKNOWN;
						for (const a of $All(tds[2], 'a')) {
							a.href = a.href.replace(/id=\d+/, 'id='+aid).replace(/fname=[^&]+/, 'fname='+$URL.encode(name));
						}
					}

					// Book index loader for type umd
					function loadIndex_umd() {
						const tr = $(tbody, 'tr+tr');
						const tds = toArray(tr.children);

						tds[0].innerText = tds[1].innerText = TEXT_GUI_UNKNOWN;
						tds[2].innerText = lastupdate;
						tds[3].innerText = $(xmlIndex, 'volume:first-child').childNodes[0].nodeValue + '—' + $(xmlIndex, 'volume:last-child').childNodes[0].nodeValue;
						const as = [].concat(toArray($All(tds[4], 'a'))).concat(toArray($All(table, 'caption>a+a')));
						for (const a of as) {
							a.href = a.href.replace(/id=\d+/, 'id='+aid);
						}
					}

					// Book index loader for type jar
					function loadIndex_jar() {
						// Currently type jar is the same as type umd
						loadIndex_umd();
					}

					function toArray(_arr) {
						return Array.prototype.map.call(_arr, (elm) => (elm));
					}
				}
			}
		}

		// Add a bottom-styled botton into bottom line, to the first place
		function addBottomButton(details) {
			const aClose = $('a[href="javascript:window.close()"]');
			const bottom = aClose.parentElement;
			const a = $CrE('a');
			const t1 = document.createTextNode('[');
			const t2 = document.createTextNode(']');
			const blank = $CrE('span');
			blank.innerHTML = ' ';
			blank.style.width = '0.5em';
			a.href = details.href;
			a.innerHTML = details.innerHTML;
			a.onclick = details.onclick;
			[blank, t2, a, t1].forEach((elm) => {bottom.insertBefore(elm, bottom.childNodes[0]);});
		}
	}

	// 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 = $All('.block');
		const close = $All('a[href="javascript:window.close()"]');
		return block.length === 1 && close.length === 1;
	}

	// Basic functions
	// querySelector
	function $() {
		switch(arguments.length) {
			case 2:
				return arguments[0].querySelector(arguments[1]);
				break;
			default:
				return document.querySelector(arguments[0]);
		}
	}
	// querySelectorAll
	function $All() {
		switch(arguments.length) {
			case 2:
				return arguments[0].querySelectorAll(arguments[1]);
				break;
			default:
				return document.querySelectorAll(arguments[0]);
		}
	}
	// createElement
	function $CrE() {
		switch(arguments.length) {
			case 2:
				return arguments[0].createElement(arguments[1]);
				break;
			default:
				return document.createElement(arguments[0]);
		}
	}
	// Object1[prop] ==> Object2[prop]
	function copyProp(obj1, obj2, prop) {obj1[prop] !== undefined && (obj2[prop] = obj1[prop]);}
	function copyProps(obj1, obj2, props) {props.forEach((prop) => (copyProp(obj1, obj2, prop)));}

	// Send reply for bookreview
	// Arg: {rid, title, content, onload:(oDoc)=>{}, onerror:()=>{}}
	function sendReviewReply(detail) {
		if (typeof($URL) !== 'object') {
			DoLog(LogLevel.Error, 'sendReviewReply: $URL not found.');
			return false;
		}
		const data = '&ptitle=' + $URL.encode(detail.title) + '&pcontent=' + $URL.encode(detail.content);
		const url = 'https://www.wenku8.net/modules/article/reviewshow.php?rid=' + detail.rid.toString();
		GM_xmlhttpRequest({
			method: 'POST',
			url: url,
			headers: {
				'Content-Type': 'application/x-www-form-urlencoded'
			},
			data: data,
			responseType: 'blob',
			onload: function (response) {
				if (!detail.onload) {return false;}
				parseDocument(response.response, detail.onload);
			},
			onerror: function (e) {
				detail.onerror && detail.onerror(e);
			}
		});
	}

	// Android API set
	function AndroidAPI() {
		const AA = this;
		const DParser = new DOMParser();

		const encode = AA.encode = function(str) {
			return '&appver=1.13&request=' + btoa(str) + '&timetoken=' + (new Date().getTime());
		};

		const request = AA.request = function(details) {
			const url = details.url;
			const type = details.type || 'text';
			const callback = details.callback || function() {};
			const args = details.args || [];
			GM_xmlhttpRequest({
				method: 'POST',
				url: 'http://app.wenku8.com/android.php',
				headers: {
					'Content-Type': 'application/x-www-form-urlencoded',
					'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 7.1.2; unknown Build/NZH54D)'
				},
				data: encode(url),
				onload: function(e) {
					let result;
					switch (type) {
						case 'xml':
							result = DParser.parseFromString(e.responseText, 'text/xml');
							break;
						case 'text':
							result = e.responseText;
							break;
					}
					callback.apply(null, [result].concat(args));
				},
				onerror: function(e) {
					DoLog(LogLevel.Error, 'AndroidAPI.request Error while requesting "' + url + '"');
					DoLog(LogLevel.Error, e);
				}
			});
		};

		// aid, lang, callback, args
		AA.getNovelShortInfo = function(details) {
			const aid = details.aid;
			const lang = details.lang;
			const callback = details.callback || function() {};
			const args = details.args || [];
			const url = 'action=book&do=info&aid=' + aid + '&t=' + lang;
			request({
				url: url,
				callback: callback,
				args: args,
				type: 'xml'
			});
		}

		// aid, lang, callback, args
		AA.getNovelIndex = function(details) {
			const aid = details.aid;
			const lang = details.lang;
			const callback = details.callback || function() {};
			const args = details.args || [];
			const url = 'action=book&do=list&aid=' + aid + '&t=' + lang;
			request({
				url: url,
				callback: callback,
				args: args,
				type: 'xml'
			});
		};

		// aid, cid, lang, callback, args
		AA.getNovelContent = function(details) {
			const aid = details.aid;
			const cid = details.cid;
			const lang = details.lang;
			const callback = details.callback || function() {};
			const args = details.args || [];
			const url = 'action=book&do=text&aid=' + aid + '&cid=' + cid + '&t=' + lang;
			request({
				url: url,
				callback: callback,
				args: args,
				type: 'text'
			});
		};
	}

	// Create reply-area with enhanced UBBEditor
	function makeEditor(parent, rid, aid) {
		parent.innerHTML = `<form name="frmreview" method="post" action="https://www.wenku8.net/modules/article/reviewshow.php?rid={RID}"><table class="grid" width="100%" align="center"><tbody><tr><td class="odd" width="25%">标题</td><td class="even"><input type="text" class="text" name="ptitle" id="ptitle" size="60" maxlength="60" value="" /></td></tr></tbody><caption>回复书评:</caption><tbody><tr><td class="odd" width="25%">内容(每帖+1积分)</td><td class="even"><textarea class="textarea" name="pcontent" id="pcontent" cols="60" rows="12"></textarea></td></tr><tr><td class="odd" width="25%">&nbsp;</td><td class="even"><input type="submit" name="Submit" class="button" value="发表书评(Ctrl+Enter)" style="padding: 0.3em 0.4em; height: auto;" /><span></span></td></tr></tbody></table></form>`.replace('{RID}', rid).replace('{AID}', aid);
		const script = $CrE('script');
		script.innerHTML = 'loadJs("https://www.wenku8.net/scripts/ubbeditor_gbk.js", function(){UBBEditor.Create("pcontent");});';
		$(parent, '#pcontent').parentElement.appendChild(script);
		areaReply();
	}

	// getMyUserDetail with soft alerts
	function refreshMyUserDetail(callback, args=[]) {
		alertify.notify(TEXT_ALT_USRDTL_REFRESH);
		getMyUserDetail(function() {
			const alertBox = alertify.success(TEXT_ALT_USRDTL_REFRESHED);

			// rewrite onclick function from copying to showing details
			alertBox.callback = function(isClicked) {
				isClicked && alertify.message(altMyUserDetail()/*JSON.stringify(getMyUserDetail())*/);
			}

			// callback if exist
			callback ? callback.apply(args) : function() {};
		})
	}

	// 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 not logged in, return false
	// if callback is not a function, then will just request&store but not callback
	function getMyUserDetail(callback, args=[]) {
		if (getUserID() === null) {
			return false;
		}
		if (callback) {
			requestWeb();
			return true;
		} else {
			const storage = CONFIG.userDtlePrefs.getConfig();
			if (!storage.userDetail && !storage.userFriends) {
				DoLog(LogLevel.Warning, 'Attempt to read userDetail from gm_storage but no record found');
				return false;
			};
			const userDetail = storage;
			return userDetail;
		}

		function requestWeb() {
			const lastStorage = CONFIG ? CONFIG.userDtlePrefs.getConfig() : undefined;
			let restXHR = 2;
			let storage = {};

			// Request userDetail
			getDocument(URL_USRDETAIL, detailLoaded)

			// Request userFriends
			getDocument(URL_USRFRIEND, friendLoaded)

			function detailLoaded(oDoc) {
				const content = $(oDoc, '#content');
				storage.userDetail = {
					userID: Number($(content, 'tr:nth-child(1)>.even').innerText),  // '用户ID'
					userLink: $(content, 'tr:nth-child(2)>.even').innerText,        // '推广链接'
					userName: $(content, 'tr:nth-child(3)>.even').innerText,        // '用户名'
					displayName: $(content, 'tr:nth-child(4)>.even').innerText,     // '用户昵称'
					userType: $(content, 'tr:nth-child(5)>.even').innerText,        // '等级'
					userGrade: $(content, 'tr:nth-child(6)>.even').innerText,       // '头衔'
					gender: $(content, 'tr:nth-child(7)>.even').innerText,          // '性别'
					email: $(content, 'tr:nth-child(8)>.even').innerText,           // 'Email'
					qq: $(content, 'tr:nth-child(9)>.even').innerText,              // 'QQ'
					msn: $(content, 'tr:nth-child(10)>.even').innerText,            // 'MSN'
					site: $(content, 'tr:nth-child(11)>.even').innerText,           // '网站'
					signupDate: $(content, 'tr:nth-child(13)>.even').innerText,     // '注册日期'
					contibute: $(content, 'tr:nth-child(14)>.even').innerText,      // '贡献值'
					exp: $(content, 'tr:nth-child(15)>.even').innerText,            // '经验值'
					credit: $(content, 'tr:nth-child(16)>.even').innerText,         // '现有积分'
					friends: $(content, 'tr:nth-child(17)>.even').innerText,        // '最多好友数'
					mailbox: $(content, 'tr:nth-child(18)>.even').innerText,        // '信箱最多消息数'
					bookcase: $(content, 'tr:nth-child(19)>.even').innerText,       // '书架最大收藏量'
					vote: $(content, 'tr:nth-child(20)>.even').innerText,           // '每天允许推荐次数'
					sign: $(content, 'tr:nth-child(22)>.even').innerText,           // '用户签名'
					intoduction: $(content, 'tr:nth-child(23)>.even').innerText,    // '个人简介'
					userImage: $(content, 'tr>td>img').src                          // '头像'
				}
				loaded();
			}

			function friendLoaded(oDoc) {
				const content = $(oDoc, '#content');
				const trs = $All(content, 'tr');
				const friends = [];
				const lastFriends = lastStorage ? lastStorage.userFriends : undefined;

				for (let i = 1; i < trs.length; i++) {
					getFriends(trs[i]);
				}
				storage.userFriends = friends;
				loaded();

				function getFriends(tr) {
					// Check if userID exist
					if (isNaN(Number($(tr.children[2], 'a').href.match(/\?uid=(\d+)/)[1]))) {return false;};

					// Collect information
					let friend = {
						userID: Number($(tr.children[2], 'a').href.match(/\?uid=(\d+)/)[1]),
						userName: tr.children[0].innerText,
						signupDate: tr.children[1].innerText
					}
					friend = fillLocalInfo(friend)
					friends.push(friend);
				}

				function fillLocalInfo(friend) {
					if (!lastFriends) {return friend;};
					for (const f of lastFriends) {
						if (f.userID === friend.userID) {
							for (const [key, value] of Object.entries(f)) {
								if (friend.hasOwnProperty(key)) {continue;};
								friend[key] = value;
							}
							break;
						}
					}
					return friend;
				}
			}

			function loaded() {
				restXHR--;
				if (restXHR === 0) {
					// Save to gm_storage
					if (CONFIG) {
						storage.lasttime = getTime('-', false);
						CONFIG.userDtlePrefs.saveConfig(storage);
					}

					// Callback
					typeof(callback) === 'function' ? callback.apply(null, [storage].concat(args)) : function() {};
				}
			}
		}
	}

	// Show userdetail in an alertify alertbox
	function altMyUserDetail() {
		const json = getMyUserDetail();
		alertify.message(JSON.stringify(getMyUserDetail()));
	}

	function exportConfig(noPass=false) {
		// Get config
		const config = {};
		const getValue = window.getValue ? window.getValue : GM_getValue;
		const listValues = window.listValues ? window.listValues : GM_listValues;
		for (const key of listValues()) {
			config[key] = getValue(key);
		}

		// Remove username and password if required
		noPass && (config[KEY_CM].users = {});

		// Download
		const text = JSON.stringify(config);
		const name = '轻小说文库+_配置文件({P})_v{V}_{T}.wkp'.replace('{P}', noPass ? '无账号密码' : '含账号密码').replace('{V}', GM_info.script.version).replace('{T}', getTime());
		downloadText(text, name);
		alertify.success(TEXT_ALT_CONFIG_EXPORTED.replace('{N}', name));
	}

	function importConfig(json) {
		// Redirect
		redirectGMStorage();

		// Preserve users
		const users = GM_getValue('Config-Manager').users;

		// Delete json
		for (const [key, value] of GM_listValues()) {
			GM_deleteValue(key, value);
		}

		// Set json
		for (const [key, value] of Object.entries(json)) {
			GM_setValue(key, value);
		}

		// Preserve users
		const config = GM_getValue('Config-Manager', {});
		if (!config.users) {config.users = {}}
		for (const [name, user] of Object.entries(users)) {
			config.users[name] = user;
		}
		GM_setValue('Config-Manager', config);

		// Reload
		location.reload();
	}

	function sortLaterReads(books, sortby) {
		const sorter = FUNC_LATERBOOK_SORTERS[sortby].sorter;
		return Object.values(books).sort(sorter);
	}

	function getUserID() {
        const match = $URL.decode(document.cookie).match(/jieqiUserId=(\d+)/);
		const id = match && match[1] ? Number(match[1]) : null;
		return isNaN(id) ? null : id;
	}

	function getUserName() {
		const match = $URL.decode(document.cookie).match(/jieqiUserName=([^, ;]+)/);
		const name = match ? match[1] : null;
		return name;
	}

	// Reload page without re-sending form data, and keeps reviewshow-page
	function reloadPage() {
		const url = /^https?:\/\/www\.wenku8\.net\/modules\/article\/reviewshow\.php/.test(location.href) ? URL_REVIEWSHOW_2.replace('{R}', getUrlArgv('rid')).replace('{P}', $('#pagelink>strong').innerText) : location.href;
		location.href = url;
	}

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

	// New tipobj movement method. Makes sure the tipobj stay close with the mouse.
	function tipscroll() {
		if (!tipready) {return false;}

		DoLog('tipscroll executed. ')
		tipobj.style.position = 'fixed';
		window.addEventListener('mousemove', tipmoveplus)
		return true;

		function tipmoveplus(e) {
			tipobj.style.left = e.clientX + tipx + 'px';
			tipobj.style.top = e.clientY + tipy + 'px';
		}
	}

	// show & hide tip when mouse in & out. accepts tip as a string or a function that returns the tip string
	function settip(elm, tip) {
		typeof(tip) === 'string' && (elm.tiptitle = tip);
		typeof(tip) === 'function' && (elm.tipgetter = tip);
		elm.removeEventListener('mouseover', showtip);
		elm.removeEventListener('mouseout', hidetip);
		elm.addEventListener('mouseover', showtip);
		elm.addEventListener('mouseout', hidetip);
	}

	function showtip(e) {
		if (e && e.currentTarget && (e.currentTarget.tiptitle || e.currentTarget.tipgetter)) {
			const tip = e.currentTarget.tiptitle || e.currentTarget.tipgetter();
			if (tipready) {
				tipshow(tip);
				e.currentTarget.title && e.currentTarget.removeAttribute('title');
			} else {
				e.currentTarget.title = e.currentTarget.tiptitle;
			}
		} else if (typeof(e) === 'string') {
			tipready && tipshow(e);
		}
	}

	function hidetip() {
		tipready && tiphide();
	}

	// Side-located control panel
	// Requirements: FontAwesome, tooltip.css(from https://github.com/felipefialho/css-components/blob/main/build/tooltip/tooltip.css)
	// Use 'new' keyword
	function SidePanel() {
		// Public SP
		const SP = this;
		const elms = SP.elements = {};

		// Private _SP
		// keys start with '_' shouldn't be modified
		const _SP = {
			_id: {
				css: 'sidepanel-style',
				usercss: 'sidepanel-style-user',
				panel: 'sidepanel-panel'
			},
			_class: {
				button: 'sidepanel-button'
			},
			_css: '#sidepanel-panel {position: fixed; background-color: #00000000; padding: 0.5vmin; line-height: 3.5vmin; height: auto; display: flex; transition-duration: 0.3s; z-index: 9999999999;} #sidepanel-panel.right {right: 3vmin;} #sidepanel-panel.bottom {bottom: 3vmin; flex-direction: column-reverse;} #sidepanel-panel.left {left: 3vmin;} #sidepanel-panel.top {top: 3vmin; flex-direction: column;} .sidepanel-button {padding: 1vmin; margin: 0.5vmin; font-size: 3.5vmin; border-radius: 10%; text-align: center; color: #00000088; background-color: #FFFFFF88; box-shadow:3px 3px 2px #00000022; user-select: none; transition-duration: inherit;} .sidepanel-button:hover {color: #FFFFFFDD; background-color: #000000DD;}',
			_directions: ['left', 'right', 'top', 'bottom']
		};

		Object.defineProperty(SP, 'css', {
			configurable: false,
			enumerable: true,
			get: () => (_SP.css),
			set: (css) => {
				_SP.css = css;
				spAddStyle(css, _SP._id.css);
			}
		});
		Object.defineProperty(SP, 'usercss', {
			configurable: false,
			enumerable: true,
			get: () => (_SP.usercss),
			set: (usercss) => {
				_SP.usercss = usercss;
				spAddStyle(usercss, _SP._id.usercss);
			}
		});
		SP.css = _SP._css;

		SP.create = function() {
			// Create panel
			const panel = elms.panel = document.createElement('div');
			panel.id = _SP._id.panel;
			SP.setPosition('bottom-right');
			document.body.appendChild(panel);

			// Prepare buttons
			elms.buttons = [];
		}

		// Insert a button to given index
		// details = {index, text, faicon, id, tip, className, onclick, listeners}, all optional
		// listeners = [..[..args]]. [..args] will be applied as button.addEventListener's args
		// faicon = 'fa-icon-name-classname fa-icon-style-classname', this arg stands for a FontAwesome icon to be inserted inside the botton
		// Returns the button(HTMLDivElement), including button.faicon(HTMLElement/HTMLSpanElement in firefox, <i>) if faicon is set
		SP.insert = function(details) {
			const index = details.index;
			const text = details.text;
			const faicon = details.faicon;
			const id = details.id;
			const tip = details.tip;
			const className = details.className;
			const onclick = details.onclick;
			const listeners = details.listeners || [];

			const button = document.createElement('div');
			text && (button.innerHTML = text);
			id && (button.id = id);
			tip && setTooltip(button, tip); //settip(button, tip);
			className && (button.className = className);
			onclick && (button.onclick = onclick);
			if (faicon) {
				const i = document.createElement('i');
				i.className = faicon;
				button.faicon = i;
				button.appendChild(i);
			}
			for (const listener of listeners) {
				button.addEventListener.apply(button, listener);
			}
			button.classList.add(_SP._class.button);

			elms.buttons = insertItem(elms.buttons, button, index);
			index < elms.buttons.length ? elms.panel.insertBefore(button, elms.panel.children[index]) : elms.panel.appendChild(button);
			return button;
		}

		// Append a button
		SP.add = function(details) {
			details.index = elms.buttons.length;
			return SP.insert(details);
		}

		// Remove a button
		SP.remove = function(arg) {
			let index, elm;
			if (arg instanceof HTMLElement) {
				elm = arg;
				index = elms.buttons.indexOf(elm);
			} else if (typeof(arg) === 'number') {
				index = arg;
				elm = elms.buttons[index];
			} else if (typeof(arg) === 'string') {
				elm = $(elms.panel, arg);
				index = elms.buttons.indexOf(elm);
			}

			elms.buttons = delItem(elms.buttons, index);
			elm.parentElement.removeChild(elm);
		}

		// Sets the display position by texts like 'right-bottom'
		SP.setPosition = function(pos) {
			const poses = _SP.direction = pos.split('-');
			const avails = _SP._directions;

			// Available check
			if (poses.length !== 2) {return false;}
			for (const p of poses) {
				if (!avails.includes(p)) {return false;}
			}

			// remove all others
			for (const p of avails) {
				elms.panel.classList.remove(p);
			}

			// add new pos
			for (const p of poses) {
				elms.panel.classList.add(p);
			}

			// Change tooltips' direction
			elms.buttons && elms.buttons.forEach(function(button) {
				if (button.getAttribute('role') === 'tooltip') {
					setTooltipDirection(button)
				}
			});
		}

		// Gets the current display position
		SP.getPosition = function() {
			return _SP.direction.join('-');
		}

		// Append a style text to document(<head>) with a <style> element
		// Replaces existing id-specificed <style>s
		function spAddStyle(css, id) {
			const style = document.createElement("style");
			id && (style.id = id);
			style.textContent = css;
			for (const elm of $All('#'+id)) {
				elm.parentElement && elm.parentElement.removeChild(elm);
			}
			document.head.appendChild(style);
		}

		// Set a tooltip to the element
		function setTooltip(elm, text, direction='auto') {
			elm.tooltip = tippy(elm, {
				content: text,
				arrow: true,
				hideOnClick: false
			});

			// Old version, uses tooltip.css
			/*
			elm.setAttribute('role', 'tooltip');
			elm.setAttribute('aria-label', text);
			*/

			setTooltipDirection(elm, direction);
		}

		function setTooltipDirection(elm, direction='auto') {
			direction === 'auto' && (direction = _SP.direction.includes('left') ? 'right' : 'left');
			if (!_SP._directions.includes(direction)) {throw new Error('setTooltip: invalid direction');}

			// Tippy direction
			if (!elm.tooltip) {
				DoLog(LogLevel.Error, 'SidePanel.setTooltipDirection: Given elm has no tippy instance(elm.tooltip)');
				throw new Error('SidePanel.setTooltipDirection: Given elm has no tippy instance(elm.tooltip)');
			}
			elm.tooltip.setProps({
				placement: direction
			});

			// Old version, uses tooltip.css
			/*
			for (const dirct of _SP._directions) {
				elm.classList.remove('tooltip-'+dirct);
			}
			elm.classList.add('tooltip-'+direction);
			*/
		}

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

		// Insert an item into an array using given index. Returns the array but can NOT modify the original array directly!!
		function insertItem(arr, item, index) {
			arr = arr.slice(0, index).concat(item).concat(arr.slice(index));
			return arr;
		}
	}

	// Create a list gui like reviewshow.php##FontSizeTable
	// list = {display: '', id: '', parentElement: <*>, insertBefore: <*>, list: [{value: '', onclick: Function, tip: ''/Function}, ...], visible: bool, onshow: Function(bool shown), onhide: Function(bool hidden)}
	// structure: {div: <div>, ul: <ul>, list: [{li: <li>, button: <input>}, ...], visible: list.visible, show: Function, hide: Function, append: Function({...}), remove: Function(index), clear: Function, onshow: list.onshow, onhide: list.onhide}
	// Use 'new' keyword
	function PlusList(list) {
		const PL = this;

		// Make list
		const div = PL.div = document.createElement('div');
		const ul = PL.ul = document.createElement('ul');
		div.classList.add(CLASSNAME_LIST);
		div.appendChild(ul);
		list.display && (div.style.display = list.display);
		list.id && (div.id = list.id);
		list.parentElement && list.parentElement.insertBefore(div, list.insertBefore ? list.insertBefore : null);

		PL.list = [];
		for (const item of list.list) {
			appendItem(item);
		}

		// Attach properties
		let onshow = list.onshow ? list.onshow : function() {};
		let onhide = list.onhide ? list.onhide : function() {};
		let visible = list.visible;
		PL.create = createItem;
		PL.append = appendItem;
		PL.insert = insertItem;
		PL.remove = removeItem;
		PL.clear = removeAll;
		PL.show = showList;
		PL.hide = hideList;
		Object.defineProperty(PL, 'onshow', {
			get: function() {return onshow;},
			set: function(func) {
				onshow = func ? func : function() {};
			},
			configurable: false,
			enumerable: true
		});
		Object.defineProperty(PL, 'onhide', {
			get: function() {return onhide;},
			set: function(func) {
				onhide = func ? func : function() {};
			},
			configurable: false,
			enumerable: true
		});
		Object.defineProperty(PL, 'visible', {
			get: function() {return visible;},
			set: function(bool) {
				if (typeof(bool) !== 'boolean') {return false;};
				visible = bool;
				bool ? showList() : hideList();
			},
			configurable: false,
			enumerable: true
		});
		Object.defineProperty(PL, 'maxheight', {
			get: function() {return maxheight;},
			set: function(num) {
				if (typeof(num) !== 'number') {return false;};
				maxheight = num;
			},
			configurable: false,
			enumerable: true
		});

		// Apply configurations
		div.style.display = list.visible === true ? '' : 'none';

		// Functions
		function appendItem(item) {
			const listitem = createItem(item);
			ul.appendChild(listitem.li);
			PL.list.push(listitem);
			return listitem;
		}

		function insertItem(item, index, insertByNode=false) {
			const listitem = createItem(item);
			const children = insertByNode ? ul.childNodes : ul.children;
			const elmafter = children[index];
			ul.insertBefore(item.li, elmafter);
			inserttoarr(PL.list, listitem, index);
		}

		function createItem(item) {
			const listitem = {
				remove: () => {removeItem(listitem);},
				li: document.createElement('li'),
				button: document.createElement('input')
			};
			const li  = listitem.li;
			const btn = listitem.button;
			btn.type = 'button';
			btn.classList.add(CLASSNAME_LIST_BUTTON);
			li.classList.add(CLASSNAME_LIST_ITEM);
			item.value && (btn.value = item.value);
			item.onclick && btn.addEventListener('click', item.onclick);
			item.tip && settip(li, item.tip);
			item.tip && settip(btn, item.tip);
			li.appendChild(btn);
			return listitem;
		}

		function removeItem(itemorindex) {
			// Get index
			let index;
			if (typeof(itemorindex) === 'number') {
				index = itemorindex;
			} else if (typeof(itemorindex) === 'object') {
				index = PL.list.indexOf(itemorindex);
			} else {
				return false;
			}
			if (index < 0 || index >= PL.list.length) {
				return false;
			}

			// Remove
			const li = PL.list[index];
			ul.removeChild(li.li);
			delfromarr(PL.list, index);
			return li;
		}

		function removeAll() {
			const length = PL.list.length;
			for (let i = 0; i < length; i++) {
				removeItem(0);
			}
		}

		function showList() {
			if (visible) {return false;};
			onshow(false);
			div.style.display = '';
			onshow(true);
			visible = true;
		}

		function hideList() {
			if (!visible) {return false;};
			onhide(false);
			div.style.display = 'none';
			hidetip();
			onhide(true);
			visible = false;
		}

		// Support functions
		// Del an item from an array by provided index, returns the deleted item. MODIFIES the original array directly!!
		function delfromarr(arr, delIndex) {
			if (delIndex < 0 || delIndex > arr.length-1) {
				return false;
			}
			const deleted = arr[delIndex];
			for (let i = delIndex; i < arr.length-1; i++) {
				arr[i] = arr[i+1];
			}
			arr.pop();
			return deleted;
		}

		// Insert an item to an array by its provided index, returns the item itself. MODIFIES the original array directly!!
		function inserttoarr(arr, item, index) {
			if (index < 0 || index > arr.length-1) {
				return false;
			}
			for (let i = arr.length; i > index; i--) {
				arr[i] = arr[i-1];
			}
			arr[index] = item;
			return item;
		}
	}

	// A table-based setting panel using alertify-js
	// Requires: alertify-js
	// Use 'new' keyword
	// Usage:
	/*
		var panel = new SettingPanel({
			className: '',
			id: '',
			name: '',
			tables: [
				{
					className: '',
					id: '',
					name: '',
					rows: [
						{
							className: '',
							id: '',
							name: '',
							blocks: [
								{
									innerHTML / innerText: ''
									colSpan: 1,
									rowSpan: 1,
									className: '',
									id: '',
									name: '',
									children: [HTMLElement, ...]
								},
								...
							]
						},
						...
					]
				},
				...
			]
		});
	*/
	function SettingPanel(details={}) {
		const SP = this;
		SP.insertTable = insertTable;
		SP.appendTable = appendTable;
		SP.removeTable = removeTable;
		SP.remove = remove;
		SP.PanelTable = PanelTable;
		SP.PanelRow = PanelRow;
		SP.PanelBlock = PanelBlock;

		// <div> element
		const elm = $C('div');
		copyProps(details, elm, ['id', 'name', 'className']);
		elm.classList.add('settingpanel-container');

		// Configure object
		let css='', usercss='';
		SP.element = elm;
		SP.elements = {};
		SP.children = {};
		SP.tables = [];
		SP.length = 0;
		details.id !== undefined && (SP.elements[details.id] = elm);
		copyProps(details, SP, ['id', 'name']);
		Object.defineProperty(SP, 'css', {
			configurable: false,
			enumerable: true,
			get: function() {
				return css;
			},
			set: function(_css) {
				addStyle(_css, 'settingpanel-css');
				css = _css;
			}
		});
		Object.defineProperty(SP, 'usercss', {
			configurable: false,
			enumerable: true,
			get: function() {
				return usercss;
			},
			set: function(_usercss) {
				addStyle(_usercss, 'settingpanel-usercss');
				usercss = _usercss;
			}
		});
		SP.css = '.settingpanel-table {border-spacing: 0px; border-collapse: collapse; width: 100%; margin: 2em 0;} .settingpanel-block {border: 1px solid; text-align: center; vertical-align: middle; padding: 3px; text-align: left;}'

		// Create tables
		if (details.tables) {
			for (const table of details.tables) {
				if (table instanceof PanelTable) {
					appendTable(table);
				} else {
					appendTable(new PanelTable(table));
				}
			}
		}

		// Make alerity box
		const box = SP.alertifyBox = alertify.alert();
		clearChildNodes(box.elements.content);
		box.elements.content.appendChild(elm);
		box.elements.content.style.overflow = 'auto';
		box.setHeader(TEXT_GUI_DETAIL_MANAGE_HEADER);
		box.setting({
			maximizable: true,
			overflow: true
		});
		box.show();

		// Insert a Panel-Row
		// Returns Panel object
		function insertTable(table, index) {
			// Insert table
			!(table instanceof PanelTable) && (table = new PanelTable(table));
			index < SP.length ? elm.insertBefore(table.element, elm.children[index]) : elm.appendChild(table.element);
			insertItem(SP.tables, table, index);
			table.id !== undefined && (SP.children[table.id] = table);
			SP.length++;

			// Set parent
			table.parent = SP;

			// Inherit elements
			for (const [id, subelm] of Object.entries(table.elements)) {
				SP.elements[id] = subelm;
			}

			// Inherit children
			for (const [id, child] of Object.entries(table.children)) {
				SP.children[id] = child;
			}
			return SP;
		}

		// Append a Panel-Row
		// Returns Panel object
		function appendTable(table) {
			return insertTable(table, SP.length);
		}

		// Remove a Panel-Row
		// Returns Panel object
		function removeTable(index) {
			const table = SP.tables[index];
			SP.element.removeChild(table.element);
			removeItem(SP.rows, index);
			return SP;
		}

		// Remove itself from parentElement
		// Returns Panel object
		function remove() {
			SP.element.parentElement && SP.parentElement.removeChild(SP.element);
			return SP;
		}

		// Panel-Table object
		// Use 'new' keyword
		function PanelTable(details={}) {
			const PT = this;
			PT.insertRow = insertRow;
			PT.appendRow = appendRow;
			PT.removeRow = removeRow;
			PT.remove = remove

			// <table> element
			const elm = $C('table');
			copyProps(details, elm, ['id', 'name', 'className']);
			elm.classList.add('settingpanel-table');

			// Configure
			PT.element = elm;
			PT.elements = {};
			PT.children = {};
			PT.rows = [];
			PT.length = 0;
			details.id !== undefined && (PT.elements[details.id] = elm);
			copyProps(details, PT, ['id', 'name']);

			// Append rows
			if (details.rows) {
				for (const row of details.rows) {
					if (row instanceof PanelRow) {
						insertRow(row);
					} else {
						insertRow(new PanelRow(row));
					}
				}
			}

			// Insert a Panel-Row
			// Returns Panel-Table object
			function insertRow(row, index) {
				// Insert row
				!(row instanceof PanelRow) && (row = new PanelRow(row));
				index < PT.length ? elm.insertBefore(row.element, elm.children[index]) : elm.appendChild(row.element);
				insertItem(PT.rows, row, index);
				row.id !== undefined && (PT.children[row.id] = row);
				PT.length++;

				// Set parent
				row.parent = PT;

				// Inherit elements
				for (const [id, subelm] of Object.entries(row.elements)) {
					PT.elements[id] = subelm;
				}

				// Inherit children
				for (const [id, child] of Object.entries(row.children)) {
					PT.children[id] = child;
				}
				return PT;
			}

			// Append a Panel-Row
			// Returns Panel-Table object
			function appendRow(row) {
				return insertRow(row, PT.length);
			}

			// Remove a Panel-Row
			// Returns Panel-Table object
			function removeRow(index) {
				const row = PT.rows[index];
				PT.element.removeChild(row.element);
				removeItem(PT.rows, index);
				return PT;
			}

			// Remove itself from parentElement
			// Returns Panel-Table object
			function remove() {
				PT.parent instanceof SettingPanel && PT.parent.removeTable(PT.tables.indexOf(PT));
				return PT;
			}
		}

		// Panel-Row object
		// Use 'new' keyword
		function PanelRow(details={}) {
			const PR = this;
			PR.insertBlock = insertBlock;
			PR.appendBlock = appendBlock;
			PR.removeBlock = removeBlock;
			PR.remove = remove;

			// <tr> element
			const elm = $C('tr');
			copyProps(details, elm, ['id', 'name', 'className']);
			elm.classList.add('settingpanel-row');

			// Configure object
			PR.element = elm;
			PR.elements = {};
			PR.children = {};
			PR.blocks = [];
			PR.length = 0;
			details.id !== undefined && (PR.elements[details.id] = elm);
			copyProps(details, PR, ['id', 'name']);

			// Append blocks
			if (details.blocks) {
				for (const block of details.blocks) {
					if (block instanceof PanelBlock) {
						appendBlock(block);
					} else {
						appendBlock(new PanelBlock(block));
					}
				}
			}

			// Insert a Panel-Block
			// Returns Panel-Row object
			function insertBlock(block, index) {
				// Insert block
				!(block instanceof PanelBlock) && (block = new PanelBlock(block));
				index < PR.length ? elm.insertBefore(block.element, elm.children[index]) : elm.appendChild(block.element);
				insertItem(PR.blocks, block, index);
				block.id !== undefined && (PR.children[block.id] = block);
				PR.length++;

				// Set parent
				block.parent = PR;

				// Inherit elements
				for (const [id, subelm] of Object.entries(block.elements)) {
					PR.elements[id] = subelm;
				}

				// Inherit children
				for (const [id, child] of Object.entries(block.children)) {
					PR.children[id] = child;
				}
				return PR;
			};

			// Append a Panel-Block
			// Returns Panel-Row object
			function appendBlock(block) {
				return insertBlock(block, PR.length);
			}

			// Remove a Panel-Block
			// Returns Panel-Row object
			function removeBlock(index) {
				const block = PR.blocks[index];
				PR.element.removeChild(block.element);
				removeItem(PR.blocks, index);
				return PR;
			}

			// Remove itself from parent
			// Returns Panel-Row object
			function remove() {
				PR.parent instanceof PanelTable && PR.parent.removeRow(PR.parent.rows.indexOf(PR));
				return PR;
			}
		}

		// Panel-Block object
		// Use 'new' keyword
		function PanelBlock(details={}) {
			const PB = this;
			PB.remove = remove;

			// <td> element
			const elm = $C('td');
			copyProps(details, elm, ['innerText', 'innerHTML', 'colSpan', 'rowSpan', 'id', 'name', 'className']);
			elm.classList.add('settingpanel-block');

			// Configure object
			PB.element = elm;
			PB.elements = {};
			PB.children = {};
			details.id !== undefined && (PB.elements[details.id] = elm);
			copyProps(details, PB, ['id', 'name']);

			// Append to parent if need
			details.parent instanceof PanelRow && (PB.parent = details.parent.appendBlock(PB));

			// Append child elements if exist
			if (details.children) {
				for (const child of details.children) {
					elm.appendChild(child);
				}
			}

			// Remove itself from parent
			// Returns Panel-Block object
			function remove() {
				PB.parent instanceof PanelRow && PB.parent.removeBlock(PB.parent.blocks.indexOf(PB));
				return PB;
			}
		}

		function $(e) {return document.querySelector(e);}
		function $C(e) {return document.createElement(e);}
		function $R(e) {return $(e) && $(e).parentElement.removeChild($(e));}
		function clearChildNodes(elm) {for (const el of elm.childNodes) {elm.removeChild(el);}}
		function copyProp(obj1, obj2, prop) {obj1[prop] !== undefined && (obj2[prop] = obj1[prop]);}
		function copyProps(obj1, obj2, props) {props.forEach((prop) => (copyProp(obj1, obj2, prop)));}
		function insertItem(arr, item, index) {
			for (let i = arr.length; i > index ; i--) {
				arr[i] = arr[i-1];
			}
			arr[index] = item;
			return arr;
		}
		function removeItem(arr, index) {
			for (let i = index; i < arr.length-1; i++) {
				arr[i] = arr[i+1];
			}
			delete arr[arr.length-1];
			return arr;
		}
		function addStyle(css, id) {
			$R('#'+id);
			const style = $C('style');
			style.innerHTML = css;
			style.id = id;
			document.head.appendChild(style);
			return style
		}
	}

	// Create a left .block operatingArea
	// options = {type: '', ...opts}
	// Supported type: 'mypage', 'toplist'
	function createWenkuBlock(details) {
		// Args
		//title=TEXT_GUI_BLOCK_TITLE_DEFULT, append=false, options
		const title = details.title || TEXT_GUI_BLOCK_TITLE_DEFULT;
		const parent = ({'string': $(details.parent), 'object': details.parent})[typeof details.parent];
		const type = details.type ? details.type.toLowerCase() : null;
		const items = details.items;
		const options = details.options;

		// Standard block
		const stdBlock = makeStandardBlock();
		const block = stdBlock.block;
		const blocktitle = stdBlock.blocktitle;
		const blockcontent = stdBlock.blockcontent;

		blocktitle.innerHTML = title;
		makeContent();
		parent && parent.appendChild(block);

		return block;

		// Create a standard block structure
		function makeStandardBlock() {
			const block = $CrE('div'); block.classList.add('block');
			const blocktitle = $CrE('div'); blocktitle.classList.add('blocktitle');
			const blockcontent = $CrE('div'); blockcontent.classList.add('blockcontent');
			block.appendChild(blocktitle); block.appendChild(blockcontent);
			return {block: block, blocktitle: blocktitle, blockcontent: blockcontent};
		}

		function makeContent() {
			switch (type) {
				case 'mypage': typeMypage(); break;
				case 'toplist': typeToplist(); break;
				case 'imagelist': typeImglist(); break;
				case 'element': typeElement(); break;
				default: DoLog(LogLevel.Error, 'createWenkuBlock: Invalid block type');
			}
		}

		// Links such as https://www.wenku8.net/userdetail.php
		function typeMypage() {
			const ul = $CrE('ul');
			ul.classList.add('ulitem');
			for (const link of details.items) {
				const li = $CrE('li');
				const a = $CrE('a');
				a.href = link.href ? link.href : 'javascript: void(0);';
				link.href && (a.target = '_blank');
				link.tiptitle && settip(a, link.tiptitle);
				a.innerHTML = link.innerHTML;
				a.id = link.id ? link.id : '';
				li.appendChild(a);
				ul.appendChild(li);
			}
			blockcontent.appendChild(ul);
		}

		// Links such as top-books-list inside #right in index page
		// links = [...{href: '', innerHTML: '', tiptitle: '', id: ''}]
		function typeToplist() {
			const ul = $CrE('ul');
			ul.classList.add('ultop');
			for (const link of details.items) {
				const li = $CrE('li');
				const a = $CrE('a');
				a.href = link.href ? link.href : 'javascript: void(0);';
				link.href && (a.target = '_blank');
				link.tiptitle && settip(a, link.tiptitle);
				a.innerHTML = link.innerHTML;
				a.id = link.id ? link.id : '';
				li.appendChild(a);
				ul.appendChild(li);
			}
			blockcontent.appendChild(ul);
		}

		// Links with images like center blocks in index page
		function typeImglist() {
			const container = $CrE('div');
			container.style.height = '155px';

			for (const item of items) {
				const div = $CrE('div');
				div.setAttribute('style', 'float: left;text-align:center;width: 95px; height:155px;overflow:hidden;');

				const a = $CrE('a');
				a.href = item.href;
				a.target = '_blank';
				item.tiptitle && settip(a, item.tiptitle);

				const img = $CrE('img');
				img.src = item.src;
				setAttributes(img, {
					'border': '0',
					'width': '90',
					'height': '127'
				});
				a.appendChild(img);

				const br = $CrE('br');

				const a2 = $CrE('a');
				a2.href = item.href;
				a2.target = '_blank';
				a2.innerHTML = item.text;

				div.appendChild(a);
				div.appendChild(br);
				div.appendChild(a2);
				container.appendChild(div);
			}

			blockcontent.appendChild(container);
		}

		// Just append given elements into block content
		function typeElement() {
			const elms = Array.isArray(items) ? items : [items];
			for (const elm of elms) {
				blockcontent.appendChild(elm);
			}
		}

		// Set attributes to an element
		function setAttributes(elm, attributes) {
			for (const [name, attr] of Object.entries(attributes)) {
				elm.setAttribute(name, attr);
			}
		}
	}

	// Get a review's last page url
	function getLatestReviewPageUrl(rid, callback, args=[]) {
		const reviewUrl = 'https://www.wenku8.net/modules/article/reviewshow.php?rid=' + String(rid);
		getDocument(reviewUrl, firstPage, args);

		function firstPage(oDoc, ...args) {
			const url = $(oDoc, '#pagelink>a.last').href;
			args = [url].concat(args);
			callback.apply(null, args);
		};
	};

	// Upload image to KIENG images
	// details: {file: File, onload: Function, onerror: Function, type: 'sm.ms/jd/sg/tt/...'}
	function uploadImage(details) {
		const file    = details.file;
		const onload  = details.onload  ? details.onload  : function() {};
		const onerror = details.onerror ? details.onerror : uploadError;
		const type    = details.type    ? details.type    : CONFIG.UserGlobalCfg.getConfig().imager;
		if (!DATA_IMAGERS.hasOwnProperty(type) || !DATA_IMAGERS[type].available) {
			onerror();
			return false;
		}
		const imager = DATA_IMAGERS[type];
		const upload = imager.upload;
		const request = upload.request;
		const response = upload.response;

		// Construct request url
		let url = request.url;
		if (request.urlargs) {
			const args = request.urlargs;
			const makearg = (key, value) => ('{K}={V}'.replace('{K}', key).replace('{V}', value));
			const replacers = {
				'$filename$': () => (encodeURIComponent(file.name)),
				'$random$': () => (Math.random().toString()),
				'$time$': () => ((new Date()).getTime().toString())
			};
			for (let [key, value] of Object.entries(args)) {
				url += url.includes('?') ? '&' : '?';
				for (const [str, replacer] of Object.entries(replacers)) {
					while (value !== null && value.includes(str)) {
						const val = replacer(key);
						value = (val !== null) ? value.replace(str, val) : null;
					}
				}
				(value !== null) && (url += makearg(key, value));
			}
		}

		// Construst request body
		let data;
		if (request.data) {
			data = new FormData();
			const replacers = {
				'$file$': (key) => ((data.append(key, file), null)),
				'$random$': () => (Math.random().toString()),
				'$time$': () => ((new Date()).getTime().toString())
			};

			for (let [key, value] of Object.entries(request.data)) {
				for (const [str, replacer] of Object.entries(replacers)) {
					while (value !== null && value.includes(str)) {
						const val = replacer(key);
						value = (val !== null) ? value.replace(str, val) : null;
					}
				}
				(value !== null) && data.append(key, value);
			}
		} else {
			data = file;
		}

		// headers
		const headers = request.headers || {};

		GM_xmlhttpRequest({
			method: 'POST',
			url: url,
			timeout: 15 * 1000,
			data: data,
			headers: headers,
			responseType: request.responseType ? request.responseType : 'json',
			onerror: onerror,
			ontimeout: onerror,
			onabort: onerror,
			onload: (e) => {
				const json = e.response;
				const success = e.status === 200 && response.checksuccess(json);
				if (success) {
					const url = response.geturl(json);
					const name = response.getname ? (response.getname(json) ? response.getname(json) : TEXT_ALT_IMAGE_RESPONSE_NONAME) : TEXT_ALT_IMAGE_RESPONSE_NONAME
					onload({
						url: url,
						name: name,
					});
				} else {
					onerror(json);
					return;
				}
			}
		})
		/* Common xhr version. Cannot bypass CORS.
		const re = new XMLHttpRequest();
		re.open('POST', request.url, true);
		re.timeout = 15 * 1000;
		re.onerror = re.ontimeout = re.onabort = uploadError;
		re.responseType = request.responseType ? request.responseType : 'json';
		re.onload = (e) => {
			const json = re.response;
			const success = response.checksuccess(json)
			if (success) {
				onload({
					url: response.geturl(json),
					name: response.getname ? response.getname(json) : TEXT_ALT_IMAGE_RESPONSE_NONAME,
				});
			} else {
				uploadError(json);
				return;
			}
		}
		re.send(data);*/

		function uploadError(json) {
			alertify.error(TEXT_ALT_IMAGE_UPLOAD_ERROR);
			DoLog(LogLevel.Error, [TEXT_ALT_IMAGE_UPLOAD_ERROR, json]);
		}
	}

	// Wait until a variable loaded, and call callback
	function waitUntilLoaded(varnames, callback, args=[]) {
		if (!varnames) {callback.apply(null, args)}
		if (!Array.isArray(varnames)) {varnames = [varnames];}

		const AM = new AsyncManager();
		AM.onfinish = function() {
			callback.apply(null, args);
		};
		for (const varname of varnames) {
			AM.add();
			makeWaitFunc(varname, AM)();
		}
		AM.finishEvent = true;

		function makeWaitFunc(varname, AM) {
			return function wait() {
				if (typeof(getvar(varname)) === 'undefined') {
					setTimeout(wait, NUMBER_ELEMENT_LOADING_WAIT_INTERVAL);
					return false;
				}
				AM.finish();
			};
		}
	}

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

	// Change location.href without reloading using history.pushState/replaceState
	function setPageUrl(url, push=false) {
		return history[push ? 'pushState' : 'replaceState']({modified: true, ...history.state}, '', url);
	}

	// Just stopPropagation and preventDefault
	function destroyEvent(e) {
		if (!e) {return false;};
		if (!e instanceof Event) {return false;};
		e.stopPropagation();
		e.preventDefault();
	}

	// eval() function with security check that only allows to get variable values, but don't allow executing js.
	function getvar(varname) {
		const unsafe_chars = ['(', ')', '+', '-', '*', '/', '&', '|', '[', ']', '=', '^', '%', '!', '.', '<', '>', '\\', '"', '\''];
		for (const char of unsafe_chars) {
			if (varname.includes(char)) {throw new Error('Function getvar(varname) called with insecure string "{V}"'.replaceAll('V', varname.replaceAll('"', '\\"')))}
		}

		return eval(varname);
	}

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

	// Redirect GM_storage API
	// Each key points to a different storage area
	// Original GM_functions will be backuped in window object
	// PS: No worry for GM_functions leaking, because Tempermonkey's Sandboxing
	function redirectGMStorage(key) {
		// Recover if redirected before
		GM_setValue    = typeof(window.setValue)    === 'function' ? window.setValue    : GM_setValue;
		GM_getValue    = typeof(window.getValue)    === 'function' ? window.getValue    : GM_getValue;
		GM_listValues  = typeof(window.listValues)  === 'function' ? window.listValues  : GM_listValues;
		GM_deleteValue = typeof(window.deleteValue) === 'function' ? window.deleteValue : GM_deleteValue;

		// Stop if no key
		if (!key) {return;};

		// Save original GM_functions
		window.setValue    = typeof(GM_setValue)    === 'function' ? GM_setValue    : function() {};
		window.getValue    = typeof(GM_getValue)    === 'function' ? GM_getValue    : function() {};
		window.listValues  = typeof(GM_listValues)  === 'function' ? GM_listValues  : function() {};
		window.deleteValue = typeof(GM_deleteValue) === 'function' ? GM_deleteValue : function() {};

		// Redirect GM_functions
		typeof(GM_setValue)    === 'function' ? GM_setValue    = RD_GM_setValue    : function() {};
		typeof(GM_getValue)    === 'function' ? GM_getValue    = RD_GM_getValue    : function() {};
		typeof(GM_listValues)  === 'function' ? GM_listValues  = RD_GM_listValues  : function() {};
		typeof(GM_deleteValue) === 'function' ? GM_deleteValue = RD_GM_deleteValue : function() {};

		// Get global storage
		//const storage = getStorage();

		function getStorage() {
			return window.getValue(key, {});
		}

		function saveStorage(storage) {
			return window.setValue(key, storage);
		}

		function RD_GM_setValue(key, value) {
			const storage = getStorage();
			storage[key] = value;
			saveStorage(storage);
		}

		function RD_GM_getValue(key, defaultValue) {
			const storage = getStorage();
			return storage[key] || defaultValue;
		}

		function RD_GM_listValues() {
			const storage = getStorage();
			return Object.keys(storage);
		}

		function RD_GM_deleteValue(key) {
			const storage = getStorage();
			delete storage[key];
			saveStorage(storage);
		}
	}

	// Aim to separate big data from config, to boost up the speed of config reading.
	// FAILED. NEVER USE THESE CODES. NEVER DO THESE THINGS AGAIN. FUCK MYSELF ME STUPID.
	// NOOOOOOOO!!!!!!! WHY ARE YOU DICKHEAD STILL THINGKING ABOUT THIS SHIT??????? NEVER EVER THINK ABOUT THIS FUCKING UNACHIEVABLE FUNCTION AGAIN!!!!!!
	// See https://www.wenku8.net/modules/article/reviewshow.php?rid=244568&aid=1973&page=202#yid930393 if you still want to try, you'll pay for that.
	function GMBigData(maxsize=1024) {
		const BD = this;
		BD.maxsize = maxsize;
		BD.keyPrefix = 'GM_BIGDATA:' + btoa(encodeURIComponent(GM_info.script.name + (GM_info.script.namespace || '')));

		BD.hook = function() {
			hookget();
			hookset();
		}

		BD.unhook = function() {
			if (!BD.GM_getValue || !BD.GM_setValue) {
				throw TypeError('GMBigData: BD.GM_getValue or BD.GM_setValue missing');
			}
			GM_getValue = BD.GM_getValue;
			GM_setValue = BD.GM_setValue;
		}

		function hookget() {
			const oGet = BD.GM_getValue = GM_getValue;
			GM_getValue = function(name, defaultValue) {
				return decodeValue(oGet(name, defaultValue));
			}

			function decodeValue(value) {
				return (({
					'string': decodeString,
					'object': value !== null ? decodeObject : null
				})[typeof value] || ((v) => (v)))(value);

				function decodeString(str) {
					return (isDatakey(str) && keyExists(str)) ? localStorage.getItem(str) : str;
				}

				function decodeObject(obj) {
					return new Proxy(obj, {
						get: function(target, property, receiver) {
							return decodeValue(target[property]);
						}
					});
				}
			}
		}

		function hookset() {
			const oSet = BD.GM_setValue = GM_setValue;
			GM_setValue = function(name, value) {
				const encoded = encodeValue(value);
				clearUnusedBigData(encoded);
				return oSet(name, encoded);
			}

			function encodeValue(value) {
				return (({
					'string': encodeString,
					'object': value !== null ? encodeObject : value
				})[typeof value] || ((v) => (v)))(value);

				function encodeString(str) {
					if (getDataSize(str) <= BD.maxsize) {
						return str;
					} else {
						const key = generateKey();
						localStorage.setItem(key, str);
						return key;
					}
				}

				function encodeObject(obj) {
					return new Proxy(obj, {
						get: function(target, property, receiver) {
							return encodeValue(target[property]);
						}
					});
				}
			}

			function clearUnusedBigData(data) {
				const usingKeys = getAllUsingKeys(data);
				for (const key of Object.keys(localStorage)) {
					if (isDatakey(key) && !usingKeys.includes(key)) {
						localStorage.removeItem(key);
					}
				}

				function getAllUsingKeys(data) {
					const usingKeys = [];
					(({
						'string': checkString,
						'object': data !== null ? getAllUsingKeys : null
					})[typeof data] || function() {})();
					return usingKeys;

					function checkString(str) {
						isDatakey(str) && keyExists(str) && usingKeys.push(str);
					}
				}
			}
		}

		// Datakey generator
		function generateKey(length=16) {
			let datakey = newKey();
			while (keyExists(datakey)) {
				datakey = newKey();
			}
			return datakey;

			function newKey() {
				return BD.keyPrefix + ',' + randstr(length);
			}
		}

		// Check whether a datakey already exists
		function keyExists(datakey) {
			return Object.keys(localStorage).includes(datakey);
		}

		// Check whether the value is a datakey
		function isDatakey(value) {
			return typeof value === 'string' && value.startsWith(BD.keyPrefix);
		}

		// Get the size of data
		function getDataSize(data) {
			return (new Blob([data])).size;
		}
	}

    // 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',
			timeout      : 15 * 1000,
			onloadstart  : function() {
				DoLog(LogLevel.Info, 'getting document, url=\'' + url + '\'');
			},
            onload       : function(response) {
                const htmlblob = response.response;
				parseDocument(htmlblob, callback, args);
            },
			onerror      : reqerror,
			ontimeout    : reqerror
        });

		function reqerror(e) {
			DoLog(LogLevel.Error, 'getDocument: Request Error');
			DoLog(LogLevel.Error, e);
			throw new Error('getDocument: Request Error')
		}
    }

	function parseDocument(htmlblob, callback, args=[]) {
		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');
	}

	// Get a base64-formatted url of an image
	// When image load error occurs, callback will be called without any argument
	function getImageUrl(src, fitx, fity, callback, args=[]) {
		const image = new Image();
		image.setAttribute("crossOrigin",'anonymous');
		image.onload = convert;
		image.onerror = image.onabort = callback;
		image.src = src;

		function convert() {
			const cvs = $CrE('canvas');
			const ctx = cvs.getContext('2d');

			let width, height;
			if (fitx && fity) {
				width = window.innerWidth;
				height = window.innerHeight;
			} else if (fitx) {
				width = window.innerWidth;
				height = (width / image.width) * image.height;
			} else if (fity) {
				height = window.innerHeight;
				width = (height / image.height) * image.width;
			} else {
				width = image.width;
				height = image.height;
			}
			cvs.width = width;
			cvs.height = height;
			ctx.drawImage(image, 0, 0, width, height);
			try {
				callback.apply(null, [cvs.toDataURL()].concat(args));
			} catch (e) {
				DoLog(LogLevel.Error, ['Error at getImageUrl.convert()', e]);
				callback();
			}
		}
	}

	// Convert a 'data:image/jpeg;base64,57af8b....' to a Blob object
	function b64toBlob(dataURI) {
		const mime = dataURI.match(/data:(.+?);/)[1];
		const byteString = atob(dataURI.split(',')[1]);
		const ab = new ArrayBuffer(byteString.length);
		const ia = new Uint8Array(ab);

		for (let i = 0; i < byteString.length; i++) {
			ia[i] = byteString.charCodeAt(i);
		}
		return new Blob([ab], {type: mime});
	}

	//将base64转换为文件
	function dataURLtoFile(dataurl, filename) {
		var arr = dataurl.split(','),
			mime = arr[0].match(/:(.*?);/)[1],
			bstr = atob(arr[1]),
			n = bstr.length,
			u8arr = new Uint8Array(n);
		while (n--) {
			u8arr[n] = bstr.charCodeAt(n);
		}
		return new File([u8arr], filename, {
			type: mime
		});
	}

	// Save dataURL to file
	function saveFile(dataURL, filename) {
		const a = $CrE('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
				const url = URL.createObjectURL(e.response);
				saveFile(URL.createObjectURL(e.response), details.name);
				URL.revokeObjectURL(url);

				// 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 = $CrE('a');
		a.href = url;
		a.download = name;
		a.click();
	}

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

	// Get a url argument from lacation.href
	// also recieve a function to deal the matched string
	// returns defaultValue if name not found
    // Args: {url=location.href, name, dealFunc=((a)=>{return a;}), defaultValue=null} or 'name'
	function getUrlArgv(details) {
        typeof(details) === 'string'    && (details = {name: details});
        typeof(details) === 'undefined' && (details = {});
        if (!details.name) {return null;};

        const url = details.url ? details.url : location.href;
        const name = details.name ? details.name : '';
        const dealFunc = details.dealFunc ? details.dealFunc : ((a)=>{return a;});
        const defaultValue = details.defaultValue ? details.defaultValue : null;
		const matcher = new RegExp('[\\?&]' + name + '=([^&#]+)');
		const result = url.match(matcher);
		const argv = result ? dealFunc(result[1]) : defaultValue;

		return argv;
	}

	// Get language: 0 for simplyfied chinese and others, 1 for traditional chinese
	function getLang() {
		return ({'zh-CN': 0, 'zh-TW': 1})[navigator.language] || 0;
	}

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

	function htmlEncode(text) {
		const span = $CrE('div');
		span.innerText = text;
		return span.innerHTML;
	}

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

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

	// Returns a random string
	function randstr(length=16, cases=true, aviod=[]) {
		const all = 'abcdefghijklmnopqrstuvwxyz0123456789' + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
		while (true) {
			let str = '';
			for (let i = 0; i < length; i++) {
				str += all.charAt(randint(0, all.length-1));
			}
			if (!aviod.includes(str)) {return str;};
		}
	}

	function randint(min, max) {
		return Math.floor(Math.random() * (max - min + 1)) + min;
	}

	function AsyncManager() {
		const AM = this;

		// Ongoing xhr count
		this.taskCount = 0;

		// Whether generate finish events
		let finishEvent = false;
		Object.defineProperty(this, 'finishEvent', {
			configurable: true,
			enumerable: true,
			get: () => (finishEvent),
			set: (b) => {
				finishEvent = b;
				b && AM.taskCount === 0 && AM.onfinish && AM.onfinish();
			}
		});

		// Add one task
		this.add = () => (++AM.taskCount);

		// Finish one task
		this.finish = () => ((--AM.taskCount === 0 && AM.finishEvent && AM.onfinish && AM.onfinish(), AM.taskCount));
	}

	function loadinResourceCSS() {
		for (const res of NMonkey_Info.resources) {
			if (res.isCss) {
				const css = GM_getResourceText(res.name);
				css && addStyle(css);
			}
		}
	}

	function loadinFontAwesome() {
		// https://cdn.jsdelivr.net/npm/@fortawesome/[email protected]/css/all.min.css
		const url = 'https://cdn.jsdelivr.net/npm/@fortawesome/[email protected]/css/all.min.css';
		const alts = [
			'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css',
			'https://bowercdn.net/c/fontAwesome-6.1.1/css/all.min.css',
		];
		let i = -1;

		const link = $CrE('link');
		link.href = url;
		link.rel = 'stylesheet';
		link.onerror = function() {
			i++;
			if (i < alts.length) {
				link.href = alts[i];
			} else {
				alertify.error(TEXT_ALT_SCRIPT_ERROR_AJAX_FA);
			}
		}

		document.head.appendChild(link);
	}

    // NMonkey By PY-DNG, 2021.07.18 - 2022.02.18, License GPL-3
	// NMonkey: Provides GM_Polyfills and make your userscript compatible with non-script-manager environment
	// Description:
	/*
	    Simulates a script-manager environment("NMonkey Environment") for non-script-manager browser, load @require & @resource, provides some GM_functions(listed below), and also compatible with script-manager environment.
	    Provides GM_setValue, GM_getValue, GM_deleteValue, GM_listValues, GM_xmlhttpRequest, GM_openInTab, GM_setClipboard, GM_getResourceText, GM_getResourceURL, GM_addStyle, GM_addElement, GM_log, unsafeWindow(object), GM_info(object)
	    Also provides an object called GM_POLYFILLED which has the following properties that shows you which GM_functions are actually polyfilled.
	    Returns true if polyfilled is environment is ready, false for not. Don't worry, just follow the usage below.
	*/
	// Note: DO NOT DEFINE GM-FUNCTION-NAMES IN YOUR CODE. DO NOT DEFINE GM_POLYFILLED AS WELL.
	// Note: NMonkey is an advanced version of GM_PolyFill (and BypassXB), it includes more functions than GM_PolyFill, and provides better stability and compatibility. Do NOT use NMonkey and GM_PolyFill (and BypassXB) together in one script.
	// Usage:
	/*
		// ==UserScript==
		// @name      xxx
		// @namespace xxx
		// @version   1.0
		// ...
		// @require   https://.../xxx.js
		// @require   ...
		// ...
		// @resource  https://.../xxx
		// @resource  ...
		// ...
		// ==/UserScript==

		// Use a closure to wrap your code. Make sure you have it a name.
		(function YOUR_MAIN_FUNCTION() {
			'use strict';
			// Strict mode is optional. You can use strict mode or not as you want.
			// Polyfill first. Do NOT do anything before Polyfill.
			var NMonkey_Ready = NMonkey({
				mainFunc: YOUR_MAIN_FUNCTION,
				name: "script-storage-key, aims to separate different scripts' storage area. Use your script's @namespace value if you don't how to fill this field.",
				requires: [
					{
						name: "", // Optional, used to display loading error messages if anything went wrong while loading this item
						src: "https://.../xxx.js",
						loaded: function() {return boolean_value_shows_whether_this_js_has_already_loaded;}
						execmode: "'eval' for eval code in current scope or 'function' for Function(code)() in global scope or 'script' for inserting a <script> element to document.head"
					},
					...
				],
				resources: [
					{
						src: "https://.../xxx"
						name: "@resource name. Will try to get it from @resource using this name before fetch it from src",
					},
					...
				],
				GM_info: {
					// You can get GM_info object, if you provide this argument(and there is no GM_info provided by the script-manager).
					// You can provide any object here, what you provide will be what you get.
					// Additionally, two property of NMonkey itself will be attached to GM_info if polyfilled:
					// {
					//     scriptHandler: "NMonkey"
					//     version: "NMonkey's version, it should look like '0.1'"
					// }
					// The following is just an example.
					script: {
						name: 'my first userscript for non-scriptmanager browsers!',
						description: 'this script works well both in my PC and my mobile!',
						version: '1.0',
						released: true,
						version_num: 1,
						authors: ['Johnson', 'Leecy', 'War Mars']
						update_history: {
							'0.9': 'First beta version',
							'1.0': 'Finally released!'
						}
					}
					surprise: 'if you check GM_info.surprise and you will read this!'
					// And property "scriptHandler" & "version" will be attached here
				}
			});
			if (!NMonkey_Ready) {
				// Stop executing of polyfilled environment not ready.
				// Don't worry, during polyfill progress YOUR_MAIN_FUNCTION will be called twice, and on the second call the polyfilled environment will be ready.
				return;
			}

			// Your code here...
			// Make sure your code is written after NMonkey be called
			if
			// ...

			// Just place NMonkey function code here
			function NMonkey(details) {
				...
			}
		}) ();

		// Oh you want to write something here? Fine. But code you write here cannot get into the simulated script-manager-environment.
	*/
	function NMonkey(details) {
		// Constances
		const CONST = {
			Text: {
				Require_Load_Failed: '动态加载依赖js库失败(自动重试也都失败了),请刷新页面后再试:(\n一共尝试了{I}个备用加载源\n加载项目:{N}',
				Resource_Load_Failed: '动态加载依赖resource资源失败(自动重试也都失败了),请刷新页面后再试:(\n一共尝试了{I}个备用加载源\n加载项目:{N}',
				UnkownItem: '未知项目',
			}
		};

		// Init DoLog
		DoLog();

		// Get argument
		const mainFunc = details.mainFunc;
		const name = details.name || 'default';
		const requires = details.requires || [];
		const resources = details.resources || [];
		details.GM_info = details.GM_info || {};
		details.GM_info.scriptHandler = 'NMonkey';
		details.GM_info.version = '1.0';

		// Run in variable-name-polifilled environment
		if (InNPEnvironment()) {
			// Already in polifilled environment === polyfill has alredy done, just return
			return true;
		}

		// Polyfill functions and data
		const GM_POLYFILL_KEY_STORAGE = 'GM_STORAGE_POLYFILL';
		let GM_POLYFILL_storage;
		const Supports = {
			GetStorage: function() {
				let gstorage = localStorage.getItem(GM_POLYFILL_KEY_STORAGE);
				gstorage = gstorage ? JSON.parse(gstorage) : {};
				let storage = gstorage[name] ? gstorage[name] : {};
				return storage;
			},

			SaveStorage: function() {
				let gstorage = localStorage.getItem(GM_POLYFILL_KEY_STORAGE);
				gstorage = gstorage ? JSON.parse(gstorage) : {};
				gstorage[name] = GM_POLYFILL_storage;
				localStorage.setItem(GM_POLYFILL_KEY_STORAGE, JSON.stringify(gstorage));
			},
		};
		const Provides = {
			// GM_setValue
			GM_setValue: function(name, value) {
				GM_POLYFILL_storage = Supports.GetStorage();
				name = String(name);
				GM_POLYFILL_storage[name] = value;
				Supports.SaveStorage();
			},

			// GM_getValue
			GM_getValue: function(name, defaultValue) {
				GM_POLYFILL_storage = Supports.GetStorage();
				name = String(name);
				if (GM_POLYFILL_storage.hasOwnProperty(name)) {
					return GM_POLYFILL_storage[name];
				} else {
					return defaultValue;
				}
			},

			// GM_deleteValue
			GM_deleteValue: function(name) {
				GM_POLYFILL_storage = Supports.GetStorage();
				name = String(name);
				if (GM_POLYFILL_storage.hasOwnProperty(name)) {
					delete GM_POLYFILL_storage[name];
					Supports.SaveStorage();
				}
			},

			// GM_listValues
			GM_listValues: function() {
				GM_POLYFILL_storage = Supports.GetStorage();
				return Object.keys(GM_POLYFILL_storage);
			},

			// unsafeWindow
			unsafeWindow: window,

			// GM_xmlhttpRequest
			// not supported properties of details: synchronous binary nocache revalidate context fetch
			// not supported properties of response(onload arguments[0]): finalUrl
			// ---!IMPORTANT!--- DOES NOT SUPPORT CROSS-ORIGIN REQUESTS!!!!! ---!IMPORTANT!---
			// details.synchronous is not supported as Tampermonkey
			GM_xmlhttpRequest: function(details) {
				const xhr = new XMLHttpRequest();

				// open request
				const openArgs = [details.method, details.url, true];
				if (details.user && details.password) {
					openArgs.push(details.user);
					openArgs.push(details.password);
				}
				xhr.open.apply(xhr, openArgs);

				// set headers
				if (details.headers) {
					for (const key of Object.keys(details.headers)) {
						xhr.setRequestHeader(key, details.headers[key]);
					}
				}
				details.cookie ? xhr.setRequestHeader('cookie', details.cookie) : function () {};
				details.anonymous ? xhr.setRequestHeader('cookie', '') : function () {};

				// properties
				xhr.timeout = details.timeout;
				xhr.responseType = details.responseType;
				details.overrideMimeType ? xhr.overrideMimeType(details.overrideMimeType) : function () {};

				// events
				xhr.onabort = details.onabort;
				xhr.onerror = details.onerror;
				xhr.onloadstart = details.onloadstart;
				xhr.onprogress = details.onprogress;
				xhr.onreadystatechange = details.onreadystatechange;
				xhr.ontimeout = details.ontimeout;
				xhr.onload = function (e) {
					const response = {
						readyState: xhr.readyState,
						status: xhr.status,
						statusText: xhr.statusText,
						responseHeaders: xhr.getAllResponseHeaders(),
						response: xhr.response
					};
					(details.responseType === '' || details.responseType === 'text') ? (response.responseText = xhr.responseText) : function () {};
					(details.responseType === '' || details.responseType === 'document') ? (response.responseXML = xhr.responseXML) : function () {};
					details.onload(response);
				}

				// send request
				details.data ? xhr.send(details.data) : xhr.send();

				return {
					abort: xhr.abort
				};
			},

			// NOTE: options(arg2) is NOT SUPPORTED! if provided, then will just be skipped.
			GM_openInTab: function(url) {
				window.open(url);
			},

			// NOTE: needs to be called in an event handler function, and info(arg2) is NOT SUPPORTED!
			GM_setClipboard: function(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);
			},

			GM_getResourceText: function(name) {
				const _get = typeof(GM_getResourceText) === 'function' ? GM_getResourceText : () => (null);
				let text = _get(name);
				if (text) {return text;}
				for (const resource of resources) {
					if (resource.name === name) {
						return resource.content ? resource.content : null;
					}
				}
				return null;
			},

			GM_getResourceURL: function(name) {
				const _get = typeof(GM_getResourceURL) === 'function' ? GM_getResourceURL : () => (null);
				let url = _get(name);
				if (url) {return url;}
				for (const resource of resources) {
					if (resource.name === name) {
						return resource.src ? btoa(resource.src) : null;
					}
				}
				return null;
			},

			GM_addStyle: function(css) {
				const style = document.createElement('style');
				style.innerHTML = css;
				document.head.appendChild(style);
			},

			GM_addElement: function() {
				let parent_node, tag_name, attributes;
				const head_elements = ['title', 'base', 'link', 'style', 'meta', 'script', 'noscript'/*, 'template'*/];
				if (arguments.length === 2) {
					tag_name = arguments[0];
					attributes = arguments[1];
					parent_node = head_elements.includes(tag_name.toLowerCase()) ? document.head : document.body;
				} else if (arguments.length === 3) {
					parent_node = arguments[0];
					tag_name = arguments[1];
					attributes = arguments[2];
				}
				const element = document.createElement(tag_name);
				for (const [prop, value] of Object.entries(attributes)) {
					element[prop] = value;
				}
				parent_node.appendChild(element);
			},

			GM_log: function() {
				const args = [];
				for (let i = 0; i < arguments.length; i++) {
					args[i] = arguments[i];
				}
				console.log.apply(null, args);
			},

			GM_info: details.GM_info,

			GM: {info: details.GM_info}
		};
		const _GM_POLYFILLED = Provides.GM_POLYFILLED = {};
		for (const pname of Object.keys(Provides)) {
			_GM_POLYFILLED[pname] = true;
		}

		// Not in polifilled environment, then polyfill functions and create & move into the environment
		// Bypass xbrowser's useless GM_functions
		bypassXB();

		// Create & move into polifilled environment
		ExecInNPEnv();

		return false;

		// Bypass xbrowser's useless GM_functions
		function bypassXB() {
			if (typeof(mbrowser) === 'object' || (typeof(GM_info) === 'object' && GM_info.scriptHandler === 'XMonkey')) {
				// Useless functions in XMonkey 1.0
				const GM_funcs = [
					'unsafeWindow',
					'GM_getValue',
					'GM_setValue',
					'GM_listValues',
					'GM_deleteValue',
					//'GM_xmlhttpRequest',
				];
				for (const GM_func of GM_funcs) {
					window[GM_func] = undefined;
					eval('typeof({F}) === "function" && ({F} = Provides.{F});'.replaceAll('{F}', GM_func));
				}
				// Delete dirty data saved by these stupid functions before
				for (let i = 0; i < localStorage.length; i++) {
					const key = localStorage.key(i);
					const value = localStorage.getItem(key);
					value === '[object Object]' && localStorage.removeItem(key);
				}
			}
		}

		// Check if already in name-predefined environment
		// I think there won't be anyone else wants to use this fxxking variable name...
		function InNPEnvironment() {
			return (typeof(GM_POLYFILLED) === 'object' && GM_POLYFILLED !== null && GM_POLYFILLED !== window.GM_POLYFILLED) ? true : false;
		}

		function ExecInNPEnv() {
			const NG = new NameGenerator();

			// Init names
			const tnames = ['context', 'fapply', 'CDATA', 'uneval', 'define', 'module', 'exports', 'window', 'globalThis', 'console', 'cloneInto', 'exportFunction', 'createObjectIn', 'GM', 'GM_info'];
			const pnames = Object.keys(Provides);
			const fnames = tnames.slice();
			const argvlist = [];
			const argvs = [];

			// Add provides
			for (const pname of pnames) {
				!fnames.includes(pname) && fnames.push(pname);
			}

			// Add grants
			if (typeof(GM_info) === 'object' && GM_info.script && GM_info.script.grant) {
				for (const gname of GM_info.script.grant) {
					!fnames.includes(gname) && fnames.push(gname);
				}
			}

			// Make name code
			for (let i = 0; i < fnames.length; i++) {
				const fname = fnames[i];
				const exist = eval('typeof ' + fname + ' !== "undefined"') && fname !== 'GM_POLYFILLED';
				argvlist[i] = exist ? fname : (Provides.hasOwnProperty(fname) ? 'Provides.'+fname : '');
				argvs[i] = exist ? eval(fname) : (Provides.hasOwnProperty(fname) ? Provides[name] : undefined);
				pnames.includes(fname) && (_GM_POLYFILLED[fname] = !exist);
			}

			// Load all @require and @resource
			loadRequires(requires, resources, function(requires, resources) {
				// Join requirecode
				let requirecode = '';
				for (const require of requires) {
					const mode = require.execmode ? require.execmode : 'eval';
					const content = require.content;
					if (!content) {continue;}
					switch(mode) {
						case 'eval':
							requirecode += content + '\n';
							break;
						case 'function': {
							const func = Function.apply(null, fnames.concat(content));
							func.apply(null, argvs);
							break;
						}
						case 'script': {
							const s = document.createElement('script');
							s.innerHTML = content;
							document.head.appendChild(s);
							break;
						}
					}
				}

				// Make final code & eval
				const varnames = ['NG', 'tnames', 'pnames', 'fnames', 'argvist', 'argvs', 'code', 'finalcode', 'wrapper', 'ExecInNPEnv', 'GM_POLYFILL_KEY_STORAGE', 'GM_POLYFILL_storage', 'InNPEnvironment', 'NameGenerator', 'LocalCDN', 'loadRequires', 'requestText', 'Provides', 'Supports', 'bypassXB', 'details', 'mainFunc', 'name', 'requires', 'resources', '_GM_POLYFILLED', 'CONST', 'NMonkey', 'polyfill_status'];
				const code = requirecode + 'let ' + varnames.join(', ') + ';\n(' + mainFunc.toString() + ') ();';
				const wrapper = Function.apply(null, fnames.concat(code));
				const finalcode = '(' + wrapper.toString() + ').apply(this, [' + argvlist.join(', ') + ']);';
				eval(finalcode);
			});

			function NameGenerator() {
				const NG = this;
				const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
				let index = [0];

				NG.generate = function() {
					const chars = [];
					indexIncrease();
					for (let i = 0; i < index.length; i++) {
						chars[i] = letters.charAt(index[i]);
					}
					return chars.join('');
				}

				NG.randtext = function(len=32) {
					const chars = [];
					for (let i = 0; i < len; i++) {
						chars[i] = letters[randint(0, letter.length-1)];
					}
					return chars.join('');
				}

				function indexIncrease(i=0) {
					index[i] === undefined && (index[i] = -1);
					++index[i] >= letters.length && (index[i] = 0, indexIncrease(i+1));
				}

				function randint(min, max) {
					return Math.floor(Math.random() * (max - min + 1)) + min;
				}
			}
		}

		// Load all @require and @resource for non-GM/TM environments (such as Alook javascript extension)
		// Requirements: function AsyncManager(){...}, function LocalCDN(){...}
		function loadRequires(requires, resoures, callback, args=[]) {
			// LocalCDN
			const LCDN = new LocalCDN();

			// AsyncManager
			const AM = new AsyncManager();
			AM.onfinish = function() {
				callback.apply(null, [requires, resoures].concat(args));
			}

			// Load js
			for (const js of requires) {
				!js.loaded() && loadinJs(js);
			}

			// Load resource
			for (const resource of resoures) {
				loadinResource(resource);
			}

			AM.finishEvent = true;

			function loadinJs(js) {
				AM.add();

				let i = -1;
				LCDN.get(js.src, onload, [], onfail);

				function onload(content) {
					js.content = content;
					AM.finish();
				}

				function onfail() {
					i++;
					if (js.srcset && i < js.srcset.length) {
						LCDN.get(js.srcset[i], onload, [], onfail);
					} else {
						debugger;
						alert(CONST.Text.Require_Load_Failed.replace('{I}', i.toString()).replace('{N}', js.name ? js.name : CONST.Text.UnkownItem));
					}
				}
			}

			function loadinResource(resource) {
				let content;
				if (typeof GM_getResourceText === 'function' && (content = GM_getResourceText(resource.name))) {
					resource.content = content;
				} else {
					AM.add();

					let i = -1;
					LCDN.get(resource.src, onload, [], onfail);

					function onload(content) {
						resource.content = content;
						AM.finish();
					}

					function onfail(content) {
						i++;
						if (resource.srcset && i < resource.srcset.length) {
							LCDN.get(resource.srcset[i], onload, [], onfail);
						} else {
							debugger;
							alert(CONST.Text.Resource_Load_Failed.replace('{I}', i.toString()).replace('{N}', js.name ? js.name : CONST.Text.UnkownItem));
						}
					}
				}
			}
		}

		// Loads web resources and saves them to GM-storage
		// Tries to load web resources from GM-storage in subsequent calls
		// Updates resources every $(this.expire) hours, or use $(this.refresh) function to update all resources instantly
		// Dependencies: GM_getValue(), GM_setValue(), requestText(), AsyncManager(), KEY_LOCALCDN
		function LocalCDN() {
			const LC = this;
			const _GM_getValue = typeof(GM_getValue) === 'function' ? GM_getValue : Provides.GM_getValue;
			const _GM_setValue = typeof(GM_setValue) === 'function' ? GM_setValue : Provides.GM_setValue;

			const KEY_LOCALCDN = 'LOCAL-CDN';
			const KEY_LOCALCDN_VERSION = 'version';
			const VALUE_LOCALCDN_VERSION = '0.2';

			// Default expire time (by hour)
			LC.expire = 72;

			// Try to get resource content from loaclCDN first, if failed/timeout, request from web && save to LocalCDN
			// Accepts callback only: onload & onfail(optional)
			// Returns true if got from LocalCDN, false if got from web
			LC.get = function(url, onload, args=[], onfail=function(){}) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const resource = CDN[url];
				const time = (new Date()).getTime();

				if (resource && !expired(time, resource.time)) {
					onload.apply(null, [resource.content].concat(args));
					return true;
				} else {
					LC.request(url, _onload, [], onfail);
					return false;
				}

				function _onload(content) {
					onload.apply(null, [content].concat(args));
				}
			}

			// Generate resource obj and set to CDN[url]
			// Returns resource obj
			LC.set = function(url, content) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const time = (new Date()).getTime();
				const resource = {
					url: url,
					time: time,
					content: content
				}
				CDN[url] = resource;
				_GM_setValue(KEY_LOCALCDN, CDN);
				return resource;
			}

			// Delete one resource from LocalCDN
			LC.delete = function(url) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				if (!CDN[url]) {
					return false;
				} else {
					delete CDN[url];
					_GM_setValue(KEY_LOCALCDN, CDN);
					return true;
				}
			}

			// Delete all resources in LocalCDN
			LC.clear = function() {
				_GM_setValue(KEY_LOCALCDN, {});
				upgradeConfig();
			}

			// List all resource saved in LocalCDN
			LC.list = function() {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const urls = LC.listurls();
				const resources = [];

				for (const url of urls) {
					resources.push(CDN[url]);
				}

				return resources;
			}

			// List all resource's url saved in LocalCDN
			LC.listurls = function() {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const keys = Object.keys(CDN);
				const urls = [];

				for (const key of keys) {
					if (key === KEY_LOCALCDN_VERSION) {continue;}
					urls.push(key);
				}

				return urls;
			}

			// Request content from web and save it to CDN[url]
			// Accepts callbacks only: onload & onfail(optional)
			LC.request = function(url, onload, args=[], onfail=function(){}) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				requestText(url, _onload, [], onfail);

				function _onload(content) {
					LC.set(url, content);
					onload.apply(null, [content].concat(args));
				}
			}

			// Re-request all resources in CDN instantly, ignoring LC.expire
			LC.refresh = function(callback, args=[]) {
				const urls = LC.listurls();

				const AM = new AsyncManager();
				AM.onfinish = function() {
					callback.apply(null, [].concat(args))
				};

				for (const url of urls) {
					AM.add();
					LC.request(url, function() {
						AM.finish();
					});
				}

				AM.finishEvent = true;
			}

			function upgradeConfig() {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				switch(CDN[KEY_LOCALCDN_VERSION]) {
					case undefined:
						init();
						break;
					case '0.1':
						v01_To_v02();
						logUpgrade();
						break;
					case VALUE_LOCALCDN_VERSION:
						DoLog('LocalCDN is in latest version.');
						break;
					default:
						DoLog(LogLevel.Error, 'LocalCDN.upgradeConfig: Invalid config version({V}) for LocalCDN. '.replace('{V}', CDN[KEY_LOCALCDN_VERSION]));
				}
				CDN[KEY_LOCALCDN_VERSION] = VALUE_LOCALCDN_VERSION;
				_GM_setValue(KEY_LOCALCDN, CDN);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'LocalCDN successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', CDN[KEY_LOCALCDN_VERSION]).replaceAll('{V2}', VALUE_LOCALCDN_VERSION));
				}

				function init() {
					// Nothing to do here
				}

				function v01_To_v02() {
					const urls = LC.listurls();
					for (const url of urls) {
						if (url === KEY_LOCALCDN_VERSION) {continue;}
						CDN[url] = {
							url: url,
							time: 0,
							content: CDN[url]
						};
					}
				}
			}

			function clearExpired() {
				const resources = LC.list();
				const time = (new Date()).getTime();

				for (const resource of resources) {
					expired(resource.time, time) && LC.delete(resource.url);
				}
			}

			function expired(t1, t2) {
				return (t2 - t1) > (LC.expire * 60 * 60 * 1000);
			}

			upgradeConfig();
			clearExpired();
		}

		function requestText(url, callback, args=[], onfail=function(){}) {
			const req = typeof(GM_xmlhttpRequest) === 'function' ? GM_xmlhttpRequest : Provides.GM_xmlhttpRequest;
			req({
	            method:       'GET',
	            url:          url,
	            responseType: 'text',
				timeout:      45*1000,
	            onload:       function(response) {
	                const text = response.responseText;
					const argvs = [text].concat(args);
	                callback.apply(null, argvs);
	            },
				onerror:      onfail,
				ontimeout:    onfail,
				onabort:      onfail,
	        })
		}

		function AsyncManager() {
			const AM = this;

			// Ongoing xhr count
			this.taskCount = 0;

			// Whether generate finish events
			let finishEvent = false;
			Object.defineProperty(this, 'finishEvent', {
				configurable: true,
				enumerable: true,
				get: () => (finishEvent),
				set: (b) => {
					finishEvent = b;
					b && AM.taskCount === 0 && AM.onfinish && AM.onfinish();
				}
			});

			// Add one task
			this.add = () => (++AM.taskCount);

			// Finish one task
			this.finish = () => ((--AM.taskCount === 0 && AM.finishEvent && AM.onfinish && AM.onfinish(), AM.taskCount));
		}

		// Arguments: level=LogLevel.Info, logContent, asObject=false
	    // Needs one call "DoLog();" to get it initialized before using it!
	    function DoLog() {
	    	const win = typeof(unsafeWindow) !== 'undefined' ? unsafeWindow : window;

	        // Global log levels set
	        win.LogLevel = {
	            None: 0,
	            Error: 1,
	            Success: 2,
	            Warning: 3,
	            Info: 4,
	        }
	        win.LogLevelMap = {};
	        win.LogLevelMap[LogLevel.None]     = {prefix: ''          , color: 'color:#ffffff'}
	        win.LogLevelMap[LogLevel.Error]    = {prefix: '[Error]'   , color: 'color:#ff0000'}
	        win.LogLevelMap[LogLevel.Success]  = {prefix: '[Success]' , color: 'color:#00aa00'}
	        win.LogLevelMap[LogLevel.Warning]  = {prefix: '[Warning]' , color: 'color:#ffa500'}
	        win.LogLevelMap[LogLevel.Info]     = {prefix: '[Info]'    , color: 'color:#888888'}
	        win.LogLevelMap[LogLevel.Elements] = {prefix: '[Elements]', color: 'color:#000000'}

	        // Current log level
	        DoLog.logLevel = win.isPY_DNG ? LogLevel.Info : LogLevel.Warning; // Info Warning Success Error

	        // Log counter
	        DoLog.logCount === undefined && (DoLog.logCount = 0);
	        if (++DoLog.logCount > 512) {
	            console.clear();
	            DoLog.logCount = 0;
	        }

	        // Get args
	        let level, logContent, asObject;
	        switch (arguments.length) {
	            case 1:
	                level = LogLevel.Info;
	                logContent = arguments[0];
	                asObject = false;
	                break;
	            case 2:
	                level = arguments[0];
	                logContent = arguments[1];
	                asObject = false;
	                break;
	            case 3:
	                level = arguments[0];
	                logContent = arguments[1];
	                asObject = arguments[2];
	                break;
	            default:
	                level = LogLevel.Info;
	                logContent = 'DoLog initialized.';
	                asObject = false;
	                break;
	        }

	        // Log when log level permits
	        if (level <= DoLog.logLevel) {
	            let msg = '%c' + LogLevelMap[level].prefix;
	            let subst = LogLevelMap[level].color;

	            if (asObject) {
	                msg += ' %o';
	            } else {
	                switch(typeof(logContent)) {
	                    case 'string': msg += ' %s'; break;
	                    case 'number': msg += ' %d'; break;
	                    case 'object': msg += ' %o'; break;
	                }
	            }

	            console.log(msg, subst, logContent);
	        }
	    }
	}

	// Polyfill alert
	function polyfillAlert() {
		if (typeof(GM_POLYFILLED) !== 'object') {return false;}
		if (GM_POLYFILLED.GM_setValue) {
			alertify.notify(TEXT_ALT_POLYFILL);
		}
	}

	// Polyfill String.prototype.replaceAll
	// replaceValue does NOT support regexp match groups($1, $2, etc.)
	function polyfill_replaceAll() {
		String.prototype.replaceAll = String.prototype.replaceAll ? String.prototype.replaceAll : PF_replaceAll;

		function PF_replaceAll(searchValue, replaceValue) {
			const str = String(this);

			if (searchValue instanceof RegExp) {
				const global = RegExp(searchValue, 'g');
				if (/\$/.test(replaceValue)) {console.error('Error: Polyfilled String.protopype.replaceAll does support regexp groups');};
				return str.replace(global, replaceValue);
			} else {
				return str.split(searchValue).join(replaceValue);
			}
		}
	}

    // Append a style text to document(<head>) with a <style> element
    function addStyle(css, id) {
		const style = $CrE("style");
		id && (style.id = id);
		style.textContent = css;
		for (const elm of $All('#'+id)) {
			elm.parentElement && elm.parentElement.removeChild(elm);
		}
        document.head.appendChild(style);
    }

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