轻小说文库+

TXT分卷批量下载,版权限制小说TXT简繁全本下载,书名/作者名双击复制,Ctrl+Enter快捷键发表书评,单章节下载,小说JPEG插图下载,下载线路点击切换,书评直接打开最后一页,书评帖子全贴下载保存,书评帖子回复功能增强,书评帖子自动刷新,书评帖子收藏,书架功能增强,修复文库若干自身bug,轻小说标签搜索,用户书评搜索,简单的阅读页和目录页美化,每日自动推书,帐号快速切换

当前为 2022-02-18 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         轻小说文库+
// @namespace    Wenku8+
// @version      1.6.6
// @description  TXT分卷批量下载,版权限制小说TXT简繁全本下载,书名/作者名双击复制,Ctrl+Enter快捷键发表书评,单章节下载,小说JPEG插图下载,下载线路点击切换,书评直接打开最后一页,书评帖子全贴下载保存,书评帖子回复功能增强,书评帖子自动刷新,书评帖子收藏,书架功能增强,修复文库若干自身bug,轻小说标签搜索,用户书评搜索,简单的阅读页和目录页美化,每日自动推书,帐号快速切换
// @author       PY-DNG
// @license      GPL-3
// @match        http*://www.wenku8.net/*
// @connect      wenku8.com
// @connect      wenku8.net
// @connect      greasyfork.org
// @connect      image.kieng.cn
// @connect      sm.ms
// @connect      catbox.moe
// @connect      liumingye.cn
// @connect      p.sda1.dev
// @connect      api.pandaimg.com
// @connect      imagelol.com
// @connect      pic.jitudisk.com
// @connect      cdn.jsdelivr.net
// @connect      kit.fontawesome.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_openInTab
// @grant        GM_getResourceText
// @grant        GM_info
// @grant        unsafeWindow
// @require      https://cdn.jsdelivr.net/gh/PYUDNG/CDN@eed1fcf0e901348bc4e752fd483bcb571ebe0408/js/GBK_URL/GBK.js
// @require      https://cdn.jsdelivr.net/gh/PYUDNG/CDN@94fc2bdd313f7bf2af6db5b8699effee8dd0b18d/js/ajax/GreasyForkScriptUpdate.js
// @require      https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/alertify.min.js
// @resource     alertify-css   https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/alertify.min.css
// @resource     alertify-theme https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/themes/default.min.css
// @resource     css-tooltip    https://cdn.jsdelivr.net/gh/PYUDNG/css-components@main/build/tooltip/tooltip.css
// @noframes
// ==/UserScript==

/* 需求记录 [容易(优先级高) ➡️ 困难(优先级低)]
** [已完成]{BK}书评页提供用户书评搜索
** {BK}图片大小(最大)限制
** [已完成]{BK}回复区插入@好友
** {jack158}[部分完成]全卷/分卷下载:文件重命名为书名,而不是书号
** · [已完成分卷&Book页]添加单文件下载重命名
** {BK}回复区悬浮显示
** {热忱}[已完成]修复https引用问题
** [已完成]书评打开最后一页
** [待完善]书评实时更新
** · [待完善]新回复直接添加到当前页面
** · 主动回复内容直接添加到当前页面
** [已完成]引用回复
** [已完成]支持preview版tag搜索
** [待完善]书评帖子收藏
** [已完成]每日自动推书
** [待完善]{热忱}快速切换账号
** · [已完成]为每个账号储存单独的配置
** · [待完善]保存账号信息并快速自动切换
** [待完善]快速插入图片/表情
** · [待完善]直接插入本地图片
** · 更多图床
** · 保存常用图片/表情链接
** {BK}类似ehunter的阅读模式
** 改进旧代码:
** · 每个page-addon内部要按照功能分模块,执行功能靠调用模块,不能直接写功能代码
** · 共性模块要写进脚本全局作用域,可以的话写成构造函数
** {热忱}书评:@某人时通知他
** {BK}[部分完成]页面美化
** · [已完成]阅读页去除广告
** · [待完善]阅读页美化
** · [待完善]书评页美化
** · …
** {热忱}提供带文字和插图的epub整合下载
** {BK}[待完善]书评:草稿箱功能
*/
/* 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
*/
/* 帖子收藏
** 推书:https://www.wenku8.net/modules/article/reviewshow.php?rid=201225
** 推书:https://www.wenku8.net/modules/article/reviewshow.php?rid=208112
** 文库导航姬:https://www.wenku8.net/modules/article/reviewshow.php?rid=228884
** 我的书评:
** ** 扉之外:https://www.wenku8.net/modules/article/reviewshow.php?rid=229636
*/
/* 账号收藏
** 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
*/
/* 等待回复:
** 最早标题内容回帖:https://www.wenku8.net/modules/article/reviewshow.php?rid=209588&aid=1797
** bug已反馈:https://www.wenku8.net/modules/article/reviewshow.php?rid=148298&aid=1143&page=2
** ?撕:https://www.wenku8.net/modules/article/reviewshow.php?rid=226899&aid=2937
** 少女不十分推荐:https://www.wenku8.net/modules/article/reviewshow.php?rid=227628&aid=1101
** 流星雨:https://www.wenku8.net/modules/article/reviewshow.php?rid=227265&aid=2632
** 回复插画跨域加载:https://www.wenku8.net/modules/article/reviewshow.php?rid=225702&aid=2925
** “惠”是谁?先吃口桃子:https://www.wenku8.net/modules/article/reviewshow.php?rid=220624&aid=2404
** Narcissu 游戏推荐贴:https://www.wenku8.net/modules/article/reviewshow.php?rid=227277
** 正在进行时的聊天区!大佬们的集合地:https://www.wenku8.net/modules/article/reviewshow.php?rid=208526&page=1410
** 鼠标的恋爱骚谈帖:https://www.wenku8.net/modules/article/reviewshow.php?rid=219280&page=1
** 热忱的自嗨贴:https://www.wenku8.net/modules/article/reviewshow.php?rid=218884&page=4
*/
/*
漏洞类型:xss漏洞
描述:在https://www.wenku8.net/userinfo.php中,文库提供了可以用户自定义的网站链接。经测试,服务器并未对链接进行过滤,产生了该XSS漏洞。
作用:经过攻击者的设计,可以在其他人访问并点击该链接时,执行攻击者的自定义javascript代码。
原理:在自定义网站链接处插入javascript:伪协议链接,通过window.opener对原网页进行读取和修改。
示例:https://www.wenku8.net/userinfo.php?id=917768
兼容性:MacOS_火狐、安卓_Alook测试通过,MacOS_Chrome、MacOS_Safari测试未通过,其余未测试。
*/
/* 表情:
** 滑稽:[img]data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAACFpJREFUeNqMlnuM3FUVxz/395j3zuyjs9vO7mzLbqGlhVq3tW0o1A0oaQSaSNCoaFAxJmCCMUY0Gv1DCRHrPxqMiSHGV7RVEXwk1CIgbyzYl0C7Ld2yr+622+5j3r/Hvcc/frPdLQJykpPJ3Pn9zud87zlz7lWdnZ0sNa01PT09FAoFpqbOcuTwIUSEdNzmfb3Zy667smObZanVSpFCBNG6GgZ66Nmh2QOvT1ZH6oHB1zAwMEBHRwcigmVZvNUc3sFCLf04iR2F4sozd27LdN++o3hrZ2frdbl8Lovrgm2DGPAbUKsxf/b83Oj4+Wf2vji19zev2mO4qbVaeNmCw7Zto5S6FCwilyyICL7mKkLvnp3r0juqa9atsTI2vQNXEsvlCJUFSoEiAhuDhAEtnd2tV6+a2fXCzOiuG+KBztre6wenai/Z8dQfz09OPm6MuQTk1Muz+FoIQnMR3L+iTb51fZdsf39hhXR1s/uRMV4cFwZ7M1i+AZoxmmAcGysV49i0ZkRn+eGdKTsxNVx45uCZye8fiE8cPzmuTNC4BGzHbEXd1xcXlufiiV/cteG+a7ev+YLu7o+7ncsoFlpJJuMsy2dBKZRtoZSFslTkykLZFpVawNreNCv785DMpla3hB/skXPB469deHy2FhitNQtuoxR9K1LcsjVP3Rce+OymB3YOrrq7kc5gORoCn9ZskvzyPDgZUE6klEUByrLAdWnLOnS6NcLqHEhI6NqsyfnbVL0qL73Z+Kfl2BjTVKyNsOmKLN/9TN/Wa69o213sbP1iZsNtJJZvQSUvh/hKUC2Y8ixSu4CKJcCNgwSReqUwlWnGjx7l1ME3yFx2I7Gua1DtG7G7NiPdA2zZ0HPdwGonU2wLDx59s15XCrAVfOX29bsaJ/aU7ruxVz63Cjny9H75HzMiujQu/rEHxZx5SMzkz8RM/ETMiftk9tCv5OvX5OWO4ju8KyIi8zK072tPFDusTFvGwTEC3cuyxXhXf8v2mwbZNjhJX8cY+vSvkaABKgl2EuW2gJsFlQLjgWODEkyoSXUU2fnxnejqWfraRtAnf4mYBjgpsJPg5HBSebq6iqtFSJRqYUUBXLmqve/5hz7/Qlsh30U8B55BAJVJQDKJaAuMwQQNLMdCOQK6ihgNOoQAVCIByoJGGePVURUf8UMEg5Y6TqgZenX42a1f3XdL2WfeAVgWVz327ETWr3k4XgnlxFAAjgUr21Ar8yAutjigGxBUogYTA0rANRDMISaEWgN1ogJlD6VDUIKty/hqgrxf27S6M7n20Hj9Xw7AtjUd27K5VNIXJ4Il3WhIiAJbQ1COmtgYMCFIiIgBNEikWkwY/YZAUkXdbwRlNNIQBJe2XCK1vS+9cQGsUgmnD9uCtI1kYqhkHJQNMRtcG0I/CiiyqFSa0GYiGB25FUJBoG6QwIDxMfNVVEVhOza5lHM5UVq42ZSbiRQKWAYcg7g2CgPaLJ2ngEFME7wAW0jAhIgOo7pbPjg+UqtGO6UU2BBz7HbAdgAbxIq20YDW4AUggjjRqaKiwUw01yV6TpZCF7ecMAAduXge1L1mwovTErAtgPlq4KFDEE3pQgmjQ5Qfgr+oQnSzhmHzUze3Vy9VuwjF96HhISKUKtGwQYMX6jJR/yNzpcYZ8X0IQ6anLhAGfhTUD8HTl6i46BfhIYQaCYOoF3wf6h7S8FHGoI3h/JyPUhAGmrOlYBwQC5CXTs68Vp6vGtsESCicPj4andQmiBQuJOBrCDUE4aXeVEfdh4YfrRmNFVOcHi8jdhzHgrmy7z//RuXYAlgfHSsPHT99/pTl1SisaGXo6Bilc+dRtkQqFlQGzSQCDb4BXyNes5aeD4EHJkBMgO3A/FyNoeEahZ4cttYcHa0cP3XBHwaMBehGYMb3Pjf2lKqVSakGa65azV9/9yy1+TLKVaiwWduFBJrbKoEHQQMCP0owjEpgxxU1z+dvf3+TtRtXk3FDpOaz58DMk4GWKUDbCyfbqbM1f9eGtg+1p1S6s9BBzWTY//DT5Jelae3KokSjwgDRGoy/CNMBaB9lAizboOIwMjzNw48OsW7LABvXpZDpcxw5PjN+7yNju71QTgP+AthUfVMvVbyWj25s3+o1qqxaWyTeupx9f3qOsROjZFIu2RYXyzIoNEppFCFKhdH/3YSMjs7wj/3HeeVwiR03DbJpIIs3MY6aqcqXf3v6p4fGG48Bc0Qz8aK1AFc9+Km++790c99g3XZJFnopSSvPPXWUk/8ZwiKkUMjSlU8TT7oghka1wbnpCmfOlDF2nMuvvoJrr19PNlmmMTFGYrbM7kdH9t376MR3gGNAJZoNi2YB+XTM2vbQHf3f/sRg96Y6Nk42h9tZpNJwGRmbZWx4nOmzM9SqIQCptE2+q51ifw8rV+bItGj03BR6do7YfIWf7x9/8e49Y9/ztLwCXADMW8EALrA85aotP7i1eM9dH+7ZIfEYvgInncbN5CDRArEE2O7CRRikAWGdsF4hrNaI1T3MbIUf7Zt48pt/OfNjX8u/gano4WZTvc2VOg50Aes//YH2j33jI903r1/VkjeWhS8qurLYFspRYCmwrGioaoOrNarmc/iN2bP3Pzb55z8cmnsYeB04B/hLIeod7vMOsAzoXZa2N31yc/sNt23u2Hx1b6bYknIsx7UjKIARgkBTqgT6yEhl5PcvX3hl78GZJ+bq5iAwCswsVfr/wAs1zzQTWBG3VX9/Pt6/ZVWqv7s13pVOWEkRqDZ0bWzWnzowUjs1PO2dCowMA5PA+WYjydsFfzfwUvVJIAdkm92fBGLNoAFQB0rAPFBufg/fLeh7AS991mm6u+TdBXjYdHkvwf47AKlWzm4hce5fAAAAAElFTkSuQmCC[/img]
*/
(function FUNC_MAIN() {
    'use strict';

    // Polyfills
	const script_name = '轻小说文库+';
	const script_version = '1.6.6';
	const NMonkey_Info = {
		GM_info: {
			script: {
				name: script_name,
				author: 'PY-DNG',
				version: script_version,
			}
		},
		mainFunc: FUNC_MAIN,
		name: 'wenku8_plus',
		requires: [
			{
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/CDN@eed1fcf0e901348bc4e752fd483bcb571ebe0408/js/GBK_URL/GBK.js',
				loaded: () => {typeof($URL) === 'object' ? true : false},
				execmode: 'function'
			},
			{
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/CDN@94fc2bdd313f7bf2af6db5b8699effee8dd0b18d/js/ajax/GreasyForkScriptUpdate.js',
				loaded: () => {typeof(GreasyForkUpdater) === 'function' ? true : false},
				execmode: 'eval'
			},
			{
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/alertify.min.js',
				loaded: () => {typeof(alertify) === 'object' ? true : false},
				execmode: 'function'
			}
		],
		resources: [
			{
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/alertify.min.css',
				name: 'alertify-css'
			},
			{
				src: 'https://cdn.jsdelivr.net/gh/MohammadYounes/AlertifyJS@3151fa0d65909936afcbb2f1665ed4f20767bee5/build/css/themes/default.min.css',
				name: 'alertify-theme'
			},
			{
				src: 'https://cdn.jsdelivr.net/gh/PYUDNG/css-components@main/build/tooltip/tooltip.css',
				name: 'css-tooltip'
			}
		]
	};
    const NMonkey_Ready = NMonkey(NMonkey_Info);
	if (!NMonkey_Ready) {return false;}
	polyfill_replaceAll();

    // CONSTS
	const NUMBER_MAX_XHR = 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.8';

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

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

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

	const VALUE_STR_NULL = 'null';

	const URL_REVIEWSEARCH = 'https://www.wenku8.net/modules/article/reviewslist.php?keyword={K}';
	const URL_REVIEWSHOW   = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}';
	const URL_REVIEWSHOW_1 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}';
	const URL_REVIEWSHOW_2 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&page={P}';
	const URL_REVIEWSHOW_3 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}';
	const URL_REVIEWSHOW_4 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&page={P}#{Y}';
	const URL_REVIEWSHOW_5 = 'https://www.wenku8.net/modules/article/reviewshow.php?rid={R}&aid={A}&page={P}#{Y}';
	const URL_USERINFO  = 'https://www.wenku8.net/userinfo.php?id={K}';
	const URL_DOWNLOAD1 = 'http://dl.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_DOWNLOAD2 = 'http://dl2.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_DOWNLOAD3 = 'http://dl3.wenku8.com/packtxt.php?aid={A}&vid={V}&charset={C}';
	const URL_RECOMMEND = 'https://www.wenku8.net/modules/article/uservote.php?id={B}';
	const URL_TAGSEARCH = 'https://www.wenku8.net/modules/article/tags.php?t={TU}';
	const URL_USRDETAIL = 'https://www.wenku8.net/userdetail.php';
	const URL_USRFRIEND = 'https://www.wenku8.net/myfriends.php';
	const URL_BOOKCASE  = 'https://www.wenku8.net/modules/article/bookcase.php';
	const URL_USRLOGIN  = 'https://www.wenku8.net/login.php?do=submit&jumpurl=http%3A%2F%2Fwww.wenku8.net%2Findex.php';
	const URL_USRLOGOFF = 'https://www.wenku8.net/logout.php';

	const DATA_XHR_LOGIN = [
		"username={U}",
		"password={P}",
		"usecookie={C}",
		"action=login",
		"submit=%26%23160%3B%B5%C7%26%23160%3B%26%23160%3B%C2%BC%26%23160%3B" // ' 登  录&#160'
	].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: '<del>默认图床</br>个人体验良好,推荐使用</del></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 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 = '<div id="txtfull"style="margin:0px auto;overflow:hidden;"><fieldset style="width:820px;height:35px;margin:0px auto;padding:0px;"><legend><b>《{ORIBOOKNAME}》小说TXT全本下载</b></legend><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=txt&amp;id={BOOKID}">G版原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=txt&amp;id={BOOKID}&amp;fname={BOOKNAME}.txt">G版自动重命名</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=utf8&amp;id={BOOKID}">U版原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=utf8&amp;id={BOOKID}&amp;fname={BOOKNAME}">U版自动重命名</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=big5&amp;id={BOOKID}">繁体原始下载</a></div><div style="width:16%; float:left; text-align:center;"><a href="http://dl.wenku8.com/down.php?type=big5&amp;id={BOOKID}&amp;fname={BOOKNAME}">繁体自动重命名</a></div></fieldset></div>'.replaceAll('{C}', CLASSNAME_BUTTON);
    const HTML_DOWNLOAD_BOARD = '<span class="{C}">版权下载限制已解除</br>此功能仅供学习交流,请支持正版<span style="text-align: right;">——轻小说文库+</span></span>'.replace('{C}', CLASSNAME_TEXT);
    const CSS_DOWNLOAD = '.even {display: grid; grid-template-columns: repeat(3, 1fr); text-align: center;} .dlink {text-align: center;}';
	const CSS_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;} .{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;} .{CLI} {display: block; list-style: outside none none; margin: 0px; width: 100%; border: 1px solid rgb(204, 204, 204);} .{CLB} {border: 0px; width: 100%; height: 100%; cursor: pointer;}'.replaceAll('{CAT}', CLASSNAME_LIST).replaceAll('{CLI}', CLASSNAME_LIST_ITEM).replaceAll('{CLB}', CLASSNAME_LIST_BUTTON);
	const CSS_REVIEWSHOW =
		  `body {
		      overflow: auto;
		      background-image: url({BGI});
		  }
		  #content table, table.grid td {
		      background-color: rgba(255,255,255,0.7) !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%"], .jieqiQuote, .jieqiCode, .jieqiNote{
		      font-size: calc(1em * {S} / 100);
			  line-height: calc(120% * {S} / 100);
		  }
		  .{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;
		  }
		  /*
		  #content {
		      font-size: calc(1em * {S} / 100) !important;
		  }
		  #title {
		      font-size: calc(1em * {S} / 100);
		  }*/`;
	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);
		  }
	`

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

    const TEXT_TIP_COPY = '点击复制';
	const TEXT_TIP_COPIED = '已复制';
    const TEXT_TIP_SERVERCHANGE = '点击切换线路';
	const TEXT_TIP_SEARCH_OPTION_TAG = '有关标签搜索</br></br>未完善-开发中…</br>官方尚未正式开放此功能</br>功能预览由[轻小说文库+]提供';
	const TEXT_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_DOWNLOAD_BBCODE_NOCHANGE = '帖子正在下载中,请不要更改此设置!';
	const TEXT_ALT_DOWNLOADFINISH_REVIEW = '{T}({I}) 已下载完毕</br>{N} 已保存';
	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 = '页面美化已开启';
	const TEXT_ALT_BEAUTIFUL_OFF = '页面美化已关闭';
	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 class="{CB}">[点击此处安装更新]</span></div>'.replaceAll('{CT}', CLASSNAME_TEXT).replaceAll('{CB}', CLASSNAME_BUTTON);
	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_GUI_REQUIRE_FAILED = '动态加载依赖js库失败(自动重试也都失败了),请刷新页面后再试:(';
	const TEXT_GUI_API_ADDBOOKCASE_TOBOOKCASE = '进入书架';
	const TEXT_GUI_API_ADDBOOKCASE_REMOVE = '移出本书';
    const TEXT_GUI_DOWNLOAD_IMAGE = '下载图片';
    const TEXT_GUI_DOWNLOAD_TEXT = '下载本章';
	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_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_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} 正在运行。'.replace('{S}', GM_info.script.name);
	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_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_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_CONFIG_MANAGE = '管理存储的信息';
	const TEXT_GUI_DETAIL_MANAGE_CLICK = '点击打开管理页面';
	const TEXT_GUI_DETAIL_MANAGE_HEADER = '脚本储存管理';
	const TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_SELECT = '是否要将您粘贴的图片({N})中设置为页面美化背景图片?';
	const TEXT_GUI_DETAIL_CONFIG_IMPORT_CONFIRM_PASTE = '是否要从您粘贴的配置文件({N})中导入配置?\n建议先备份您当前的配置,再导入新配置';
	const TEXT_GUI_BLOCK_TITLE_DEFULT = '操作区域';
	const TEXT_GUI_USER_REVIEWSEARCH = '用户书评';
	const TEXT_GUI_USER_USERINFO = '详细资料';
	const TEXT_GUI_LINK_TOLASTPAGE = '[打开尾页]';
	const TEXT_GUI_ACCOUNT_SWITCH = '切换账号:';
	const TEXT_GUI_ACCOUNT_CONFIRM = '是否要切换到帐号 "{N}"?';
	const TEXT_GUI_ACCOUNT_NOACCOUNT = '(帐号列表为空)';
	const TEXT_GUI_ACCOUNT_NOTLOGGEDIN = '(没有登录信息)';

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

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

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

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

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

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

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

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

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

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

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

		// Get tab url api part
		API = window.location.href.replace(/https?:\/\/www\.wenku8\.net\//, '').replace(/\?.*/, '').replace(/#.*/, '')
			.replace(/^book\/\d+\.html?/, 'book').replace(/novel\/(\d+\/?)+\.html?$/, 'novel')
			.replace(/^novel[\/\d]+index\.html?$/, 'novelindex');
		if (isAPIPage()) {
			pageAPI(API);
			return;
		};
		if (!API) {
			location.href = 'https://www.wenku8.net/index.php';
			return;
		};
		switch (API) {
			// Dwonload page
			case 'modules/article/packshow.php':
				pageDownload();
				break;
			// ReviewList page
			case 'modules/article/reviews.php':
				areaReply();
				break;
			// Review page
			case 'modules/article/reviewshow.php':
				areaReply();
				pageReview();
				break;
			// 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 'book':
				pageBook();
				break;
			// Novel index page
			case 'novelindex':
				pageNovelIndex();
				break;
			// Novel page
			case 'novel':
				pageNovel();
				break;
			// Login page
			case 'login.php':
				pageLogin();
				break;
			// Other pages
			default:
				DoLog(LogLevel.Info, API);
		}
	}

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

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

		// Auto-recommend
		TM.AutoRecommend = {

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

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

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

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

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

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

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

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

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

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

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

				return true;
			}
		}

		// 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));
						box.callback = function(isClicked) {
							isClicked && (location.href = updateurl);
						}
					} else {
						alertify.message(TEXT_ALT_SCRIPT_UPDATE_NONE);
					}
					config.scriptUpdate.lasttime = getTime('-', false);
					CONFIG.GlobalConfig.saveConfig(config);
				});

				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 = null;

				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();
						logUpgrade();
						break;
					case '0.2':
						v01_To_v02();
						v02_To_v03();
						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 (config instanceof Array) {
						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();
					}
				}
			}
		}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                    delete config.beautiful;
					delete config.backgroundImage;
				}

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

		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,
						backgroundImage: 'https://img12.360buyimg.com/ddimg/jfs/t1/197476/22/6462/3478996/613227a8E03e8ffc3/99970183ddb9f896.jpg'
					},
					novel: {
						beautiful: true,
					},
					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();
						logUpgrade();
						break;
					case '0.2':
						v02_To_v03();
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						logUpgrade();
						break;
					case '0.3':
						v03_To_v04();
						v04_To_v05();
						v05_To_v06();
						logUpgrade();
						break;
					case '0.4':
						v04_To_v05();
						v05_To_v06();
						logUpgrade();
						break;
					case '0.5':
						v05_To_v06();
						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};};
				}
			}
		}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		// Provide meta info copy
		metaCopy();

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

		// Provide tag search
		tagOption();

        // Ctrl+Enter comment submit
        areaReply();

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

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

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

		// Copy meta info
		function metaCopy() {
			let tip = TEXT_TIP_COPY;
			for (let i = -1; i < pageResource.elements.metas.length; i++) {
				const meta = i !== -1 ? pageResource.elements.metas[i] : pageResource.elements.bookName;
				const info = i !== -1 ? pageResource.info.metas[i] : pageResource.info.bookName;
				const value = i !== -1 ? info.VALUE : info;
				meta.innerHTML += HTML_BOOK_COPY;
				const copyBtn = meta.querySelector('.'+CLASSNAME_BUTTON);
				copyBtn.addEventListener('click', function() {
					copyText(value);
					showtip(TEXT_TIP_COPIED);
					alertify.message(TEXT_ALT_META_COPIED.replaceAll('{M}', value));
				});

				settip(copyBtn, TEXT_TIP_COPY);
			}
		}

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

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

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

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

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

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

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

        /* ## Ctrl+Enter comment submit ## */
		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 = document.createElement('span');
		commentbttm.appendChild(asTip);

		// Review-Page: Same rid, same savekey - 'rid123456'
		// Book-Page & Book-Review-List-Page: Same bookid, same savekey - 'bid1234'
		let commentData = {
			rid : getUrlArgv({name: 'rid', dealFunc: Number}),
			aid : getUrlArgv({name: 'aid', dealFunc: Number}),
			bid : location.href.match(/\/book\/(\d+).htm/) ? Number(location.href.match(/\/book\/(\d+).htm/)[1]) : 0,
			page : getUrlArgv({name: 'rid', dealFunc: Number, defaultValue: 1})
		}
		commentData.key = commentData.rid ? 'rid' + String(commentData.rid) : 'bid' + String(commentData.bid);
		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.querySelector('#menuItemInsertUrl');
			const menuItemInsertImage = commentForm.querySelector('#menuItemInsertImage');

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

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

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

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

			return true;
		}

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

			// Imager menu
			const menu = document.querySelector('#UBB_Menu');
			const elmImage = commentForm.querySelector('#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: document.querySelector('#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 = document.createElement('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 = document.querySelector('#UBB_Menu');
			const list = new PlusList({
				id: 'plus_AtTable',
				list: [],
				parentElement: menu.parentElement,
				insertBefore: document.querySelector('#FontSizeTable'),
				visible: false,
				onshow: showlist
			});
			list.onhide = list.clear;
			document.addEventListener('click', list.hide);

			const firstBtn = menu.children[0];
			const atBtn = document.createElement('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);

			/*const div = document.createElement('div');
			const ul = document.createElement('ul');
			div.classList.add(CLASSNAME_LIST);
			div.appendChild(ul);
			div.style.display = 'none';
			div.id = 'plus_AtTable';
			document.addEventListener('click', hideList);
			menu.parentElement.insertBefore(div, document.querySelector('#FontSizeTable'));

			function toggleDisplay() {
				div.style.display === 'none' ? showList() : hideList();
			}

			function hideList() {
				div.style.display = 'none';
			}

			function showList(e) {
				if (typeof(ubb_subdiv) === 'string' && typeof(hideeve) === 'function') {
					hideeve(ubb_subdiv);
					ubb_subdiv = 'plus_AtTable';
				}
				destroyEvent(e)
				div.style.display = '';
				refreshList();
				ul.focus();
			}*/

			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 refreshList() {
				const pageUsers = document.querySelectorAll('#content table strong>a[href^="https://www.wenku8.net/userpage.php"]');
				const friends = getMyUserDetail().userFriends;
				if (!friends) {
					refreshMyUserDetail(refreshList);
					return false;
				}

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

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

				// Make <li>
				let maxNameLength = 0;
				clearChildnodes(ul);
				for (const user of allUsers) {
					makeList(user);
				}

				// Style
				ul.style.width = String(maxNameLength+1) + 'em';
				div.style.left = String(UBBEditor.GetPosition(atBtn).x) + 'px';
				div.style.top = String(UBBEditor.GetPosition(atBtn).y + 20) + 'px';

				return true;

				// 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 makeList(user) {
					// the same style as FontSizeTable
					const li = document.createElement('li');
					const btn = document.createElement('input');
					btn.type = 'button';
					btn.value = user.userName;
					btn.user = user;
					btn.classList.add(CLASSNAME_LIST_BUTTON);
					btn.addEventListener('click', btnClick);
					settip(btn, 'uid: ' + String(user.userID));
					li.classList.add(CLASSNAME_LIST_ITEM);
					li.appendChild(btn);
					ul.appendChild(li);
					maxNameLength = Math.max(maxNameLength, user.userName.length);
					return li;
				}

				function btnClick() {
					const btn = this;
					const user = btn.user;
					const name = btn.user.userName;
					const insertPosition = commentArea.selectionEnd;
					const text = commentArea.value;
					const leftText = text.substr(0, insertPosition);
					const rightText = text.substr(insertPosition);
					let insertValue = '@' + name;

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

					// 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 makelist() {
				// Get users
				const allUsers = getAllUsers();

				// Make list
				let maxNameLength = 0;
				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;
					maxNameLength = Math.max(maxNameLength, user.userName.length);
				}

				// Style
				list.ul.style.width = String(maxNameLength+2) + 'em';
				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 = document.querySelectorAll('#content table strong>a[href^="https://www.wenku8.net/userpage.php"]');
					const friends = getMyUserDetail().userFriends;
					if (!friends) {
						refreshMyUserDetail(refreshList);
						return false;
					}

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

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

					return allUsers;

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

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

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

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

			// if not at the beginning of a line then insert a whitespace before the link
			insertValue = ((leftText.length === 0 || /[ \r\n]$/.test(leftText)) ? '' : ' ') + insertValue;
			// if not at the end of a line then insert a whitespace after the link
			insertValue += (rightText.length === 0 || /^[ \r\n]/.test(rightText)) ? '' : ' ';

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

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

		// Provide button direct to review last page
		/*// Old version. Uses xhr to get last page.
		function lastPage(a) {
			const p = a.parentElement;
			const btn = document.createElement('span');
			const rid = Number(a.href.match(/\?[.+&]?rid=(\d+)/)[1]);
			if (isNaN(rid)) {return false;} else {btn.rid = rid;};
			btn.addEventListener('click', gotoLastPage);
			btn.classList.add(CLASSNAME_BUTTON);
			btn.innerText = TEXT_GUI_LINK_TOLASTPAGE;
			btn.url = null;
			p.insertBefore(btn, a);

			function gotoLastPage() {
				const btn = this;
				const rid = btn.rid;

				if (btn.url) {
					window.open(btn.url);
				} else {
					const box = alertify.notify(TEXT_ALT_LASTPAGE_LOADING);
					getLatestReviewPageUrl(rid, (url)=>{
						if (box.exist) {box.close.call(box);};
						btn.url = url;
						window.open(btn.url);
					});
				}
			}
		}*/

		// New version. Uses '&page=last' keyword.
		function lastPage(a) {
			const p = a.parentElement;
			const lastpg = document.createElement('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',
				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 ? '显示面板' : '隐藏面板'));
						for (const button of SPanel.elements.buttons) {
							if (button === btnShowHide) {continue;}
							//button.style.display = hidden ? 'none' : 'block';
							button.style.pointerEvents = hidden ? 'none' : '';
							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, document.querySelector('#content'), document.querySelector('#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, document.querySelector('#content'), document.querySelector('#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) => {
					const url = /^https?:\/\/www\.wenku8\.net\/modules\/article\/reviewshow\.php/.test(location.href) ? URL_REVIEWSHOW_2.replace('{R}', getUrlArgv('rid')).replace('{P}', document.querySelector('#pagelink>strong').innerText) : location.href;
					location.href = url;
				}
			});
		}
	}

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

        function redirectToCorrectPage() {
            // Get redirect target rid
            const refreshMeta = document.querySelector('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
			document.querySelector('a').href = url;

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

    // Review page add-on
    function pageReview() {
		// Page Info
		const main = document.querySelector('#content');
        const rid = Number(getUrlArgv('rid'));
		const aid = getUrlArgv('aid') ? Number(getUrlArgv('aid')) : Number(main.querySelector('td[width]>a').href.match(/(\d+)\.html?$/)[1]);
		const page = Number(document.querySelector('#pagelink strong').innerText);
		const title = main.querySelector('th>strong').textContent;

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

        // GUI
        const pageCountText = document.querySelector('#pagelink>.last').href.match(/page=(\d+)/)[1];

        const headBars = main.querySelectorAll('tr>td[align]');
		const lefta = headBars[0].querySelector('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 = document.createElement('span');
        saveBtn.innerText = TEXT_GUI_DOWNLOAD_REVIEW.replaceAll('A', pageCountText);
        saveBtn.classList.add(CLASSNAME_BUTTON);
        saveBtn.addEventListener('click', downloadWholePost);
        headBars[1].appendChild(saveBtn);

		const spliter = document.createElement('span');
		const bbcdTxt = document.createElement('span');
		const bbcdChk = document.createElement('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);

		sideButtons();
		beautifier();
		floorEnhance();
		autoRefresh();
		addFavorite();

		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 document.querySelector(selector) && document.querySelector(selector).click();}
		}

		function beautifier() {
			// GUI
			const span  = document.createElement('span');
			const check = document.createElement('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.reviewshow.backgroundImage)
						 .replaceAll('{S}', config.textScale)
						 , 'beautifier');
				scaleimgs();
				hookPosition();

				function scaleimgs() {
					const imgs = document.querySelectorAll('.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 ($(obj).getStyle('position') == 'absolute' || $(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;
			}
		}

		// 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) {
					// 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();
			for (const floor of floors) {
				correctFloorLink(floor);
				alinkEdit(floor);
				addQuoteBtn(floor);
				addQueryBtn(floor);
				alinktofloor(floor.table);
			}
		}

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

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

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

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

					/*
                    // Use wenku8's displayDialog(NOT openDialog, we need to create an iframe to run UBBEditor.js)
					const url = e.target.href + '&ajax_gets=jieqi_contents&ajax_request=' + (new Date()).getTime();
					const iframe = document.createElement('iframe');
					iframe.addEventListener('load', function ionload() {
						const width = iframe.contentDocument.body.scrollWidth + 'px';
						const height = iframe.contentDocument.body.scrollHeight + 'px';
						iframe.style.width = width;
						iframe.style.height = width;
						displayDialog('<div id="dialogcontainer" style="width: {W}; height: {H}"></div>'.replace('{W}', width).replace('{H}', height));
						iframe.removeEventListener('load', ionload);
						document.querySelector('#dialogcontainer').appendChild(iframe);
					});
					iframe.src = url;
					iframe.style.width = iframe.style.height = iframe.style.border = '0px';
					document.body.appendChild(iframe);
					*/
					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    = document.querySelector('#pagelink');
			const tdLink      = pagelink.parentElement;
			const trContainer = tdLink.parentElement;
			const tdAutoRefresh  = document.createElement('td');
			const chkAutoRefresh = document.createElement('input');
			const txtAutoRefresh = document.createElement('span');
			const txtPaused = document.createElement('span');
			const ptitle    = document.querySelector('#ptitle');
			const pcontent  = document.querySelector('#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 = document.querySelector('#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);
				// Get last page, but uses the 'last' keyword in url args
				/*getDocument(location.href, (oDoc)=>{
					const eleCurPage = oDoc.querySelector('#pagelink strong');
					const eleLastPage = oDoc.querySelector('#pagelink a.last');
					const urlLastPage = eleLastPage.href;
					if (eleCurPage.innerText !== eleLastPage.innerText) {
						getDocument(urlLastPage, refreshLoaded);
					} else {
						refreshLoaded(oDoc);
					}
				})*/
				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 = document.querySelectorAll('#content>table[class="grid"]');
					const newfloors = oDoc    .querySelectorAll('#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 (newtop === 9999) {
						sendReviewReply({rid: rid, title: '诶嘿', content: '又到10000楼咯~'});
					}*/
					if (newtop > nowtop) {
						const eleLastPage = oDoc.querySelector('#pagelink a.last');
						const urlLastPage = eleLastPage.href;
						const newpage = Number(getUrlArgv({url: urlLastPage, name: 'page'}));
						const newmain = oDoc.querySelector('#content');
						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.querySelector('#pagelink').innerHTML = newmain.querySelector('#pagelink').innerHTML;

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

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

			function getTopFloorNumber(oDoc) {
				const tblfloors = oDoc.querySelectorAll('#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 = document.createElement('span');
			const favorBtn = document.createElement('span');
			const favorChk = document.createElement('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)
					};
					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 addQuoteBtn(floor) {
			const table = floor.table;
			const numberEle = table.querySelector('td.even div a');
			const attr = numberEle.parentElement;
			const btn = createQuoteBtn(attr);
			const spliter = document.createTextNode(' | ');
			attr.insertBefore(spliter, numberEle);
			attr.insertBefore(btn, spliter);

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

				// Create button
				const btn = document.createElement('span');
				btn.classList.add(CLASSNAME_BUTTON);
				btn.addEventListener('click', quoteThisFloor);
				btn.innerHTML = '引用';
				return btn;

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

					// Create insert value
					let insertValue = '[url=U]N[/url] [quote]Q[/quote]';
					insertValue = insertValue.replace('U', url).replace('N', numberText).replace('Q', content);
					// if not at the beginning of a line then insert a whitespace before the link
					insertValue = ((leftText.length === 0 || /[ \r\n]$/.test(leftText)) ? '' : ' ') + insertValue;
					// if not at the end of a line then insert a whitespace after the link
					insertValue += (rightText.length === 0 || /^[ \r\n]/.test(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 = document.createElement('a'); // Button for query reviews
				const iBtn = document.createElement('a'); // Button for query userinfo

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

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

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

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

		// Reply without refreshing the document
		function hookReply() {
			const form = document.querySelector('form[name="frmreview"]');
			const onsubmit = form.onsubmit;
			form.onsubmit = function() {
				const title = form.querySelector('#ptitle').value;
				const content = form.querySelector('#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 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.querySelector('#content');
            const avatars = main.querySelectorAll('table div img.avatar');

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

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

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

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

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

            function getFloorReply(floor) {
                const elements = floor.elements;
                const reply = {}; floor.reply = reply;
                reply.time = elements.time.nodeValue.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)[0];
                reply.number = Number(elements.number.innerText.match(/\d+/)[0]);
                reply.value = CONFIG.BkReviewPrefs.getConfig().bbcode ? getFloorContent(elements.content) : 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 = document.querySelector('#pagelink>.last').href;
            const rid = Number(lastpageUrl.match(ridMatcher)[1]);
            const pageCount = Number(lastpageUrl.match(pageMatcher)[1]);
            const curPageNum = location.href.match(pageMatcher) ? Number(location.href.match(pageMatcher)[1]) : 1;

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

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

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

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

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

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

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

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

            // End
            const foot = TEXT_OUTPUT_REVIEW_END;

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

        // ## Function: Download the whole post ##
        function downloadWholePost() {
            // Continues only if not working
			if (downloadWholePost.working) {return;};
			downloadWholePost.working = true;
			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 = '文库贴 - ' + String(data.id) + '.txt';

                const a = document.createElement('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 = parent.querySelectorAll('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.querySelector('a[name]');
				floor.tbody    = floor.tr.parentElement;
				floor.table    = floor.tbody.parentElement;
				floor.rid      = Number(getUrlArgv({href: floor.pagehref, name: 'rid'}));
				floor.aid      = Number(parent.querySelector('td[width]>a').href.match(/(\d+)\.html?$/)[1]);
				floor.page     = Number(avt.ownerDocument.querySelector('#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.querySelector('a[href*="#yid"][name^="yid"]') ? true : false;
		}

		// Get floor title element (<strong>)
		// Argv: <table> element of the floor
		function getEleFloorTitle(tblfloor) {
			return tblfloor.querySelector('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.querySelector('td.even>hr+div');
		}

		// Get the floor number
		// Argv: <table> element of the floor
		function getFloorNumber(tblfloor) {
			const eleNumber = tblfloor.querySelector('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) {
					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 += ' S '.replace('S', node.src);
								break;
							case 'A':
								content += '[url=U]T[/url]'.replace('U', node.href).replace('T', getFloorContent(node));
								break;
							case 'BR':
								// no need to add \n, because \n will be preserved in #text nodes
								//content += '\n';
								break;
							case 'DIV':
								if (node.classList.contains('jieqiQuote')) {
									content += getTagedSubcontent('quote', node);
								} else if (node.classList.contains('jieqiCode')) {
									content += getTagedSubcontent('code', node);
								} else if (node.classList.contains('divimage')) {
									content += getFloorContent(node);
								} else {
									content += getFloorContent(node);
								}
								break;
							case 'CODE': content += getFloorContent(node); break; // Just ignore
							case 'PRE':  content += getFloorContent(node); break; // Just ignore
							case 'SPAN': content += getFontedSubcontent(node); break; // Size and color
							case 'P':    content += 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); break;
							/*
							case 'SPAN':
								subContent = getFloorContent(node);
								size = node.style.fontSize.match(/\d+/) ? node.style.fontSize.match(/\d+/)[0] : '';
								color = node.style.color.match(/rgb\((\d+), ?(\d+), ?(\d+)\)/);
								break;
							*/
						}
					}
					return content;

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

					function getFontedSubcontent(node) {
						let 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);
						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.querySelector('table.grid+table[border]');
			main.insertBefore(table, elmafter);

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

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

		// gowork
		showBookcases();
		recommendAllGUI();

		function recommendAllGUI() {
			const block = createLeftBlock(TEXT_GUI_BOOKCASE_ATRCMMD, true, {
				type: 'mypage',
				links: [
					{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.querySelector('.ulitem');
			const txtst = block.querySelector('#arstatus');
			const btnAR = block.querySelector('#autorcmmd');
			const btnRN = block.querySelector('#rcmmdnow');
			const txtAR = btnAR.querySelector('span');
			const checkbox = document.createElement('input');
			txtst.classList.add(CLASSNAME_TEXT);
			btnAR.classList.add(CLASSNAME_BUTTON);
			btnRN.classList.add(CLASSNAME_BUTTON);
			checkbox.type = 'checkbox';
			checkbox.checked = arConfig.auto;
			checkbox.addEventListener('click', onclick);
			btnAR.addEventListener('click', onclick);
			btnAR.appendChild(checkbox);
			btnRN.addEventListener('click', rcmmdnow);

			function onclick(e) {
				destroyEvent(e);
				arConfig.auto = !arConfig.auto;
				setTimeout(function() {checkbox.checked = arConfig.auto;}, 0);
				CONFIG.AutoRecommend.saveConfig(arConfig);
				alertify.notify(arConfig.auto ? TEXT_ALT_ATRCMMDS_AUTO : TEXT_ALT_ATRCMMDS_NOAUTO);
			}

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

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

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

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

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

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

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

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

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

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

				// Decorate
				decorateForm(form, bookcase);

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

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

			// Provide auto-recommand button
			arBtn();

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

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

			// 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.querySelector('#newclassid');
				if(newclassid.value == -1){
					if (confirm('确实要将选中书目移出书架么?')) {return true;} else {return false;};
				} else {
					return true;
				}
			}

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

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

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

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

				function onblur() {
					tip = TEXT_GUI_BOOKCASE_DBLCLICK;
					tipready ? 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.querySelector('table');
				for (const tr of table.querySelectorAll('tr')) {
					tr.querySelector('.odd') ? decorateRow(tr) : function() {};
					tr.querySelector('th') ? decorateHeader(tr) : function() {};
					tr.querySelector('td.foot') ? decorateFooter(tr) : function() {};
				}

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

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

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

				// auto-recommend onvaluechange
				function onvaluechange(e) {
					arConfig = 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));
					}
				}
			}
		}
	}

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

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

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

		// Remove ads
		removeTopAds();

		// Side-Panel buttons
		sideButtons();

		// Provide download GUI
		downloadGUI();

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

		// Beautifier page
		beautifier();

		// More font-sizes
		moreFontSizes();

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

			function collectElements() {
				const elements = pageResource.elements;
				elements.title          = document.querySelector('#title');
				elements.images         = document.querySelectorAll('.imagecontent');
				elements.rightButtonDiv = document.querySelector('#linkright');
				elements.rightNodes     = elements.rightButtonDiv.childNodes;
				elements.rightBlank     = elements.rightNodes[elements.rightNodes.length-1];
				elements.content        = document.querySelector('#content');
				elements.contentmain    = document.querySelector('#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: pageResource.infos.isImagePage ? TEXT_GUI_DOWNLOAD_IMAGE : TEXT_GUI_DOWNLOAD_TEXT,
				onclick: pageResource.infos.isImagePage ? dlNovelImages : dlNovelText
			});

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

			// Previous page
			SPanel.add({
				faicon: 'fa-solid fa-angle-left',
				tip: '上一页',
				onclick: (e) => {document.querySelector('#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 = document.createElement('span');
			dlBtn.classList.add(CLASSNAME_BUTTON);
			dlBtn.addEventListener('click', infos.isImagePage ? dlNovelImages : dlNovelText);
			dlBtn.innerText = infos.isImagePage ? TEXT_GUI_DOWNLOAD_IMAGE : TEXT_GUI_DOWNLOAD_TEXT;

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

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

		// 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.reviewshow.backgroundImage)
						 .replaceAll('{S}', config.textScale)
						 .replaceAll('{H}', usedHeight), 'beautifier'
				);

				unsafeWindow.scrolling = beautiful_scrolling;

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

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

		function moreFontSizes() {
			const select = document.querySelector('#fonttype');
			const savebtn = document.querySelector('#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 = document.createElement('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
			waitUntilLoaded('loadSet', function() {
				loadSet();
			});

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

		// 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 = document.createElement('a');
							a.href = url;
							a.download = name;
							a.click();

							download.finished++;
							updateDownloadStatus();
							// Code below seems can work, but actually it doesn't work well and somtimes some file cannot be saved
							// The reason is still unknown, but from what I know I can tell that mistakes happend in GM_xmlhttpRequest
							// Error stack: GM_xmlhttpRequest.onload ===> image.onload ===> downloadFile ===> GM_xmlhttpRequest =X=> .onload
							// This Error will also stuck the GMXHRHook.ongoingList
							/*downloadFile({
									url: url,
									name: name,
									onload: function() {
										download.finished++;
										DoLog(LogLevel.Info, '[' + String(i) + ']file saved: ' + name);
										alert('[' + String(i) + ']file saved: ' + name);
										updateDownloadStatus();
									},
									onerror: function() {
										alert('downloadfile error! url = ' + String(url) + ', i = ' + String(i));
									}
								})*/
						}
						image.onerror = function() {
							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 = document.createElement('canvas');
            cvs.width = image.width;
		    cvs.height = image.height;
            const ctx = cvs.getContext('2d');
            ctx.drawImage(image, 0, 0);
            return cvs.toDataURL(format);
        }
    }

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

		let optionTags;
		provideTagOption();
		onsubmitHOOK();

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

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

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

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

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

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

	// Mylink page add-on
	function pageMylink() {
		// Get elements
		const main = document.querySelector('#content');
		const tbllink = document.querySelector('#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 = tbllink.querySelectorAll('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 = document.querySelectorAll('.blockcontent .userinfo')[0].parentElement;
			const container = oriContainer.cloneNode(true);
			const button = container.querySelector('a');
			button.innerText = TEXT_GUI_USER_REVIEWSEARCH;
			button.href = URL_REVIEWSEARCH.replaceAll('{K}', String(UID));
			oriContainer.parentElement.appendChild(container);
		}
	}

	// Detail page add-on
	function pageDetail() {
		// Get elements
		const content = document.querySelector('#content');
		const tbody = content.querySelector('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_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_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 = document.createElement('input');
				const bgioprt = elements.bgioprt = document.createElement('span');
				const bgiupld = elements.bgupload;
				const ckbgiup = elements.ckbgiup = document.createElement('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;
				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 = document.createElement('span');
					const radio = document.createElement('input');
					const text = document.createElement('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);
						settip(radio, tip);
						settip(text, tip);
						//settip(span, imager.tip);
					}
					elements.imager.appendChild(span);
				}
				elements.imager.querySelector('#imager_'+curimager).checked = true;

				// Text scale
				const textScale = CONFIG.BeautifierCfg.getConfig().textScale;
				const scalectnr = elements.scalectnr;
				const elmscale = elements.scale = document.createElement('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 ckbtfnvl = elements.ckbtfnvl = document.createElement('input');
				const ckbtfrvw = elements.ckbtfrvw = document.createElement('input');
				ckbtfnvl.type = ckbtfrvw.type = 'checkbox';
				ckbtfnvl.page = 'novel';
				ckbtfrvw.page = 'reviewshow';
				ckbtfnvl.checked = CONFIG.BeautifierCfg.getConfig().novel.beautiful;
				ckbtfrvw.checked = CONFIG.BeautifierCfg.getConfig().reviewshow.beautiful;
				ckbtfnvl.addEventListener('change', beautifulChange);
				ckbtfrvw.addEventListener('change', beautifulChange);
				btfnvlctnr.appendChild(ckbtfnvl);
				btfrvwctnr.appendChild(ckbtfrvw);

				// Favorite open
				const favoropen = elements.favoropen;
				const favorlast = elements.favorlast = document.createElement('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 = document.createElement('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);

				// Config export/import
				const exportcfg = elements.exportcfg;
				const exportcfgnp = elements.exportcfgnp;
				const importcfg = elements.importcfg;
				const configinput = elements.configinput = document.createElement('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);
				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 = document.createElement('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.reviewshow.backgroundImage = url;
					config.reviewshow.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.reviewshow.backgroundImage = json.url;
								CONFIG.BeautifierCfg.saveConfig(config);
								elements.bgioprt.innerHTML = name;
								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.reviewshow.bgiName ? config.reviewshow.bgiName : 'image.jpeg';

				if (config.upload) {
					// Upload
					const url = config.reviewshow.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.reviewshow.backgroundImage);
								CONFIG.BeautifierCfg.saveConfig(config);
							},
							onload: (json) => {
								const config = CONFIG.BeautifierCfg.getConfig();
								config.reviewshow.backgroundImage = json.url;
								config.reviewshow.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.reviewshow.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.reviewshow.backgroundImage);
								CONFIG.BeautifierCfg.saveConfig(config);
								return false;
							};

							// Save to config
							const config = CONFIG.BeautifierCfg.getConfig();
							config.reviewshow.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() {
				const box = alertify.alert();
				box.setHeader(TEXT_GUI_DETAIL_MANAGE_HEADER);
				box.setting({
					 maximizable: true
				});
				box.show();
			}
		}

		function createTableGUI(lines) {
			const elements = {};
			for (const line of lines) {
				const tr = document.createElement('tr');
				for (const item of line) {
					const td = document.createElement('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.querySelector('#'+id);
					}
				});

				return p;
			}
		}
	}

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

		// 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 = createLeftBlock(TEXT_GUI_INDEX_FAVORITES, true, {
				type: 'toplist',
				links: links
			})
		}

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

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

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

		// Donwload GUI
		downloadGUI();

        // Server GUI
        serverGUI();

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

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

			// define vars
			let i;

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

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

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

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

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

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

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

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

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

		// Server GUI
		function serverGUI() {
			let servers = document.querySelectorAll('#content>b');
			let serverEles = [];
			for (i = 0; i < servers.length; i++) {
				if (servers[i].innerText.includes('wenku8.com')) {
					serverEles.push(servers[i]);
				}
			}
			for (i = 0; i < serverEles.length; i++) {
				serverEles[i].classList.add(CLASSNAME_BUTTON);
				serverEles[i].addEventListener('click', function () {
					changeAllServers(this.innerText);
				});
				settip(serverEles[i], TEXT_TIP_SERVERCHANGE);
			}
		}

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

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

	// Login page add-on
	function pageLogin() {
		const form = document.querySelector('form[name="frmlogin"]');
		if (!form) {return false;}
		const eleUsername = form.querySelector('input.text[name="username"]');
		const elePassword = form.querySelector('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 (!document.querySelector('.fl')) {return false;};
		GUI();

		function GUI() {
			// Add switch select
			const eleTopLeft = document.querySelector('.fl');
			const eletext    = document.createElement('span');
			const sltSwitch  = document.createElement('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 = document.createElement('option');
				option.innerText = text;
				option.value = value;
				sltSwitch.appendChild(option);
				return option;
			}

			function selectCurUser() {
				for (const option of sltSwitch.querySelectorAll('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);
		//logAPI();

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

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

		function pageAddbookcase() {

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

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

		// Add a bottom-styled botton into bottom line, to the first place
		function addBottomButton(details) {
			const aClose = document.querySelector('a[href="javascript:window.close()"]');
			const bottom = aClose.parentElement;
			const a = document.createElement('a');
			const t1 = document.createTextNode('[');
			const t2 = document.createTextNode(']');
			const blank = document.createElement('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 = document.querySelectorAll('.block');
		const close = document.querySelectorAll('a[href="javascript:window.close()"]');
		return block.length === 1 && close.length === 1;
	}

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

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

			function friendLoaded(oDoc) {
				const content = oDoc.querySelector('#content');
				const trs = content.querySelectorAll('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].querySelector('a').href.match(/\?uid=(\d+)/)[1]))) {return false;};

					// Collect information
					let friend = {
						userID: Number(tr.children[2].querySelector('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() {};
				}
			}
		}
	}

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

	// 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';
		document.onmousemove = unsafeWindow.tipmove = function tipmoveplus(e) {
			tipobj.style.left = e.clientX + tipx + 'px';
			tipobj.style.top = e.clientY + tipy + 'px';
		}
		return true;
	}

	// 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.target && (e.target.tiptitle || e.target.tipgetter)) {
			const tip = e.target.tiptitle || e.target.tipgetter();
			if (tipready) {
				tipshow(tip);
				e.target.title && e.target.removeAttribute('title');
			} else {
				e.target.title = e.target.tiptitle;
			}
		} else if (typeof(e) === 'string') {
			tipready && tipshow(e);
		}
	}

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

	// Side-located control panel
	// Require: 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.querySelector(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 document.querySelectorAll('#'+id)) {
				elm.parentElement && elm.parentElement.removeChild(elm);
			}
			document.head.appendChild(style);
		}

		// Set a tooltip to the element
		function setTooltip(elm, text, direction='auto') {
			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');}
			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);

		let maxlength = 0;
		PL.list = [];
		for (const item of list.list) {
			item.value && (maxlength = Math.max(maxlength, String(item.value).length));
			appendItem(item);
		}
		ul.style.width = String(maxlength+2) + 'em';

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

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

		titleEle.innerText = title;
		clearChildnodes(cntntEle);

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

		append && leftEle.appendChild(blockEle);

		return blockEle;

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

		// Links such as top-books-list inside #right in index page
		// options = {type: 'toplist', links = [...{href: '', innerHTML: '', tiptitle: '', id: ''}]}
		function typeToplist() {
			const ul = document.createElement('ul');
			ul.classList.add('ultop');
			for (const link of options.links) {
				const li = document.createElement('li');
				const a = document.createElement('a');
				a.href = link.href ? link.href : 'javascript: void(0);';
				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);
			}
			blockEle.appendChild(ul);
		}
	}

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

		function firstPage(oDoc, ...args) {
			const url = oDoc.querySelector('#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);
		}
	}

	// 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.
	function GMBigData() {
		function hookget() {
			const oGet = GM_getValue;
			GM_getValue = function(name, defaultValue) {
				const value = oGet(name, defaultValue);

				// Only hooks object
				if (typeof(value) !== 'object') {
					return value;
				}

				const data = dealAll(value);

				function dealAll(data) {
					const meta = {};
					for (const key of Object.keys(data)) {
						if (isDatakey(value) && keyExists(value)) {
							Object.defineProperty(data, key, {
								get: function() {
									return localStorage.getItem(data[key]);
								},
								configurable: true,
								enumerable: true
							});
							meta[key] = value;
						}
						if (typeof(value) === 'object') {dealAll(value);};
					}
					Object.defineProperty(data, 'GM_BIGDATA_META_INFO', {
						value: meta,
						writable: false,
						configurable: false,
						enumerable: false
					});

					return data;
				}
			}
		}

		function hookset() {
			const oSet = GM_setValue;
			GM_setValue = function(name, value) {
				// Only hooks object
				if (typeof(value) !== 'object') {
					oSet(name, value);
				}

				// Deal value data
				const data = dealAll(value);

				// Save to GM_storage
				oSet(name, data);

				// Search & deal bigdata in data object, returns dealed data; not modifying original data object directly
				// NOT working on non-objective data types
				function dealAll(data) {
					const result = {};
					const meta = data['GM_BIGDATA_META_INFO'];
					for (const [key, value] of Object.entries(data)) {
						if (typeof(value) !== 'object') {
							if (meta.hasOwnProperty(key)) {
								const datakey = meta[key];
								localStorage.setItem(datakey, value);
							} else {
								data = dealOne(data);
							}
							return data;
						} else {
							result[key] = dealAll(value);
						}
					}

					return result;
				}

				// Deal one data, returns the datakey of dealed data
				function dealOne(data) {
					// Only data bigger than 10kb will be dealed as bigdata
					if (getDataSize(data) <= 1024*10) {return data;};

					// If data is already a datakey, then no repeating the work
					if (isDatakey(data)) {return data;};

					// Generate a key for this bigdata
					const datakey = generateKey();

					// Save data to localStorage[datakey]
					localStorage.setItem(datakey, data);

					// Return datakey
					return datakey;
				}
			}
		}

		// Datakey generator
		function generateKey(length=16) {
			let datakey = 'GM_BIGDATA:' + randstr(length);
			while (keyExists(datakey)) {
				datakey = 'GM_BIGDATA:' + randstr(length);
			}
		}

		// Check whether a datakey already exists
		function keyExists(datakey) {
			for (let i = 0; i < localStorage.length; i++) {
				if (localStorage.key(i) === datakey) {return true;};
			}
			return false;
		}

		// Check whether the value is a datakey
		function isDatakey(value) {
			return typeof(value) !== 'string' ? false : /^BIGDATA:[a-zA-Z0-9]+$/.test(value);
		}
	}

    // Download and parse a url page into a html document(dom).
    // when xhr onload: callback.apply([dom, args])
    function getDocument(url, callback, args=[]) {
        GM_xmlhttpRequest({
            method       : 'GET',
            url          : url,
            responseType : 'blob',
			onloadstart  : function() {
				DoLog(LogLevel.Info, 'getting document, url=\'' + url + '\'');
			},
            onload       : function(response) {
                const htmlblob = response.response;
				parseDocument(htmlblob, callback, args);
            }
        })
    }

	function parseDocument(htmlblob, callback, args=[]) {
		const reader = new FileReader();
		reader.onload = function(e) {
			const htmlText = reader.result;
			const dom = new DOMParser().parseFromString(htmlText, 'text/html');
			args = [dom].concat(args);
			callback.apply(null, args);
			//callback(dom, htmlText);
		}
		reader.readAsText(htmlblob, 'GBK');
	}

	// Get a base64-formatted url of an image
	// When image load error occurs, callback will be called without any argument
	function getImageUrl(src, fitx, fity, callback, args=[]) {
		const image = new Image();
		image.setAttribute("crossOrigin",'anonymous');
		image.onload = convert;
		image.onerror = image.onabort = callback;
		image.src = src;

		function convert() {
			const cvs = document.createElement('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 = document.createElement('a');
		a.href = dataURL;
		a.download = filename;
		a.click();
	}

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

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

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

		// Send request
		GM_xmlhttpRequest(requestObj);
	}

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

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

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

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

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

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

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

		return result;
	}

	// Convert rgb color(e.g. 51,51,153) to hex color(e.g. '333399')
	function rgbToHex(r, g, b) {return 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;
	}

	// Get the size of data
	function getDataSize(data) {
		return (new Blob([data])).size;
	}

	// 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) {
			const css = GM_getResourceText(res.name);
			css && addStyle(css);
		}
	}

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

		// Not in polifilled environment, then polyfill functions and create & move into the environment
		// Bypass xbrowser's useless GM_functions
		bypassXB();

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

		// 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} = undefined);'.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) ? 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' ? true : false;
				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;
					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', '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();
				LCDN.get(js.src, function(content) {
					js.content = content;
					AM.finish();
				});
			}

			function loadinResource(resource) {
				let content;
				if (typeof(GM_getResourceText) === 'function' && (content = GM_getResourceText(resource.name))) {
					resource.content = content;
				} else {
					AM.add();
					LCDN.get(resource.src, function(content) {
						resource.content = content;
						AM.finish();
					});
				}
			}
		}

		// 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 = Provides.GM_getValue, GM_setValue = Provides.GM_setValue;

			const KEY_LOCALCDN = 'LOCAL-CDN';
			const KEY_LOCALCDN_VERSION = 'version';
			const VALUE_LOCALCDN_VERSION = '0.2';

			// Default expire time (by hour)
			LC.expire = 72;

			// Try to get resource content from loaclCDN first, if failed/timeout, request from web && save to LocalCDN
			// Accepts callback only
			// Returns true if got from LocalCDN, false if got from web
			LC.get = function(url, callback, args=[]) {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				const resource = CDN[url];
				const time = (new Date()).getTime();

				if (resource && !expired(time, resource.time)) {
					callback.apply(null, [resource.content].concat(args));
					return true;
				} else {
					LC.request(url, function(content) {
						callback.apply(null, [content].concat(args));
					});
					return false;
				}
			}

			// Generate resource obj and set to CDN[url]
			// Returns resource obj
			LC.set = function(url, content) {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				const time = (new Date()).getTime();
				const resource = {
					url: url,
					time: time,
					content: content
				}
				CDN[url] = resource;
				GM_setValue(KEY_LOCALCDN, CDN);
				return resource;
			}

			// Delete one resource from LocalCDN
			LC.delete = function(url) {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				if (!CDN[url]) {
					return false;
				} else {
					delete CDN[url];
					GM_setValue(KEY_LOCALCDN, CDN);
					return true;
				}
			}

			// Delete all resources in LocalCDN
			LC.clear = function() {
				GM_setValue(KEY_LOCALCDN, {});
				upgradeConfig();
			}

			// List all resource saved in LocalCDN
			LC.list = function() {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				const urls = LC.listurls();
				const resources = [];

				for (const url of urls) {
					resources.push(CDN[url]);
				}

				return resources;
			}

			// List all resource's url saved in LocalCDN
			LC.listurls = function() {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				const keys = Object.keys(CDN);
				const urls = [];

				for (const key of keys) {
					if (key === KEY_LOCALCDN_VERSION) {continue;}
					urls.push(key);
				}

				return urls;
			}

			// Request content from web and save it to CDN[url]
			// Accepts callback only
			LC.request = function(url, callback, args=[]) {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				requestText(url, function(content) {
					LC.set(url, content);
					callback.apply(null, [content].concat(args));
				});
			}

			// Re-request all resources in CDN instantly, ignoring LC.expire
			LC.refresh = function(callback, args=[]) {
				const urls = LC.listurls();

				const AM = new AsyncManager();
				AM.onfinish = function() {
					callback.apply(null, [].concat(args))
				};

				for (const url of urls) {
					AM.add();
					LC.request(url, function() {
						AM.finish();
					});
				}

				AM.finishEvent = true;
			}

			function upgradeConfig() {
				const CDN = GM_getValue(KEY_LOCALCDN, {});
				switch(CDN[KEY_LOCALCDN_VERSION]) {
					case undefined:
						init();
						break;
					case '0.1':
						v01_To_v02();
						logUpgrade();
						break;
					case VALUE_LOCALCDN_VERSION:
						DoLog('LocalCDN is in latest version.');
						break;
					default:
						DoLog(LogLevel.Error, 'LocalCDN.upgradeConfig: Invalid config version({V}) for LocalCDN. '.replace('{V}', CDN[KEY_LOCALCDN_VERSION]));
				}
				CDN[KEY_LOCALCDN_VERSION] = VALUE_LOCALCDN_VERSION;
				GM_setValue(KEY_LOCALCDN, CDN);

				function logUpgrade() {
					DoLog(LogLevel.Success, 'LocalCDN successfully upgraded From v{V1} to v{V2}. '.replaceAll('{V1}', CDN[KEY_LOCALCDN_VERSION]).replaceAll('{V2}', VALUE_LOCALCDN_VERSION));
				}

				function init() {
					// Nothing to do here
				}

				function v01_To_v02() {
					const urls = LC.listurls();
					for (const url of urls) {
						if (url === KEY_LOCALCDN_VERSION) {continue;}
						CDN[url] = {
							url: url,
							time: 0,
							content: CDN[url]
						};
					}
				}
			}

			function clearExpired() {
				const resources = LC.list();
				const time = (new Date()).getTime();

				for (const resource of resources) {
					expired(resource.time, time) && LC.delete(resource.url);
				}
			}

			function expired(t1, t2) {
				return (t2 - t1) > (LC.expire * 60 * 60 * 1000);
			}

			upgradeConfig();
			clearExpired();
		}

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

		function 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 = document.createElement("style");
		id && (style.id = id);
		style.textContent = css;
		for (const elm of document.querySelectorAll('#'+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 = document.createElement('textarea');
        document.body.appendChild(newInput);
        newInput.value = text;
        newInput.select();
        document.execCommand('copy');
        document.body.removeChild(newInput);
    }
})();