Sleazy Fork is available in English.

EhAria2下载助手

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

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         EhAria2下载助手
// @namespace    com.xioxin.AriaEh
// @version      1.2
// @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[type="submit"],button[type="submit"]');
                    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));
        }
    }

}