EhAria2下载助手

发送任务到Aria2,并查看下载进度

// ==UserScript==
// @name         EhAria2下载助手
// @namespace    com.xioxin.AriaEh
// @version      1.1
// @description  发送任务到Aria2,并查看下载进度
// @author       xioxin, SchneeHertz
// @homepage     https://github.com/EhTagTranslation/UserScripts
// @supportURL   https://github.com/EhTagTranslation/UserScripts/issues
// @include      *://exhentai.org/*
// @include      *://e-hentai.org/*
// @include      *hath.network/archive/*
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM.getValue
// @grant        GM.setValue
// @connect      localhost
// @connect      127.0.0.1
// ==/UserScript==


const IS_EX = window.location.host.includes("exhentai");
const gmc = new GM_config({
    'id': 'AriaEhSetting',
    'title': 'AriaEh设置',
    'fields': {
        'ARIA2_RPC': {
            'section': [ 'ARIA2配置', '如果你的下载服务器不是本机,需要的将域名添加到: <br>设置 - XHR 安全 - 用户域名白名单'],
            'label': 'ARIA2_RPC地址<b style="color: red">(必填)</b>',
            'title': 'ARIA2_RPC地址, 例如: http://127.0.0.1:6800/jsonrpc',
            'labelPos': 'left',
            'type': 'text',
            'default': ''
        },
        'ARIA2_SECRET': {
            'label': 'ARIA2_RPC密钥',
            'title': 'ARIA2_RPC密钥',
            'type': 'text',
            'default': ''
        },
        'ARIA2_DIR': {
            'label': '保存文件位置',
            'title': '例如 /Downloads 或者 D:\\Downloads, 留空将下载到默认位置',
            'type': 'text',
            'default': ''
        },
        'USE_ONE_CLICK_DOWNLOAD': {
            'section': [ '一键下载存档', '该功能是将"存档下载"的连接发送给Aria2.在列表页面与详情页增加橙色下载按钮.<b style="color: #f60">注意该功能会产生下载费用!</b>'],
            'labelPos': 'left',
            'label': '启用',
            'title': '在列表页与详情页增加存档一键下载按钮',
            'type': 'checkbox',
            'default': true
        },
        'ONE_CLICK_DOWNLOAD_DLTYPE': {
            'label': '一键下载画质',
            'type': 'select',
            'labelPos': 'left',
            'options': ['org(原始档案)', 'res(重采样档案)'],
            'default': 'org(原始档案)'
        },
        'USE_TORRENT_POP_LIST': {
            'section': [ '种子下载快捷弹窗', '鼠标指向详情页的"种子下载",或者列表的绿色箭头.将显示种子列表浮窗.并高亮最大体积,最新更新.' ],
            'labelPos': 'left',
            'label': '启用',
            'title': '使用种子下载快捷弹窗',
            'type': 'checkbox',
            'default': true
        },
        'REPLACE_EX_TORRENT_URL': {
            'label': '里站使用表站种子连接',
            'title': '替换里站种子域名为ehtracker.org',
            'type': 'checkbox',
            'default': true
        },
        'USE_MAGNET': {
            'label': '使用磁力链替代种子链接',
            'title': '先将种子转换为磁力链,再发送给Aria2',
            'type': 'checkbox',
            'default': false
        },
        'USE_LIST_TASK_STATUS': {
            'section': [ '下载进度展示'],
            'labelPos': 'left',
            'label': '在搜索列表页',
            'title': '在搜索列表页',
            'type': 'checkbox',
            'default': true
        },
        'USE_GALLERY_DETAIL_TASK_STATUS': {
            'label': '在画廊详情页',
            'title': '在画廊详情页',
            'type': 'checkbox',
            'default': true
        },
        'USE_HATH_ARCHIVE_TASK_STATUS': {
            'label': '在存档下载页面',
            'title': '在存档下载页面',
            'type': 'checkbox',
            'default': true
        },
        'USE_TORRENT_TASK_STATUS': {
            'label': '在种子下载页面',
            'title': '在种子下载页面',
            'type': 'checkbox',
            'default': true
        },
        'INITIALIZED': {
            'type': 'hidden',
            'default': false,
        }
    },
    'events': {
        'init': onConfigInit
    },
    css: `
    #AriaEhSetting { background: #E3E0D1; }
    #AriaEhSetting .config_header { margin-bottom: 8px; }
    #AriaEhSetting .section_header { font-size: 12pt; }
    #AriaEhSetting .section_header_holder { margin-top: 16pt; }
    #AriaEhSetting input, #AriaEhSetting select { background:#E3E0D1; border: 2px solid #B5A4A4; border-radius: 3px; }
    #AriaEhSetting .field_label { display: inline-block; min-width: 150px; text-align: right;}
    ${IS_EX ? `
    #AriaEhSetting { background:#4f535b; color: #FFF; }
    #AriaEhSetting .section_header { border: 1px solid #000;  }
    #AriaEhSetting .section_desc { background:#34353b; border: 1px solid #000; color: #CCC; }
    #AriaEhSetting input, #AriaEhSetting select { background:#34353b; color: #FFF; border: 2px solid #8d8d8d; border-radius: 3px; }
    #AriaEhSetting_resetLink { color: #FFF; }
    `: ''}
    `
})
console.log('gmc', gmc);

function onConfigInit() {
    // 如果没有配置地址, 在首页弹出配置页面
    if((!gmc.get('ARIA2_RPC')) && window.location.pathname == '/') {
        gmc.open();
        AriaEhSetting.style = iframeCss
        throw new Error("未设置ARIA2_RPC地址");
    }
    init()
}

const iframeCss = `
    width: 400px;
    height: 480px;
    border: 1px solid;
    border-radius: 4px;
    position: fixed;
    z-index: 9999;
`

GM_registerMenuCommand("设置", () => {
    gmc.open()
    AriaEhSetting.style = iframeCss
})


let ARIA2_CLIENT_ID = GM_getValue('ARIA2_CLIENT_ID', '');
if (!ARIA2_CLIENT_ID) {
    ARIA2_CLIENT_ID = "EH-" + new Date().getTime();
    GM_setValue("ARIA2_CLIENT_ID", ARIA2_CLIENT_ID);
}

const IS_TORRENT_PAGE = window.location.href.includes("gallerytorrents.php");
const IS_HATH_ARCHIVE_PAGE = window.location.href.includes("hath.network/archive");
const IS_GALLERY_DETAIL_PAGE = window.location.href.includes("/g/");

const STYLE = `
.aria2helper-box {
    height: 27px;
    line-height: 27px;
}
.aria2helper-button { }
.aria2helper-loading { }
.aria2helper-message { cursor: pointer;  }
.aria2helper-status {
    display: none;
    padding: 4px 4px;
    font-size: 12px;
    text-align: center;
    background: rgba(${IS_EX ? '0,0,0': '255,255,255'}, 0.6);
    margin: 4px 8px;
    border-radius: 4px;
    font-weight: normal;
    white-space: normal;
    box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.glname .aria2helper-status {
    margin: 4px 0px;
}
.gl3e .aria2helper-status {
    margin: 0px 4px;
    padding: 4px 4px;
    width: 112px !important;
    white-space: normal;
    box-sizing: border-box;
    text-align: center !important;
}
.gl3e .aria2helper-status span {
    display: block;
}
.gl1t .aria2helper-status{
    margin: 4px 4px;
}
`;


const ONE_CLICK_STYLE = `
.aria2helper-one-click {
    width: 15px;
    height: 15px;
    background: radial-gradient(#ffc36b,#c56a00);
    border-radius: 15px;
    border: 1px #666 solid;
    box-sizing: border-box;
    color: #ebeae9;
    text-align: center;
    line-height: 15px;
    cursor: pointer;
    user-select: none;
}
.aria2helper-one-click:hover {
    background: radial-gradient(#bf893b,#985200);
}
.aria2helper-one-click.bt {
    background: radial-gradient(#a2d04f,#5fb213);
}
.aria2helper-one-click.bt:hover {
    background: radial-gradient(#95cf2b,#427711);
}
.aria2helper-one-click i {
    font-style: initial;
    transform: scale(0.7);
    margin-left: -1.5px;
}
.gldown {
    width: 35px !important;
    display: flex;
    flex-direction: row;
    justify-content: space-between;
}
.gl3e>div:nth-child(6) {
    left: 45px;
}
.aria2helper-one-click svg circle {
    stroke: #fff !important;
    stroke-width: 15px !important;
}
.aria2helper-one-click svg {
    width: 10px;
    display: inline-block;
    height: 10px;
    padding-top: 1.3px;
}
.gsp .aria2helper-one-click {
    display: inline-block;
    margin-left: 8px;
    vertical-align: -1.5px;
}

#gd5 .g2 {
    position: relative;
}
#btList{
    display: none;
    background: #f00;
    width: 90%;
    position: absolute;
    border-radius: 4px;
    border: 1px;
    z-index: 999;
    padding: 8px 0;
    font-size: 12px;
    text-align: left;
    background: rgba(${IS_EX ? '0,0,0': '255,255,255'}, 0.6);
    border-radius: 4px;
    font-weight: normal;
    white-space: normal;
    box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
    backdrop-filter: saturate(180%) blur(20px);
    font-size: 12px;
    width: max-content;
    margin-top: 8px;
}
.nowrap {
    white-space:nowrap;
}
#gmid #btList {
    right: 0;
    margin-top: 16px;
}
.gldown #btList{
    left: 0;
}
.gldown #btList table {
    max-width: 60vw;
}
.btListShow #btList{
    display: block;
}
#btList .bt-item {
    padding: 4px 8px;
}
#btList .bt-name {
    font-weight: bold;
}
#btList .quality {
    font-weight: bold;
}
#btList td span {
    display: inline-block;
    padding: 2px 4px;
    height: 16px;
    line-height: 16px;
}
#btList td span.quality {
    font-weight: bold;
    border-radius: 4px;
    background: ${IS_EX ? '#fff': '#5c0d12'};
    color:  ${IS_EX ? '#000': '#fff'};
}
#btList table {
    border-spacing:0;
    border-collapse:collapse;
    max-width: 80vw;
}
#btList tr th {
    padding-bottom: 8px;
    text-align: center;
}
#btList tr th span {
    font-weight: 400;
}
#btList tr td {
    padding: 2px 4px;
}
#btList tr:hover td {
    background: rgba(${IS_EX ? '0,0,0': '255,255,255'}, 0.6);
}
#btList tr>td:first-of-type, #btList tr>th:first-of-type {
    padding: 0 8px;
}
#btList tr>td:last-child, #btList tr>th:last-child {
    padding-right: 8px;
}
`;

const SVG_LOADING_ICON = `<svg style="margin: auto; display: block; shape-rendering: auto;" width="24px" height="24px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" fill="none" stroke="${IS_EX ? '#f1f1f1': '#5C0D11'}" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138">
  <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"></animateTransform>
</circle></svg>`;

const ARIA2_ERROR_MSG = {
    '2': '操作超时',
    '3': '无法找到指定资源',
    '4': "无法找到指定资源.",
    '5': "由于下载速度过慢, 下载已经终止.",
    '6': "网络问题",
    '8': "服务器不支持断点续传",
    '9': "可用磁盘空间不足",
    '10': "分片大小与 .aria2 控制文件中的不同.",
    '11': "aria2 已经下载了另一个相同文件.",
    '12': "aria2 已经下载了另一个相同哈希的种子文件.",
    '13': "文件已经存在.",
    '14': "文件重命名失败.",
    '15': "文件打开失败.",
    '16': "文件创建或删除已有文件失败.",
    '17': "文件系统出错.",
    '18': "无法创建指定目录.",
    '19': "域名解析失败.",
    '20': "解析 Metalink 文件失败.",
    '21': "FTP 命令执行失败.",
    '22': "HTTP 返回头无效或无法识别.",
    '23': "指定地址重定向过多.",
    '24': "HTTP 认证失败.",
    '25': "解析种子文件失败.",
    '26': '指定 ".torrent" 种子文件已经损坏或缺少 aria2 需要的信息.',
    '27': '指定磁链地址无效.',
    '28': '设置错误.',
    '29': '远程服务器繁忙, 无法处理当前请求.',
    '30': '处理 RPC 请求失败.',
    '32': '文件校验失败.'
};


class AriaClientLite {
    constructor(opt = {}) {
        this.rpc = opt.rpc;
        this.secret = opt.secret;
        this.id = opt.id;
    }

    async addUri(url, dir = '') {
        const response = await this.post(this.rpc, this._addUriParameter(url, dir));
        return this.singleResponseGuard(response);
    }

    async tellStatus(id) {
        const response = await this.post(this.rpc, this._tellStatusParameter(id));
        return this.singleResponseGuard(response);
    }

    async batchTellStatus(ids = []) {
        if(!ids.length) return [];
        const dataList = ids.map(id => this._tellStatusParameter(id));
        const response = await this.post(this.rpc, dataList);
        if(response.responseType !== 'json') throw `不支持的数据格式: ${response.status}`;
        const json = JSON.parse(response.responseText);
        if(!Array.isArray(json)) throw "批量请求数据结构错误";
        return json.map(v => v.result);
    }

    request(url, opt={}) {
        return new Promise((resolve, reject) => {
            opt.onerror = opt.ontimeout = reject
            opt.onload = resolve
            GM_xmlhttpRequest({
                url,
                timeout: 2000,
                responseType: 'json',
                ...opt
            });
        })
    }

    post(url, data = {}) {
        return this.request(url, {
            method: "POST",
            data: JSON.stringify(data),
        });
    }

    singleResponseGuard(response) {
        if(response.responseType !== 'json') {
            throw `不支持的数据格式: ${response.status}`;
        }
        const json = JSON.parse(response.responseText);
        if(response.status !== 200 && json && json.error) throw `${json.error.code} ${json.error.message}`;
        if(response.status !== 200) throw `错误: ${response.status}`;
        return json.result;
    }

    _jsonRpcPack(method, params) {
        if (this.secret) params.unshift("token:" + this.secret);
        return {
            "jsonrpc": "2.0",
            "method": method,
            "id": ARIA2_CLIENT_ID,
            params
        }
    }

    _addUriParameter(url, dir = '') {
        const opt = {"follow-torrent": 'true'};
        if(dir) opt['dir'] = dir;
        return this._jsonRpcPack('aria2.addUri', [ [url], opt ]);
    }

    _tellStatusParameter(id) {
        return this._jsonRpcPack('aria2.tellStatus', [id]);
    }

}

class SendTaskButton {
    constructor(gid, link) {
        this.element = document.createElement("div");;
        this.link = link;
        this.gid = gid;

        this.element.className = "aria2helper-box";
        this.loading = document.createElement("div");
        this.loading.className = "aria2helper-loading";
        this.loading.innerHTML = SVG_LOADING_ICON;

        this.message = document.createElement("div");
        this.message.className = "aria2helper-message";

        this.button = document.createElement("input");
        this.button.type = "button";
        this.button.value = "发送到Aria2";
        this.button.className = 'stdbtn aria2helper-button';
        this.button.onclick = () => this.buttonClick();
        this.element.appendChild(this.button);
        this.element.appendChild(this.loading);
        this.element.appendChild(this.message);
        this.message.onclick = () => this.show(this.button);
        this.show(this.button);
    }

    show(node) {
        this.loading.style.display = 'none';
        this.message.style.display = 'none';
        this.button.style.display = 'none';
        node.style.display = '';
    }

    showMessage(msg) {
        this.message.textContent = msg;
        this.show(this.message);
    }
    showLoading() {
        this.show(this.loading);
    }

    async buttonClick() {
        this.showLoading();
        try {
            const id = await ariaClient.addUri(getTorrentLink(this.link), gmc.get('ARIA2_DIR'));
            Tool.setTaskId(this.gid, id);
            this.showMessage("成功");
        } catch (error) {
            console.error(error);
            if(typeof error === 'string') return this.showMessage(error || "请求失败");
            if(error.status) return this.showMessage("请求失败 HTTP" + error.status);
            this.showMessage(error.message || "请求失败");
        }
    }
}

class TaskStatus {
    constructor() {
        this.element = document.createElement("div");
        this.element.className = 'aria2helper-status'
        this.monitorCount = 0;
    }
    setStatus(task) {
        this.monitorCount ++;
        const statusBox = this.element;
        statusBox.style.display = 'block'
        const completedLength = parseInt(task.completedLength, 10) || 0;
        const totalLength = parseInt(task.totalLength, 10) || 0;
        const downloadSpeed = parseInt(task.downloadSpeed, 10) || 0;
        const uploadLength = parseInt(task.uploadLength, 10) || 0;
        const uploadSpeed = parseInt(task.uploadSpeed, 10) || 0;
        const connections = parseInt(task.connections, 10) || 0;
        const file = task.files[0];
        const filePath = file ? file.path : '';
        const name = filePath.split(/[\/\\]/).pop();
        // 显示扩展名 用于区分当前下载的是种子 还是文件。
        const ext = name.includes(".") ? name.split('.').pop() : '';

        let progress = '-';

        if(totalLength) {
            progress = (completedLength/totalLength * 100).toFixed(2) + '%';
        }

        // ⠓ ⠚ ⠕ ⠪
        const iconList = "⠓⠋⠙⠚".split("");
        const icon = iconList[this.monitorCount % iconList.length];
        let info = [];

        if (task.status === 'active') {
            if (task.verifyIntegrityPending) {
                info.push(`<b>${icon} 🔍 等待验证</b>`);
            } else if (task.verifiedLength) {
                info.push(`<b>${icon} 🔍 正在验证</b>`);
                if (task.verifiedPercent) {
                    info.push(`已验证 (${task.verifiedPercent})`);
                }
            } else if (task.seeder === true || task.seeder === 'true') {
                info.push(`<b>${icon} 📤 做种</b>`);
                info.push(`已上传:${Tool.fileSize(uploadLength)}`);
                info.push(`速度:${Tool.fileSize(uploadSpeed)}/s`);
            } else {
                info.push(`<b>${icon} 📥 下载中</b>`);
                info.push(`进度:${progress}`);
                info.push(`速度:${Tool.fileSize(downloadSpeed)}/s`);
            }
        } else if (task.status === 'waiting') {
            info.push(`<b>${icon} ⏳ 排队</b>`);
        } else if (task.status === 'paused') {
            info.push(`<b>${icon} ⏸ 暂停</b>`);
            info.push(`进度:${progress}`);
        } else if (task.status === 'complete') {
            info.push(`<b>${icon} ☑️ 完成</b>`);
        } else if (task.status === 'error') {
            const errorMessageCN = ARIA2_ERROR_MSG[task.errorCode]
            info.push(`<b>${icon} 错误</b> (${task.errorCode}: ${errorMessageCN || task.errorMessage || "未知错误"})`);
            info.push(`进度:${progress}`);
        } else if (task.status === 'removed') {
            info.push(`<b>${icon} ⛔️ 已删除</b>`);
        }

        info.push(`类型:${ext}`);

        statusBox.innerHTML = info.map(v => `<span>${v}</span>`).join(' ');

        if(task.followedBy && task.followedBy.length) {
            // BT任务跟随
            Tool.setTaskId(GID, task.followedBy[0]);
        }
    }

}

class Tool {


    static htmlDecodeByRegExp (str) {
        let temp = "";
        if(str.length == 0) return "";
        temp = str.replace(/&amp;/g,"&");
        temp = temp.replace(/&lt;/g,"<");
        temp = temp.replace(/&gt;/g,">");
        temp = temp.replace(/&nbsp;/g," ");
        temp = temp.replace(/&#39;/g,"\'");
        temp = temp.replace(/&quot;/g,"\"");
        return temp;
    }

    static addStyle(styles) {
        var styleSheet = document.createElement("style")
        styleSheet.innerText = styles
        document.head.appendChild(styleSheet)
    }

    static urlGetGId(url) {
        let m;
        m = /gid=(\d+)/i.exec(url);
        if(m) return parseInt(m[1], 10);
        m = /archive\/(\d+)\//i.exec(url);
        if(m) return parseInt(m[1], 10);
        m = /\/g\/(\d+)\//i.exec(url);
        if(m) return parseInt(m[1], 10);
    }

    static urlGetToken(url) {
        let m;
        m = /&t(oken)?=(\d+)/i.exec(url);
        if(m) return m[2];
        m = /\/g\/(\d+)\/(\w+)\//i.exec(url);
        if(m) return m[2];
    }

    static setTaskId(ehGid, ariaGid) {
        GM_setValue("task-" + ehGid, ariaGid);
    }

    static getTaskId(ehGid) {
        return GM_getValue("task-" + ehGid, 0);
    }

    static fileSize(_size, round = 2) {
        const divider = 1024;

        if (_size < divider) {
          return _size + ' B';
        }

        if (_size < divider * divider && _size % divider === 0) {
          return (_size / divider).toFixed(0) + ' KB';
        }

        if (_size < divider * divider) {
          return `${(_size / divider).toFixed(round)} KB`;
        }

        if (_size < divider * divider * divider && _size % divider === 0) {
          return `${(_size / (divider * divider)).toFixed(0)} MB`;
        }

        if (_size < divider * divider * divider) {
          return `${(_size / divider / divider).toFixed(round)} MB`;
        }

        if (_size < divider * divider * divider * divider && _size % divider === 0) {
          return `${(_size / (divider * divider * divider)).toFixed(0)} GB`;
        }

        if (_size < divider * divider * divider * divider) {
          return `${(_size / divider / divider / divider).toFixed(round)} GB`;
        }

        if (_size < divider * divider * divider * divider * divider &&
          _size % divider === 0) {
          const r = _size / divider / divider / divider / divider;
          return `${r.toFixed(0)} TB`;
        }

        if (_size < divider * divider * divider * divider * divider) {
          const r = _size / divider / divider / divider / divider;
          return `${r.toFixed(round)} TB`;
        }

        if (_size < divider * divider * divider * divider * divider * divider &&
          _size % divider === 0) {
          const r = _size / divider / divider / divider / divider / divider;
          return `${r.toFixed(0)} PB`;
        } else {
          const r = _size / divider / divider / divider / divider / divider;
          return `${r.toFixed(round)} PB`;
        }
    }

}

class MonitorTask {
    constructor() {
        this.gids = [];
        this.taskIds = [];
        this.taskToGid = {};
        this.statusMap = {};
        this.timerId = 0;
        this.run = false;
    }

    start() {
        this.run = true;
        this.refreshTaskIds();
        this.loadStatus();
    }

    stop() {
        this.run = false;
        if(this.timerId)clearTimeout(this.timerId);
    }

    addGid(gid) {
        this.gids.push(gid);
        GM_addValueChangeListener("task-" + gid, () => {
            this.refreshTaskIds();
        });
        this.statusMap[gid] = new TaskStatus();
        return this.statusMap[gid];
    }

    refreshTaskIds() {
        if(!this.gids.length) return;
        this.taskIds = this.gids.map(v => {
            const agid = Tool.getTaskId(v);
            if(agid) this.taskToGid[agid] = v;
            return agid;
        }).filter(v => v);
    }

    async loadStatus() {
        if(!this.run) return;
        let hasActive = false;
        try {
            const batch = await ariaClient.batchTellStatus(this.taskIds);
            batch.forEach(task => {
                this.setStatusToUI(task);
                if(task) hasActive = hasActive || "active" === task.status;
            });
        } catch (error) {
            console.error(error);
        }
        this.timerId = setTimeout(() => {
            this.loadStatus()
        }, hasActive ? 500 : 5000);
    }

    setStatusToUI(task) {
        if(!task) return;
        const gid = this.taskToGid[task.gid];
        if(!gid) return;
        const ui = this.statusMap[gid];
        if(!ui) return;
        ui.setStatus(task);
    }
}

const GID = Tool.urlGetGId(window.location.href);
const TOKEN = Tool.urlGetToken(window.location.href);

let ariaClient;

console.log({GID, TOKEN});


function oneClickButton(gid, pageLink, archiverLink) {
    const oneClick = document.createElement('div');
    oneClick.textContent = "🡇";
    oneClick.title = "[Aria2] 一键下载";
    oneClick.classList.add("aria2helper-one-click");
    let loading = false;
    oneClick.onclick = async () => {
        if(loading === true) return;
        oneClick.innerHTML = SVG_LOADING_ICON;
        loading = true;
        try {
            if (pageLink && !archiverLink) {
                const g = await fetch(pageLink, { credentials: "include" }).then(v => v.text());
                const archiverLinkMatch = /'(https:\/\/e.hentai\.org\/archiver\.php?.*?)'/i.exec(g);
                archiverLink = Tool.htmlDecodeByRegExp(archiverLinkMatch[1]).replace("--", "-");
            }
            let formData = new FormData();
            formData.append("dltype", gmc.get('ONE_CLICK_DOWNLOAD_DLTYPE').slice(0, 3));
            formData.append("dlcheck","Download Original Archive");
            const archiverHtml = await fetch(
                archiverLink,
                {method: "POST", credentials: "include", body: formData}
            ).then(v => v.text());
            const downloadLinkMatch = /"(http.*?\.hath.network\/archive.*?)"/i.exec(archiverHtml);
            const downloadLink = downloadLinkMatch[1] + '?start=1';
            const taskId = await ariaClient.addUri(downloadLink, gmc.get('ARIA2_DIR'));
            Tool.setTaskId(gid, taskId);
            oneClick.innerHTML = "✔";
            setTimeout(() => {
                oneClick.innerHTML = "🡇";
            }, 2000);
        } catch (error) {
            alert("一键下载失败:" + error.message);
            oneClick.innerHTML = "🡇";
        }
        loading = false;
    }
    return oneClick;
}


async function getTorrentList(gid, token, lifeTime = 0) {
    const html = await fetch(`${document.location.origin}/gallerytorrents.php?gid=${gid}&t=${token}`, {credentials: "include"}).then(v => v.text());
    const safeHtml = html.replace(/^.*<body>(.*)<\/body>.*$/igms,"$1").replace(/<script.*?>(.*?)<\/script>/igms, '');
    const dom = document.createElement('div')
    dom.innerHTML = safeHtml;
    const formList = [...dom.querySelectorAll("form")];
    const list = formList.map((e, i) => {
        const link = e.querySelector("table > tbody > tr:nth-child(3) > td > a");
        if(!link) return null;
        const posted = e.querySelector("table > tbody > tr:nth-child(1) > td:nth-child(1)");
        const size = e.querySelector("table > tbody > tr:nth-child(1) > td:nth-child(2)");
        const seeds = e.querySelector("table > tbody > tr:nth-child(1) > td:nth-child(4)");
        const peers = e.querySelector("table > tbody > tr:nth-child(1) > td:nth-child(5)");
        const downloads = e.querySelector("table > tbody > tr:nth-child(1) > td:nth-child(6)");
        const uploader = e.querySelector("table > tbody > tr:nth-child(2) > td:nth-child(1)");
        const getNumber = (text = '') => parseFloat((text.match(/[\d\.]+/) || [0])[0]);
        const getValueText = (text = '') => (text.match(/:(.*)/) || ['',''])[1].trim();
        const sizeText = getValueText(size.textContent);
        const sizeNumber = getNumber(sizeText);
        const unit = [sizeText.match(/[KMGT]i?B/) || ['']][0];
        const magnification = {
            "KB": 1000,
            "MB": 1000 * 1000,
            "GB": 1000 * 1000 * 1000,
            "TB": 1000 * 1000 * 1000 * 1000,
            "KiB": 1024,
            "MiB": 1024 * 1024,
            "GiB": 1024 * 1024 * 1024,
            "TiB": 1024 * 1024 * 1024 * 1024,
        }
        if(!magnification[unit]) {
            console.warn("未知单位: ", unit, size);
        }
        let bytes = magnification[unit] ? sizeNumber * magnification[unit] : -1;
        const time = new Date(getValueText(posted.textContent));
        return {
            index: i,
            time: time,
            readableTime: dateStr(time),
            size: sizeText,
            bytes: bytes,
            seeds: getNumber(seeds.textContent), // 做种
            peers: getNumber(peers.textContent), // 下载中
            downloads: getNumber(downloads.textContent), // 完成
            user: getValueText(uploader.textContent),
            name: link.textContent,
            link: link.getAttribute('href'),
            achievements: new Set(),
        }
    }).filter(v => v);

    let maxBytes = 0;
    let maxTime = 0;
    let maxSeeds = 0;
    let maxPeers = 0;
    let maxDownloads = 0;
    list.forEach(v => {
        maxBytes = Math.max(maxBytes, v.bytes);
        maxTime = Math.max(maxTime, v.time.getTime());
        maxSeeds = Math.max(maxSeeds, v.seeds);
        maxPeers = Math.max(maxPeers, v.peers);
        maxDownloads = Math.max(maxDownloads, v.downloads);
    });
    list.forEach(v => {
        const time = v.time.getTime();
        if(v.bytes == maxBytes) v.achievements.add("size");
        if(time == maxTime) v.achievements.add("time");
        if(v.seeds == maxSeeds && maxSeeds > 0) v.achievements.add("seeds");
        if(v.peers == maxPeers && maxPeers > 0) v.achievements.add("peers");
        if(v.downloads == maxDownloads && maxDownloads > 0) v.achievements.add("downloads");
        if(time < lifeTime) v.achievements.add("overdue");
    });

    list.sort((a,b) => {
        return b.time.getTime() - a.time.getTime();
    })

    console.log('list', list);
    return list;
}

function dateStr(date = new Date()){
    const today = new Date();
    const now = today.getTime();
    const time = Math.floor((now - date.getTime())/1000) + ( today.getTimezoneOffset() * 60);
    if(time <= 60){
        return '刚刚';
    }else if(time<=60*60){
        return Math.floor(time/60)+"分钟前";
    }else if(time<=60*60*24){
        return  Math.floor(time/60/60)+"小时前";
    }else if(time<=60*60*24*7) {
        return Math.floor(time/60/60/24) + "天前";
    }else if(time<=60*60*24*7*4) {
        return Math.floor(time/60/60/24/7) + "周前";
    }else if(time<=60*60*24*365) {
        return (date.getMonth()+1).toString().padStart(2, '0')+"月"+date.getDate().toString().padStart(2, '0')+"日"
    }
    return date.getFullYear()+'年';
}

async function torrentsPopDetail(btButtonBox, gid = GID, token = TOKEN, buttonLeft = false, twoLines = false) {
    if(!btButtonBox) {
        btButtonBox = document.querySelector('#gd5 .g2:nth-child(3)');
    }
    if(btButtonBox) {
        boxA = btButtonBox.querySelector('a');
        boxA.onmouseenter = async () => {
            let btListBox = btButtonBox.querySelector('#btList');
            btButtonBox.classList.add('btListShow');
            if(!btListBox) {
                btListBox = document.createElement("div");
                btListBox.id = 'btList';
                btButtonBox.appendChild(btListBox);
                btListBox.innerHTML = SVG_LOADING_ICON;
                try {
                    const torents = await getTorrentList(gid, token);
                    if(torents.length) {
                        const achievement = (item, name) => item.achievements.has(name) ? 'quality' : '';
                        let th = '';
                        if(twoLines) {
                            th = `
                            <th>名称</th>
                            <th>体积</th>
                            <th>时间</th>
                            <th><span title="正在做种 Seeds">📤</span></th>
                            <th><span title="正在下载 Peers">📥</span></th>
                            <th><span title="下载完成 Downloads">✔️</span></th>`;
                        }else {
                            th = `
                            ${buttonLeft ? "<th></th><th></th>" : ""}
                            <th>名称</th>
                            <th>体积</th>
                            <th>时间</th>
                            <th><span title="正在做种 Seeds">📤</span></th>
                            <th><span title="正在下载 Peers">📥</span></th>
                            <th><span title="下载完成 Downloads">✔️</span></th>`;
                        }


                        btListBox.innerHTML = `<table>
                        <tr>
                        ${th}
                        </tr>
                        ${
                            torents.map(item => {

                                const button1 = `<td class="bt-button nowrap"><div data-link="${item.link}" data-gid="${gid}" class="aria2helper-one-click bt-download-button bt ">🡇</div></td>`;
                                const button2 = `<td class="bt-button nowrap"><div data-link="${item.link}" data-gid="${gid}" class="aria2helper-one-click bt-copy-button icon bt ">✂</div></td>`;
                                    const nameHtml = `<td class="bt-name"><a href="${item.link}">${item.name}</a></td>`;
                                    const infoHtml = `<td class="bt-size nowrap"><span class="${achievement(item, 'size')}">${item.size}</span></td>
                                    <td class="bt-time nowrap"><span title="${item.time.toLocaleString()}" class="${achievement(item, 'time')}">${item.readableTime}</span></td>
                                    <td class="bt-seeds nowrap"><span class="${achievement(item, 'seeds')}">${item.seeds}</span></td>
                                    <td class="bt-peers nowrap"><span class="${achievement(item, 'peers')}">${item.peers}</span></td>
                                    <td class="bt-downloads nowrap"><span class="${achievement(item, 'downloads')}">${item.downloads}</span></td>`;
                                    if(twoLines) {
                                        return `
                                <tr class="bt-item no-hover">
                                    ${nameHtml}
                                </tr>
                                <tr class="bt-item">
                                    ${button1 + button2}
                                    ${infoHtml}
                                </tr>
                                `;
                                    }
                                return `
                                <tr class="bt-item">
                                    ${buttonLeft ? button1 + button2 : ''}
                                    ${nameHtml}
                                    ${infoHtml}
                                    ${buttonLeft ? '' :  button2 + button1 }
                                </tr>
                                `
                            }).join('')
                        }</table>`;

                        btListBox.onclick = async (event) => {
                            if(event.target.classList.contains("bt-download-button")) {
                                const link = event.target.dataset.link;
                                const gid = parseInt(event.target.dataset.gid, 10);
                                if(event.target.dataset.loading === true) return;
                                event.target.innerHTML = SVG_LOADING_ICON;
                                event.target.dataset.loading = true;
                                try {
                                    const taskId = await ariaClient.addUri(getTorrentLink(link), gmc.get('ARIA2_DIR'));
                                    Tool.setTaskId(gid, taskId);
                                    event.target.innerHTML = "✔";
                                    setTimeout(() => {
                                        event.target.innerHTML = "🡇";
                                    }, 2000);
                                } catch (error) {
                                    alert("一键下载失败:" + error.message);
                                    event.target.innerHTML = "🡇";
                                }
                                event.target.dataset.loading = false;
                            }
                            if(event.target.classList.contains("bt-copy-button") || event.target.parentNode.contains("bt-copy-button")) {
                                const link = event.target.dataset.link;
                                const magnet = torrentLink2magnet(link);
                                if (magnet) {
                                    GM_setClipboard(magnet);
                                    event.target.innerHTML = "✔";
                                    setTimeout(() => {
                                        event.target.innerHTML = "✂";
                                    }, 2000);
                                }
                            }

                        }
                    }else {
                        btListBox.innerHTML = "没有可用种子"
                    }
                } catch (error) {
                    btListBox.innerHTML = error.message;
                }
            }
        }
        btButtonBox.onmouseleave = () => {
            btButtonBox.classList.remove('btListShow');
        }
    }
}

function getTorrentInfo(link) {
    let match = link.match(/\/(\d+)\/([0-9a-f]{40})/i);
    if(!match) return;
    return {
        hash: match[2],
        trackerId: match[1],
    }
}

function torrentLink2magnet (link) {
    const info = getTorrentInfo(link);
    if(!info) return;
    return `magnet:?xt=urn:btih:${info.hash}&tr=${encodeURIComponent(`http://ehtracker.org/${info.trackerId}/announce`)}`;
}

function torrentLinkForceEhTracker (link) {
    const info = getTorrentInfo(link);
    if(!info) return;
    return `https://ehtracker.org/get/${info.trackerId}/${info.hash}.torrent`;
}

function getTorrentLink(link) {
    if(gmc.get('USE_MAGNET')) {
        return torrentLink2magnet(link) || link;
    }
    if(link.includes('exhentai.org') && gmc.get('REPLACE_EX_TORRENT_URL')) {
        return torrentLinkForceEhTracker(link) || link;
    }
    return link;
}


function init() {
    ariaClient = new AriaClientLite({rpc: gmc.get('ARIA2_RPC'), secret: gmc.get('ARIA2_SECRET'), id: ARIA2_CLIENT_ID});
    Tool.addStyle(STYLE);

    const monitorTask = new MonitorTask();
    if(GID) {

        // button
        if(IS_TORRENT_PAGE) {
            let tableList = document.querySelectorAll("#torrentinfo form table");
            if(tableList && tableList.length){
                tableList.forEach(function (table) {
                    let insertionPoint = table.querySelector('input');
                    if(!insertionPoint)return;
                    let a = table.querySelector('a');
                    if(!a) return;
                    const link = a.href;
                    const button = new SendTaskButton(GID, link);
                    insertionPoint.parentNode.insertBefore(button.element, insertionPoint);
                });
            }
        }

        if (IS_HATH_ARCHIVE_PAGE) {
            let insertionPoint = document.querySelector("#db a");
            if(!insertionPoint)return;
            const link = insertionPoint.href;
            const button = new SendTaskButton(GID, link);
            button.element.style.marginTop = '16px';
            insertionPoint.parentNode.insertBefore(button.element, insertionPoint);
        }

        // 状态监听
        const taskStatusUi = monitorTask.addGid(GID);
        if (IS_HATH_ARCHIVE_PAGE && gmc.get('USE_HATH_ARCHIVE_TASK_STATUS')) {
            taskStatusUi.element.style.marginTop = '8px';
            const insertionPoint = document.querySelector('#db strong');
            if(insertionPoint) insertionPoint.parentElement.insertBefore(taskStatusUi.element, insertionPoint.nextElementSibling);
        }
        if (IS_GALLERY_DETAIL_PAGE && gmc.get('USE_GALLERY_DETAIL_TASK_STATUS')) {
            const insertionPoint = document.querySelector('#gd2');
            if(insertionPoint) insertionPoint.appendChild(taskStatusUi.element);
        }
        if (IS_TORRENT_PAGE && gmc.get('USE_TORRENT_TASK_STATUS')) {
            const insertionPoint = document.querySelector('#torrentinfo p');
            if(insertionPoint) insertionPoint.parentElement.insertBefore(taskStatusUi.element, insertionPoint.nextElementSibling);
        }
        if(IS_GALLERY_DETAIL_PAGE && gmc.get('USE_TORRENT_POP_LIST')) {
            torrentsPopDetail();
        }
    } else if(gmc.get('USE_LIST_TASK_STATUS')) {
        const trList = document.querySelectorAll(".itg tr, .itg .gl1t");
        if(trList && trList.length) {
            const insertionPointMap = {};
            let textAlign = 'left';
            trList.forEach(function (tr) {
                let glname = tr.querySelector(".gl3e, .glname");
                let a = tr.querySelector(".glname a, .gl1e a, .gl1t");
                if(tr.classList.contains('gl1t')) {
                    glname = tr;
                    a = tr.querySelector('a');
                    textAlign = 'center';
                }
                if(!(glname && a)) return;
                const gid = Tool.urlGetGId(a.href);
                const token = Tool.urlGetToken(a.href);
                insertionPointMap[gid] = glname;
                const statusUI = monitorTask.addGid(gid);
                statusUI.element.style.textAlign = textAlign;
                glname.appendChild(statusUI.element);

                const listTypeDom = document.querySelector("#dms select > option[selected]");
                const listType = listTypeDom ? listTypeDom.value : '';
                if(listType == 't') return; // 暂时不支持缩略图模式,显示问题
                const gldown = tr.querySelector(".gldown");
                const torrentImg = gldown.querySelector('img');
                const torrentImgSrc = torrentImg.attributes.getNamedItem('src').value
                const hasTorrent = torrentImgSrc.includes("g/t.png");
                if(gmc.get('USE_TORRENT_POP_LIST') && hasTorrent) {
                    torrentsPopDetail(gldown, gid, token, true, listType == 't');
                }
            });
        }
    }


    monitorTask.start();

    if(gmc.get('USE_ONE_CLICK_DOWNLOAD')) {
        Tool.addStyle(ONE_CLICK_STYLE);
        const trList = document.querySelectorAll(".itg tr, .itg .gl1t");
        if(trList && trList.length) {
            trList.forEach(tr => {
                let a = tr.querySelector(".glname a, .gl1e a, .gl1t");
                if(tr.classList.contains('gl1t')) a = tr.querySelector('a');
                if(!a) return;
                const link = a.href;
                const gid = Tool.urlGetGId(a.href);
                let gldown = tr.querySelector(".gldown");
                gldown.appendChild(oneClickButton(gid, link, null));
            })
        }
        if(IS_GALLERY_DETAIL_PAGE) {
            const gldown = document.querySelector(".g2.gsp");
            const a = document.querySelector(".g2.gsp a");
            const archiverLinkMatch = /'(https:\/\/e.hentai\.org\/archiver\.php?.*?)'/i.exec(a.onclick.toString());
            const archiverLink = Tool.htmlDecodeByRegExp(archiverLinkMatch[1]).replace("--", "-");
            gldown.appendChild(oneClickButton(GID, null, archiverLink));
        }
    }

}