Greasy Fork

Greasy Fork is available in English.

思齐 (si-qi) 补种助手

自动对比“已完成”和“做种中”列表,一键发送“已完成但未做种”的种子到 qBittorrent 并跳过校验。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         思齐 (si-qi) 补种助手
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  自动对比“已完成”和“做种中”列表,一键发送“已完成但未做种”的种子到 qBittorrent 并跳过校验。
// @author       (Your Name)
// @match        https://si-qi.xyz/userdetails.php?id=*
// @icon         https://si-qi.xyz/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    // --- 1. qBittorrent WebUI 设置 (可配置化) ---
    let qbUrl = GM_getValue('QB_URL', 'http://127.0.0.1:8080');
    let qbUsername = GM_getValue('QB_USERNAME', 'admin');
    let qbPassword = GM_getValue('QB_PASSWORD', 'adminadmin');
    let qbSkipChecking = GM_getValue('QB_SKIP_CHECKING', true);

    GM_registerMenuCommand('设置 qB 连接', () => {
        const newUrl = prompt('请输入 qBittorrent WebUI 地址 (例如: http://10.0.0.88:9991)', qbUrl);
        if (newUrl === null) return alert('已取消配置。');

        const newUsername = prompt('请输入 qB 登录用户名', qbUsername);
        if (newUsername === null) return alert('已取消配置。');

        const newPassword = prompt('请输入 qB 登录密码', qbPassword);
        if (newPassword === null) return alert('已取消配置。');

        const newSkip = confirm('是否默认跳过校验?(推荐 "是", 点 "确定" = 是, "取消" = 否)', qbSkipChecking);

        GM_setValue('QB_URL', newUrl);
        GM_setValue('QB_USERNAME', newUsername);
        GM_setValue('QB_PASSWORD', newPassword);
        GM_setValue('QB_SKIP_CHECKING', newSkip);

        qbUrl = newUrl;
        qbUsername = newUsername;
        qbPassword = newPassword;
        qbSkipChecking = newSkip;

        alert('qB 配置已保存!');
    });
    // -------------------------------------------

    let qbCookie = null;

    GM_addStyle(`
        #reseed-helper-box { background: #fdfdfd; border: 1px solid #ff9800; border-radius: 8px; padding: 15px; margin: 15px 0; }
        #reseed-helper-box h3 { margin: 0 0 15px 0; color: #ff7b00; }
        #reseed-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 15px; }
        #reseed-controls button { background: linear-gradient(90deg,#ff9800 0,#ffc107 100%); color: #fff; border: none; border-radius: 6px; padding: 6px 18px; font-size: 1em; font-weight: bold; cursor: pointer; }
        #reseed-controls button:hover { background: linear-gradient(90deg,#ffc107 0,#ff9800 100%); }
        #reseed-controls button:disabled { background: #ccc; cursor: not-allowed; }
        #reseed-status { font-weight: bold; color: #1976d2; }
        #reseed-results-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
        #reseed-results-table th, #reseed-results-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        #reseed-results-table th { background-color: #f9f9f9; }
        #reseed-results-table .reseed-action-btn { background: #43a047; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; border: none; }
        #reseed-results-table .reseed-action-btn:disabled { background: #aaa; }
    `);

    const targetH2 = document.querySelector('h2');
    if (!targetH2) return;

    const helperBox = document.createElement('div');
    helperBox.id = 'reseed-helper-box';
    helperBox.innerHTML = `
        <h3>🚀 补种助手</h3>
        <div id="reseed-controls">
            <button id="start-reseed-check">1. 开始对比</button>
            <button id="send-all-missing" style="display:none;">2. 一键发送全部</button>
            <span id="reseed-status"></span>
        </div>
        <div id="reseed-results"></div>
    `;
    targetH2.parentNode.insertBefore(helperBox, targetH2);

    const startBtn = document.getElementById('start-reseed-check');
    const sendAllBtn = document.getElementById('send-all-missing');
    const statusEl = document.getElementById('reseed-status');
    const resultsEl = document.getElementById('reseed-results');

    let missingTorrents = [];

    async function fetchAllPages(type, userId) {
        console.log(`--- 开始抓取 [${type}] 列表 ---`);
        let torrents = new Map();
        let page = 0;
        let totalPages = 1;

        while (page < totalPages) {
            const statusMsg = `正在抓取 [${type}] 列表... 第 ${page + 1} 页${totalPages > 1 ? ` / ${totalPages}` : ''}`;
            console.log(statusMsg);
            statusEl.textContent = statusMsg;

            const url = `getusertorrentlistajax.php?userid=${userId}&type=${type}&page=${page}`;

            try {
                const html = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: url,
                        onload: (res) => resolve(res.responseText),
                        onerror: (err) => reject(err),
                    });
                });

                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');

                const rows = doc.querySelectorAll('table[border="1"] tr');
                let foundInThisPage = 0;
                rows.forEach((row) => {
                    const titleLink = row.querySelector('td:nth-child(2) a[href^="details.php?id="]');
                    if (titleLink) {
                        const href = titleLink.getAttribute('href');
                        const torrentId = new URLSearchParams(href.split('?')[1]).get('id');
                        const torrentName = titleLink.querySelector('b') ? titleLink.querySelector('b').textContent : titleLink.textContent;

                        const sizeEl = row.querySelector('td:nth-child(4)');
                        const torrentSize = sizeEl ? sizeEl.textContent.trim() : '未知';

                        if (torrentId && !torrents.has(torrentId)) {
                            torrents.set(torrentId, { name: torrentName, detailsLink: href, size: torrentSize });
                            foundInThisPage++;
                        }
                    }
                });
                console.log(`  > 第 ${page + 1} 页:找到 ${foundInThisPage} 个新种子。`);

                if (page === 0) {
                    const paginationLinks = doc.querySelectorAll('p.nexus-pagination a[href*="page="]');
                    console.log(`  > [分页检测] 找到 ${paginationLinks.length} 个分页链接。`);

                    let maxPageFound = 0;

                    paginationLinks.forEach((link) => {
                        const href = link.getAttribute('href');
                        const queryString = href.split('?')[1];
                        if (queryString) {
                            const pageParam = new URLSearchParams(queryString).get('page');
                            if (pageParam) {
                                const pageNum = parseInt(pageParam);
                                if (!isNaN(pageNum) && pageNum > maxPageFound) {
                                    maxPageFound = pageNum;
                                }
                            }
                        }
                    });

                    console.log(`  > [分页检测] 遍历所有链接后,找到的最大 'page' 参数: ${maxPageFound}`);

                    totalPages = maxPageFound + 1;

                    if (paginationLinks.length === 0) {
                        totalPages = 1;
                    }

                    console.log(`  > [分页检测] 判定总页数 (totalPages): ${totalPages}`);
                }

                page++;
            } catch (error) {
                console.error(`抓取 [${type}] 第 ${page + 1} 页失败:`, error);
                statusEl.textContent = `抓取 [${type}] 第 ${page + 1} 页失败!`;
                throw error;
            }
        }

        console.log(`--- [${type}] 列表抓取完毕,共 ${page} 页,总计 ${torrents.size} 条数据 ---`);
        return torrents;
    }

    async function qbLogin() {
        if (qbCookie) return true;

        statusEl.textContent = '正在登录 qBittorrent...';
        const formData = new FormData();
        formData.append('username', qbUsername);
        formData.append('password', qbPassword);

        const loginUrl = `${qbUrl.replace(/\/$/, '')}/api/v2/auth/login`;

        try {
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: loginUrl,
                    data: formData,
                    onload: (res) => {
                        if (res.status === 200 && res.responseText === 'Ok.') {
                            const cookieHeader = res.responseHeaders.split('\n').find((h) => h.toLowerCase().startsWith('set-cookie'));
                            if (cookieHeader) {
                                qbCookie = cookieHeader.split(';')[0].replace('set-cookie: ', '').trim();
                                resolve(true);
                            } else {
                                // --- 关键改动 ---
                                // 登录成功, 但未返回新 cookie (说明浏览器已登录)
                                // 我们不再报错, 而是设置一个 "true" 标记
                                // 让后续请求依赖浏览器自动发送 cookie
                                qbCookie = true;
                                resolve(true);
                                // --- 改动结束 ---
                            }
                        } else {
                            reject(new Error(`qB 登录失败: ${res.responseText}`));
                        }
                    },
                    onerror: (err) => reject(err),
                });
            });
            statusEl.textContent = 'qB 登录成功!';
            return true;
        } catch (error) {
            console.error(error);
            statusEl.textContent = `qB 登录失败: ${error.message} (请点击油猴图标设置qB连接)`;
            return false;
        }
    }

    async function sendTorrentToQb(torrentId, torrentName) {
        if (!await qbLogin()) return false;

        statusEl.textContent = `正在发送 ${torrentName} ...`;

        try {
            const downloadUrl = `https://si-qi.xyz/download.php?id=${torrentId}`;
            const torrentBlob = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: downloadUrl,
                    responseType: 'blob',
                    onload: (res) => {
                        if (res.status === 200) {
                            resolve(res.response);
                        } else {
                            reject(new Error(`下载 .torrent 文件失败 (ID: ${torrentId})`));
                        }
                    },
                    onerror: (err) => reject(err),
                });
            });

            const formData = new FormData();
            formData.append('torrents', torrentBlob, `${torrentId}.torrent`);
            formData.append('skip_checking', qbSkipChecking ? 'true' : 'false');
            formData.append('tags', '脚本补种');
            formData.append('rename', torrentName);

            const addUrl = `${qbUrl.replace(/\/$/, '')}/api/v2/torrents/add`;

            // --- 关键改动 ---
            // 动态创建 headers
            // 只有当我们通过登录明确获取了 cookie 字符串时, 才手动添加 Cookie
            // 否则 (qbCookie === true), 我们发送空 headers, 让浏览器自动处理
            const headers = {};
            if (qbCookie && typeof qbCookie === 'string') {
                headers['Cookie'] = qbCookie;
            }
            // --- 改动结束 ---

            const qbAddResponse = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: addUrl,
                    headers: headers, // 使用动态创建的 headers
                    data: formData,
                    onload: (res) => {
                        if (res.status === 200) {
                            resolve(true);
                        } else {
                            reject(new Error(`添加到 qB 失败: ${res.responseText}`));
                        }
                    },
                    onerror: (err) => reject(err),
                });
            });

            statusEl.textContent = `${torrentName} 发送成功!`;
            return true;
        } catch (error) {
            console.error(error);
            statusEl.textContent = `发送 ${torrentName} 失败: ${error.message}`;
            return false;
        }
    }

    startBtn.addEventListener('click', async () => {
        startBtn.disabled = true;
        sendAllBtn.style.display = 'none';
        resultsEl.innerHTML = '';
        missingTorrents = [];

        try {
            const userId = new URLSearchParams(window.location.search).get('id');
            if (!userId) {
                statusEl.textContent = '无法获取当前用户ID。';
                return;
            }

            const seedingTorrents = await fetchAllPages('seeding', userId);
            statusEl.textContent = `“做种中”列表抓取完毕 (${seedingTorrents.size} 条)。`;

            const completedTorrents = await fetchAllPages('completed', userId);
            statusEl.textContent = `“已完成”列表抓取完毕 (${completedTorrents.size} 条)。`;

            statusEl.textContent = '正在对比...';
            completedTorrents.forEach((data, id) => {
                if (!seedingTorrents.has(id)) {
                    missingTorrents.push({ id, ...data });
                }
            });

            displayResults();
        } catch (error) {
            console.error('对比过程中发生错误:', error);
            statusEl.textContent = `发生错误: ${error.message}`;
        } finally {
            startBtn.disabled = false;
        }
    });

    function displayResults() {
        if (missingTorrents.length === 0) {
            statusEl.textContent = '太棒了!没有发现已完成但未做种的种子。';
            resultsEl.innerHTML = '';
            sendAllBtn.style.display = 'none';
            return;
        }

        statusEl.textContent = `对比完成!发现 ${missingTorrents.length} 个“已完成但未做种”的种子。`;
        sendAllBtn.style.display = 'inline-block';

        let tableHTML = `
            <table id="reseed-results-table">
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>标题</th>
                        <th>体积</th>
                        <th>操作</th>
                    </tr>
                </thead>
                <tbody>
        `;

        missingTorrents.forEach((torrent) => {
            tableHTML += `
                <tr data-id="${torrent.id}">
                    <td>${torrent.id}</td>
                    <td><a href="${torrent.detailsLink}" target="_blank">${torrent.name}</a></td>
                    <td>${torrent.size}</td>
                    <td><button class="reseed-action-btn" data-id="${torrent.id}" data-name="${encodeURIComponent(
                        torrent.name
                    )}">发送到 qB</button></td>
                </tr>
            `;
        });

        tableHTML += `</tbody></table>`;
        resultsEl.innerHTML = tableHTML;
    }

    resultsEl.addEventListener('click', async (e) => {
        if (e.target.classList.contains('reseed-action-btn')) {
            const btn = e.target;
            const id = btn.dataset.id;
            const name = decodeURIComponent(btn.dataset.name);

            btn.disabled = true;
            btn.textContent = '发送中...';

            const success = await sendTorrentToQb(id, name);

            if (success) {
                btn.textContent = '发送成功';
                btn.style.backgroundColor = '#1e88e5';
                setTimeout(() => {
                    const row = document.querySelector(`#reseed-results-table tr[data-id="${id}"]`);
                    if (row) row.remove();

                    if (document.querySelectorAll('#reseed-results-table tbody tr').length === 0) {
                        displayResults();
                    }
                }, 1000);
            } else {
                btn.textContent = '发送失败';
                btn.style.backgroundColor = '#d32f2f';
                btn.disabled = false;
            }
        }
    });

    sendAllBtn.addEventListener('click', async () => {
        sendAllBtn.disabled = true;
        sendAllBtn.textContent = '正在全部发送...';

        const allButtons = resultsEl.querySelectorAll('.reseed-action-btn:not(:disabled)');

        for (const btn of allButtons) {
            await btn.click();
            await new Promise((resolve) => setTimeout(resolve, 300));
        }

        sendAllBtn.textContent = '2. 一键发送全部';
        sendAllBtn.disabled = false;
    });
})();