Greasy Fork is available in English.

轻小说文库+

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

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

// ==UserScript==
// @name         轻小说文库+
// @namespace    Wenku8+
// @version      1.7.5
// @description  轻小说文库全方位体验改善,涵盖阅读、下载、书架、推荐、书评、账号、页面个性化等各种方面,你能想到的这里都有。没有?欢迎提出你的点子。
// @updateinfo   <h3>v1.7.5</h3><ul><li>支持wenku8.cc</li><li>修复书评文字缩放行间距错误地随文字缩放放大缩小问题</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/*
// @match        http*://www.wenku8.cc/*
// @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      cdnjs.cloudflare.com
// @connect      bowercdn.net
// @connect      unpkg.com
// @connect      cdn.bootcdn.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://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=953098
// @require      https://greasyfork.org/scripts/431490-greasyforkscriptupdate/code/GreasyForkScriptUpdate.js?version=965063
// @require      https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/alertify.min.js
// @require      https://unpkg.com/@popperjs/core@2
// @require      https://unpkg.com/tippy.js@6
// @resource     alertify-css    https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/alertify.min.css
// @resource     alertify-theme  https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/themes/default.min.css
// @noframes
// ==/UserScript==

/* 需求记录 [容易(优先级高) ➡️ 困难(优先级低)(我懒,一般而言优先做低难度的)]
** [已完成]{BK}书评页提供用户书评搜索
** {BK}图片大小(最大)限制
** [已完成]{BK}回复区插入@好友
** [已完成]全卷/分卷下载:文件重命名为书名,而不是书号
** · [已完成]添加单文件下载重命名
** {BK}回复区悬浮显示
** {热忱}[已完成]修复https引用问题
** [已完成]书评打开最后一页
** [待完善]书评实时更新
** · [待完善]新回复直接添加到当前页面
** · 主动回复内容直接添加到当前页面
** [待完善]引用回复
** · [已完成]引用楼层号和回复内容
** · [已完成]仅引用楼层号
** [已完成]支持preview版tag搜索
** [高优先级]备注功能
   · [待完善]用户备注
   · 小说备注
   · [低优先级]阅读随笔(这真的可能实现吗??)
** [待完善]书评帖子收藏
** · [已完成]书评页面收藏
** · [高优先级]收藏的书评页面可以添加编辑备注
** [已完成]每日自动推书
** [待完善]{热忱}快速切换账号
** · [已完成]为每个账号储存单独的配置
** · [待完善]保存账号信息并快速自动切换
** [待完善]快速插入图片/表情
** · [已完成]直接插入本地图片
** · [持续进行]更多图床
** · [低优先级]保存常用图片/表情链接
** [部分完成]{BK}页面美化
** · [已完成]阅读页去除广告
** · [已完成]阅读页美化
** · [已完成]书评页美化
** · …
** [高优先级][施工中]脚本储存管理界面
** [高优先级][待完善]稍后再读(可以的话,请给我提出改进建议)
** {BK}类似ehunter的阅读模式
** 改进旧代码:
** · 每个page-addon内部要按照功能分模块,执行功能靠调用模块,不能直接写功能代码
** · 共性模块要写进脚本全局作用域,可以的话写成构造函数
** [低优先级]{RC}书评:@某人时通知他
** [待完善]{BK}书评:草稿箱功能
** {热忱}{s1h2}提供带文字和插图的epub整合下载
*/
/* API记录
** 阅读API:http://dl.wenku8.com/pack.php?aid=2478&vid=92914
** 回帖API:https://www.wenku8.net/modules/article/reviewshow.php?rid=209631&aid=2751
** 查人API:https://www.wenku8.net/modules/article/reviewslist.php?keyword=136877
** 读书API:https://www.wenku8.net/modules/article/reader.php?aid=2946
** 好友API:https://www.wenku8.net/myfriends.php  // 好友名称选择器:content.querySelectorAll('tr>td.odd:nth-child(1)')
** 登录API:https://www.wenku8.net/login.php?do=submit&jumpurl=http%3A%2F%2Fwww.wenku8.net%2Findex.php
** 最新回复:https://www.wenku8.net/modules/article/reviewslist.php?t=1
** 检查更新:https://greasyfork.org/zh-CN/scripts/416310/code/script.meta.js
*/
/* 账号收藏
** wenku8高仿号(按照相似度排列):
** ** https://www.wenku8.net/userpage.php?uid=912148
** ** https://www.wenku8.net/userpage.php?uid=728810
** ** https://www.wenku8.net/userpage.php?uid=917768
** BK高仿号
** ** https://www.wenku8.net/userpage.php?uid=918609
** 热忱高仿号
** ** https://www.wenku8.net/userpage.php?uid=918764
** 隐身鱼高仿号
** ** https://www.wenku8.net/userpage.php?uid=918773
*/
(function FUNC_MAIN() {
    'use strict';

    // Polyfills
	const script_name = '轻小说文库+';
	const script_version = '1.7.4.3';
	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://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=953098',
				srcset: [
					'https://cdn.jsdelivr.net/gh/PYUDNG/CDN@eed1fcf0e901348bc4e752fd483bcb571ebe0408/js/GBK_URL/GBK.js',
				],
				loaded: () => (typeof $URL === 'object'),
				execmode: 'function'
			},

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

			// Alertify
			{
				name: 'Alertify',
				src: 'https://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/alertify.min.js',
				srcset: [
					'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/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://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/alertify.min.css',
				srcset: [
					'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/alertify.min.css',
					'https://bowercdn.net/c/alertify-js-1.13.1/build/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://cdnjs.cloudflare.com/ajax/libs/AlertifyJS/1.13.1/css/themes/default.min.css',
				srcset: [
					'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/themes/default.min.css',
					'https://bowercdn.net/c/alertify-js-1.13.1/build/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/fontawesome-free@6.1.1/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.5';

	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_REMARKS = 'remarks';
	const KEY_REMARKS_VERSION = 'version';
	const VALUE_REMARKS_VERSION = '0.1';

	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://${location.host}/book/{I}.htm`;
	const URL_REVIEWSEARCH = `https://${location.host}/modules/article/reviewslist.php?keyword={K}`;
	const URL_REVIEWSHOW   = `https://${location.host}/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}`;
	const URL_REVIEWSHOW_1 = `https://${location.host}/modules/article/reviewshow.php?rid={R}`;
	const URL_REVIEWSHOW_2 = `https://${location.host}/modules/article/reviewshow.php?rid={R}&page={P}`;
	const URL_REVIEWSHOW_3 = `https://${location.host}/modules/article/reviewshow.php?rid={R}&aid={A}`;
	const URL_REVIEWSHOW_4 = `https://${location.host}/modules/article/reviewshow.php?rid={R}&page={P}#{Y}`;
	const URL_REVIEWSHOW_5 = `https://${location.host}/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}#{Y}`;
	const URL_USERINFO  = `https://${location.host}/userinfo.php?id={K}`;
	const URL_DOWNLOAD1 = `http://${location.host.replace('www.', 'dl.')}/packtxt.php?aid={A}&vid={V}&charset={C}`;
	const URL_DOWNLOAD2 = `http://${location.host.replace('www.', 'dl2.')}/packtxt.php?aid={A}&vid={V}&charset={C}`;
	const URL_DOWNLOAD3 = `http://${location.host.replace('www.', 'dl3.')}/packtxt.php?aid={A}&vid={V}&charset={C}`;
	const URL_PACKSHOW = `https://${location.host}/modules/article/packshow.php?id={A}&type={T}`;
	const URL_BOOKINTRO = `https://${location.host}/book/{A}.htm`;
	const URL_ADDBOOKCASE = `https://${location.host}/modules/article/addbookcase.php?bid={A}`;
	const URL_RECOMMEND = `https://${location.host}/modules/article/uservote.php?id={B}`;
	const URL_TAGSEARCH = `https://${location.host}/modules/article/tags.php?t={TU}`;
	const URL_USRDETAIL = `https://${location.host}/userdetail.php`;
	const URL_USRFRIEND = `https://${location.host}/myfriends.php`;
	const URL_BOOKCASE  = `https://${location.host}/modules/article/bookcase.php`;
	const URL_USRLOGIN  = `https://${location.host}/login.php?do=submit&jumpurl=http%3A%2F%2F${location.host}%2Findex.php`;
	const URL_USRLOGOFF = `https://${location.host}/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://${location.host}/modules/article/packshow.php?id={BOOKID}&amp;type=txt{CHARSET}">TXT简繁分卷</a></div><div style="width:210px; float:left; text-align:center;"><a href="https://${location.host}/modules/article/packshow.php?id={BOOKID}&amp;type=txtfull{CHARSET}">TXT简繁全本</a></div><div style="width:210px; float:left; text-align:center;"><a href="https://${location.host}/modules/article/packshow.php?id={BOOKID}&amp;type=umd{CHARSET}">UMD分卷下载</a></div><div style="width:190px; float:left; text-align:center;"><a href="https://${location.host}/modules/article/packshow.php?id={BOOKID}&amp;type=jar{CHARSET}">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: 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_BOOKCASE_AFTERBOOKS_V4BUG = '由于历史版本脚本的一个bug,您的<i>稍后再读</i>列表的小说排序被打乱了(非常抱歉)</br>而现在这个bug已经修复,<i>稍后再读</i>列表的小说排序也许需要您重新调整一次</br><span class="{CB}">[我知道了]</span>'.replaceAll('{CB}', CLASSNAME_BUTTON);
	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_USER_USERREMARKEDIT = '编辑备注';
	const TEXT_GUI_USER_USERREMARKSHOW = '用户备注:';
	const TEXT_GUI_USER_USERREMARKEMPTY = '假装这里有个备注';
	const TEXT_GUI_USER_USERREMARKEDIT_TITLE = '编辑备注';
	const TEXT_GUI_USER_USERREMARKEDIT_MSG = '设置 [{N}] 的备注为:';
	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|cc)\//, '').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://${location.host}/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;
			// Novel index page & novel page
			case 'modules/article/reader.php':
				chapter_id === '0' ? pageNovelIndex() : 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();
						v04_To_v05();
						logUpgrade();
						break;
					case '0.2':
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						logUpgrade();
						break;
					case '0.3':
						v03_To_v04();
						v04_To_v05();
						logUpgrade();
						break;
					case '0.4':
						v04_To_v05();
						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: {}
					};
				}

				function v04_To_v05() {
					const books = config.laterbooks.books;
					const sorts = [];
					let err = false;
					for (const book of Object.values(books)) {
						if (sorts.includes(book.sort)) {
							err = true;
							break;
						}
						sorts.push(book.sort);
					}
					Math.max.apply(null, sorts) > books.length && (err = true);
					if (err) {
						let i = 0;
						for (const book of Object.values(books)) {
							book.sort = ++i;
						}
						alertify.notify(TEXT_ALT_BOOKCASE_AFTERBOOKS_V4BUG, '', 0);
					}
				}
			}
		}

		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://${location.host}/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://${location.host}/modules/article/reviewshow.php?rid=228884`,
							tiptitle: '梦想成为书评区大水怪的可以来康康'
						}
					}
				}

				function v03_To_v04() {
					if (config.favorites) {return;};
					config.favorites = {
						228884: {
							name: '文库导航姬',
							href: `https://${location.host}/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.RemarksConfig = {
			saveConfig: function(config) {
				config ? config[KEY_REMARKS_VERSION] = VALUE_REMARKS_VERSION : function() {};
				GM_setValue(KEY_REMARKS, config);
			},

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

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

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

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

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

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

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

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

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

		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.RemarksConfig.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://${location.host}/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;
				const book = books[pageResource.info.BID];
				if (!book) {return false;}
				delete books[pageResource.info.BID];
				Array.prototype.forEach.call(Object.values(books), (b) => (b.sort > book.sort && b.sort--));
				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))
				.replaceAll('{CHARSET}', getUrlArgv('charset') ? '&amp;charset=' + getUrlArgv('charset') : '')

			// 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://${location.host}/`);
				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://${location.host}/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://${location.host}/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://${location.host}/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);
				addRemark(floor);
				alinktofloor(floor.table);
			}
		}

		function alinktofloor(parent=main) {
			const floorLinks = $All(main, `a[name][href^="https://${location.host}/modules/article/reviewshow.php"][href*="#yid"]`);
			for (const a of $All(parent, 'a')) {
				if (!a.href.match(/^https?:\/\/www\.wenku8\.(net|cc)\/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://${location.host}/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://${location.host}/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
			const mBtn = $CrE('a'); // Button for edit user remark

			// Get UID
			const user = $(div, 'a');
			const name = user.innerText;
			const UID = Math.floor(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));
			mBtn.href = 'javascript: void(0);'
			qBtn.target = '_blank';
			iBtn.target = '_blank';
			mBtn.addEventListener('click', editUserRemark.bind(null, UID, name, reloadRemarks));
			qBtn.innerText = TEXT_GUI_USER_REVIEWSEARCH;
			iBtn.innerText = TEXT_GUI_USER_USERINFO;
			mBtn.innerText = TEXT_GUI_USER_USERREMARKEDIT;

			// Append to GUI
			div.appendChild($CrE('br'));
			div.appendChild(iBtn);
			div.appendChild(qBtn);
			div.insertBefore(spliter, qBtn);
			div.appendChild($CrE('br'));
			div.appendChild(mBtn);

			function reloadRemarks() {
				const floors = getAllFloors();
				floors.forEach((f) => (addRemark(f)));
			}
		}

		function addRemark(floor) {
			// Get container div
			const div = floor.leftdiv;
			const strong = $(div, 'strong');

			// Get config
			const config = CONFIG.RemarksConfig.getConfig();
			const uid = Math.floor($(div, 'strong>a').href.match(/\?uid=(\d+)/)[1]);
			const user = (config.user[uid] || {});

			if ($(div, '.user-remark')) {
				// Edit remark displayer
				const name = $(div, '.user-remark-remark');
				name.innerText = user.remark || TEXT_GUI_USER_USERREMARKEMPTY;
				name.style.color = user.remark ? 'black' : 'grey';
			} else {
				// Add remark displayer
				const container = $CrE('span');
				const br = $CrE('br');
				const name = $CrE('span');
				container.classList.add('user-remark');
				container.classList.add(CLASSNAME_TEXT);
				container.innerText = TEXT_GUI_USER_USERREMARKSHOW;
				name.innerText = user.remark || TEXT_GUI_USER_USERREMARKEMPTY;
				name.style.color = user.remark ? 'black' : 'grey';
				name.classList.add('user-remark-remark');
				container.appendChild(name);
				strong.insertAdjacentElement('afterend', br);
				br.insertAdjacentElement('afterend', container);
			}
		}

		// 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);
			addRemark(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://${location.host}/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);
		// Init bookcase config if need
		initPreferences();
		const bookcases = CONFIG.bookcasePrefs.getConfig().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 config = CONFIG.bookcasePrefs.getConfig();
			if (config.bookcases.length === 0) {
				for (const option of options) {
					config.bookcases.push({
						classid: Number(option.value),
						url: bookCaseURL.replace('{CID}', String(option.value)),
						name: option.innerText
					});
				}
				CONFIG.bookcasePrefs.saveConfig(config);
			}
		}

		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();
						const books = config.laterbooks.books;
						const bk = books[book.aid];
						if (!bk) {
							body.removeChild(row);
							return false;
						}
						delete config.laterbooks.books[book.aid];
						Array.prototype.forEach.call(Object.values(books), (b) => (b.sort > bk.sort && b.sort--));
						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.stopScroll = beautiful_stopScroll;
				document.onmousedown = beautiful_stopScroll;
				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() {
					let contentmain = pageResource.elements.contentmain;
					let currentpos = contentmain.scrollTop || 0;
					contentmain.scrollTo(0, ++currentpos);
					let nowpos = contentmain.scrollTop || 0;
					pageResource.elements.content.style.userSelect = 'none';
					currentpos != nowpos && beautiful_stopScroll();
				}

				function beautiful_stopScroll() {
					pageResource.elements.content.style.userSelect = '';
					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;
			const charset = (function() {
				const match = document.cookie.match(/(; *)?jieqiUserCharset=(.+?)( *;|$)/);
				return match && match[2] && match[2].toLowerCase() === 'big5' ? 1 : 0;
			}) ();

			// Get content xml
			AndAPI.getNovelContent({
				aid: unsafeWindow.article_id,
				cid: unsafeWindow.chapter_id,
				lang: charset,
				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://${location.host}/modules/article/bookcase.php`,
				innerHTML: TEXT_GUI_API_ADDBOOKCASE_TOBOOKCASE
			});

			// Append link to remove from bookcase (not finished)
			/*addBottomButton({
				href: `https://${location.host}/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);
					}

					// Fix all <a>.href
					Array.from($All('a')).forEach((a) => {
						if (/https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/packshow.php\??/.test(a.href)) {
							a.href = a.href.replace(/([\?&])id=\d+/, '$1id='+aid)
						}
					});

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

	// Display an alertify prompt for editing user-remark
	function editUserRemark(uid, name, callback) {
		const config = CONFIG.RemarksConfig.getConfig();
		const user = config.user[uid] || {uid: uid, name: name, remark: ''};

		// Update name
		user.name = name;
		CONFIG.RemarksConfig.saveConfig(config);

		// Display dialog
		alertify.prompt(TEXT_GUI_USER_USERREMARKEDIT_TITLE, TEXT_GUI_USER_USERREMARKEDIT_MSG.replace('{N}', user.name), user.remark, onChange, onCancel);

		function onChange(evt, value) {
			const config = CONFIG.RemarksConfig.getConfig();
			if (value) {
				const user = config.user[uid] || {uid: uid, name: name, remark: ''};
				user.remark = value;
				config.user[uid] = user;
			} else {
				delete config.user[uid]
			}
			CONFIG.RemarksConfig.saveConfig(config);

			callback(value);
		}

		function onCancel() {}
	}

	// 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://${location.host}/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://${location.host}/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://${location.host}/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|cc)\/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://${location.host}/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);
		}
		const charset = ['GBK', 'BIG5'][getLang()];
		reader.readAsText(htmlblob, charset);
	}

	// 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() {
		const match = document.cookie.match(/(; *)?jieqiUserCharset=(.+?)( *;|$)/);
		const nvgtLang = ({'zh-CN': 0, 'zh-TW': 1})[navigator.language] || 0;
		return match && match[2] ? (match[2].toLowerCase() === 'big5' ? 1 : 0) : nvgtLang;
	}

    // 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/fontawesome-free@6.1.1/css/all.min.css
		const url = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css';
		const alts = [
			'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@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();

				const srclist = js.srcset ? LCDN.sort(js.srcset).srclist : [];
				let i = -1;
				LCDN.get(js.src, onload, [], onfail);

				function onload(content) {
					js.content = content;
					AM.finish();
				}

				function onfail() {
					i++;
					if (i < srclist.length) {
						LCDN.get(srclist[i], onload, [], onfail);
					} else {
						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.3';

			// 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 && resource.content !== null && !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
			// Provide content means load success, provide null as content means load failed
			LC.set = function(url, content) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const time = (new Date()).getTime();
				const resource = {
					url: url,
					time: time,
					content: content,
					success: content !== null ? (CDN[url] ? CDN[url].success + 1 : 1) : (CDN[url] ? CDN[url].success : 0),
					fail: content === null ? (CDN[url] ? CDN[url].fail + 1 : 1) : (CDN[url] ? CDN[url].fail : 0),
				};
				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();
				return LC.listurls().map((url) => (CDN[url]));
			}

			// List all resource's url saved in LocalCDN
			LC.listurls = function() {
				return Object.keys(_GM_getValue(KEY_LOCALCDN, {})).filter((url) => (url !== KEY_LOCALCDN_VERSION));
			}

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

				function _onfail() {
					LC.set(url, null);
					onfail();
				}
			}

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

			// Sort src && srcset, to get a best request sorting
			LC.sort = function(srcset) {
				const CDN = _GM_getValue(KEY_LOCALCDN, {});
				const result = {srclist: [], lists: []};
				const lists = result.lists;
				const srclist = result.srclist;
				const suc_rec = lists[0] = []; // Recent successes take second (not expired yet)
				const suc_old = lists[1] = []; // Old successes take third
				const fails   = lists[2] = []; // Fails & unused take the last place
				const time = (new Date()).getTime();

				// Make lists
				for (const s of srcset) {
					const resource = CDN[s];
					if (resource && resource.content !== null) {
						if (!expired(resource.time, time)) {
							suc_rec.push(s);
						} else {
							suc_old.push(s);
						}
					} else {
						fails.push(s);
					}
				}

				// Sort lists
				// Recently successed: Choose most recent ones
				suc_rec.sort((res1, res2) => (res2.time - res1.time));
				// Successed long ago or failed: Sort by success rate & tried time
				[suc_old, fails].forEach((arr) => (arr.sort(sorting)));

				// Push all resources into seclist
				[suc_rec, suc_old, fails].forEach((arr) => (arr.forEach((res) => (srclist.push(res)))));

				DoLog(['LocalCDN: sorted', result]);
				return result;

				function sorting(res1, res2) {
					const sucRate1 = (res1.success+1) / (res1.fail+1);
					const sucRate2 = (res2.success+1) / (res2.fail+1);

					if (sucRate1 !== sucRate2) {
						// Success rate: high to low
						return sucRate2 - sucRate1;
					} else {
						// Tried time: less to more
						// Less tried time means newer added source
						return (res1.success+res1.fail) - (res2.success+res2.fail);
					}
				}
			}

			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 '0.2':
						v01_To_v02();
						v02_To_v03();
						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 v02_To_v03() {
					const urls = LC.listurls();
					for (const url of urls) {
						CDN[url].success = CDN[url].fail = 0;
					}
				}
			}

			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 (t1 - t2) > (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);
    }
})();