123 云盘资源快搜助手 | 一键生成分享链接

访问123panfx.com 123云盘资源社区时,点击发新帖按钮旁的「123云盘分享」图标,一键检索个人云盘资源并生成分享链接。

// ==UserScript==
// @name         123 云盘资源快搜助手 | 一键生成分享链接
// @namespace    http://tampermonkey.net/
// @version      0.3.4
// @description  访问123panfx.com 123云盘资源社区时,点击发新帖按钮旁的「123云盘分享」图标,一键检索个人云盘资源并生成分享链接。
// @author       Walking
// @match        *://123panfx.com/*
// @match        *://www.123panfx.com/*
// @match        *://pan1.me/*
// @match        *://www.123pan.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // 样式配置
    const StyleConfig = {
        BG_COLOR: "#f5f5f7",
        ACCENT_COLOR: "#2c7be5",
        TEXT_COLOR: "#333333",
        LIGHT_TEXT: "#666666",
        BORDER_COLOR: "#e0e0e0",
        HOVER_COLOR: "#e8f0fe",
        SELECT_COLOR: "#d0e2ff",
        FONT_FAMILY: "Microsoft YaHei, sans-serif",
        FONT_SIZE: "14px",
        PADDING: "12px",
        MARGIN: "8px",
        SPACING: "10px"
    };

    // 全局存储
    const Storage = {
        get(key) {
            const value = GM_getValue(key, '');
            console.log(`[存储读取] ${key}=${value}`);
            return value;
        },
        set(key, value) {
            GM_setValue(key, value);
            console.log(`[存储写入] ${key}=${value}`);
        },
        getObj(key) {
            const val = GM_getValue(key, '{}');
            try {
                return JSON.parse(val);
            } catch (e) {
                console.error(`[存储解析失败] ${key}`, e);
                return {};
            }
        },
        setObj(key, obj) {
            GM_setValue(key, JSON.stringify(obj));
            console.log(`[存储写入对象] ${key}=${JSON.stringify(obj)}`);
        },
        clearToken() {
            GM_setValue('crossDomainToken', '');
            console.log(`[存储清除] 已清除token`);
        }
    };

    // 目标域名集合(同一网站的不同域名)
    const targetDomains = new Set([
        '123panfx.com',
        'www.123panfx.com',
        'pan1.me'
    ]);
    const tokenSourceDomain = 'www.123pan.com'; // token来源域名

    // 域名判断工具函数
    function getCurrentDomain() {
        return window.location.hostname.replace(/^www\./, ''); // 移除www.前缀统一判断
    }
    const currentDomain = getCurrentDomain();
    const isTargetDomain = targetDomains.has(currentDomain); // 是否为目标使用域名
    const isTokenSource = currentDomain === tokenSourceDomain.replace(/^www\./, ''); // 是否为token来源域名

    console.log(`[域名信息] 当前域名=${window.location.hostname},处理后=${currentDomain},是否目标域名=${isTargetDomain},是否token来源=${isTokenSource}`);

    // 目标域名接收并缓存token(从URL参数)
    function handleTokenFromURL() {
        if (!isTargetDomain) return;

        const urlParams = new URLSearchParams(window.location.search);
        const token = urlParams.get('token');
        const loginUuid = urlParams.get('loginUuid');

        console.log(`[目标域名接收token] token=${token ? '存在' : '不存在'},loginUuid=${loginUuid || '空'}`);

        if (token) {
            // 持久化存储token
            Storage.set('crossDomainToken', token);
            if (loginUuid) Storage.set('loginUuid', loginUuid);

            // 移除URL中的token参数
            urlParams.delete('token');
            urlParams.delete('loginUuid');
            const cleanUrl = `${window.location.pathname}${urlParams.toString() ? '?' + urlParams.toString() : ''}`;
            window.history.replaceState({}, document.title, cleanUrl);
            alert('✅ token获取成功,可以开始搜索了');
        }
    }

    // token来源域名(www.123pan.com)处理逻辑(静默优化)
    function fetchTokenAndRedirect() {
        if (!isTokenSource) return;

        // 从URL参数中获取原目标域名的跳转地址
        const urlParams = new URLSearchParams(window.location.search);
        const redirectUrl = urlParams.get('redirect');
        console.log(`[token来源域名] redirectUrl=${redirectUrl || '空'}`);

        // 无跳转参数时,完全静默(不提示、不操作)
        if (!redirectUrl) {
            console.log(`[www.123pan.com] 无跳转参数,静默模式`);
            return;
        }

        // 有跳转参数时,读取token并跳转(仅此时执行操作)
        const token = localStorage.getItem('authorToken');
        const loginUuid = localStorage.getItem('LoginUuid');
        console.log(`[来源域名读取token] authorToken=${token ? '存在' : '不存在'},LoginUuid=${loginUuid || '空'}`);

        // 即使token不存在,也不提示(避免干扰用户),仅在控制台输出日志
        if (!token) {
            console.log(`[www.123pan.com] 未找到token,无法完成跳转`);
            return;
        }

        // 跳转回目标域名,并携带token
        try {
            const targetUrl = new URL(decodeURIComponent(redirectUrl));
            targetUrl.searchParams.set('token', token);
            if (loginUuid) targetUrl.searchParams.set('loginUuid', loginUuid);
            console.log(`[准备跳转回目标域名] url=${targetUrl.toString()}`);
            window.location.href = targetUrl.toString();
        } catch (e) {
            console.error('[跳转地址解析失败]', e);
            // 解析失败也不提示,仅日志记录
        }
    }

    // 获取缓存的token(无效则返回null)
    function getCachedToken() {
        return Storage.get('crossDomainToken') || null;
    }

    // 处理token过期(清除缓存并重新获取)
    function handleTokenExpired() {
        console.log('[token已过期] 准备重新获取');
        Storage.clearToken();
        alert('⚠️ token已过期,请重新获取授权');
        // 跳转至token来源域名
        const currentUrl = window.location.href;
        window.location.href = `https://${tokenSourceDomain}?redirect=${encodeURIComponent(currentUrl)}`;
    }

    // 全局状态
    let globalState = {
        clientID: Storage.get('clientID'),
        clientSecret: Storage.get('clientSecret'),
        accessToken: getCachedToken(),
        loginUuid: Storage.get('loginUuid'),
        folderCache: Storage.getObj('folderCache')
    };

    // 保存状态
    function saveState() {
        Storage.set('clientID', globalState.clientID);
        Storage.set('clientSecret', globalState.clientSecret);
        Storage.set('loginUuid', globalState.loginUuid);
        Storage.setObj('folderCache', globalState.folderCache);
    }

    // 创建UI(所有目标域名都显示)
    function createUI() {
        if (!isTargetDomain) return;

        const newPostBtn = Array.from(document.querySelectorAll('a, button, span')).find(el =>
            el.textContent.trim().includes('发新帖')
        );

        const container = document.createElement('div');
        container.style.cssText = 'position: relative; display: inline-block; margin-left: 8px;';

        const shareBtn = document.createElement('button');
        shareBtn.style.cssText = `
            padding: 6px 12px;
            background-color: ${StyleConfig.ACCENT_COLOR};
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-family: ${StyleConfig.FONT_FAMILY};
            font-size: 14px;
        `;
        shareBtn.textContent = '123云盘分享';

        const searchBox = document.createElement('div');
        searchBox.id = 'shareSearchBox';
        searchBox.style.cssText = `
            position: absolute;
            top: 100%;
            right: 0;
            margin-top: 4px;
            width: 400px;
            background: ${StyleConfig.BG_COLOR};
            border-radius: 6px;
            padding: ${StyleConfig.PADDING};
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
            z-index: 999998;
            display: none;
        `;
        searchBox.innerHTML = `
            <div style="margin-bottom: ${StyleConfig.SPACING}px; font-size: 16px; font-weight: bold;">
                请输入关键词搜索
            </div>
            <input type="text" id="searchInput" style="
                width: 100%;
                padding: 8px;
                border: 1px solid ${StyleConfig.BORDER_COLOR};
                border-radius: 4px;
                margin-bottom: ${StyleConfig.SPACING}px;
            " placeholder="例如:权力的游戏...">
            <button id="searchBtn" style="
                background: ${StyleConfig.ACCENT_COLOR};
                color: white;
                border: none;
                padding: 8px 16px;
                border-radius: 4px;
                cursor: pointer;
            ">搜索</button>
        `;

        const folderList = document.createElement('div');
        folderList.id = 'folderListContainer';
        folderList.style.cssText = `
            position: absolute;
            top: 100%;
            right: 0;
            margin-top: 4px;
            width: 600px;
            max-height: 500px;
            overflow-y: auto;
            background: ${StyleConfig.BG_COLOR};
            border-radius: 6px;
            padding: ${StyleConfig.PADDING};
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
            z-index: 999998;
            display: none;
        `;

        container.appendChild(shareBtn);
        container.appendChild(searchBox);
        container.appendChild(folderList);

        // 插入页面
        if (newPostBtn && newPostBtn.parentNode) {
            newPostBtn.parentNode.insertBefore(container, newPostBtn.nextSibling);
        } else {
            shareBtn.style.cssText = `
                position: fixed;
                bottom: 20px;
                right: 20px;
                width: 50px;
                height: 50px;
                background: ${StyleConfig.ACCENT_COLOR};
                border-radius: 50%;
                box-shadow: 0 2px 10px rgba(0,0,0,0.2);
                cursor: pointer;
                z-index: 999999;
                display: flex;
                align-items: center;
                justify-content: center;
                color: white;
                font-weight: bold;
            `;
            shareBtn.textContent = '123';
            document.body.appendChild(shareBtn);
            document.body.appendChild(searchBox);
            document.body.appendChild(folderList);
        }

        // 事件绑定
        shareBtn.addEventListener('click', () => {
            const isVisible = searchBox.style.display === 'block';
            searchBox.style.display = isVisible ? 'none' : 'block';
            folderList.style.display = 'none';
            if (!isVisible) document.getElementById('searchInput')?.focus();
        });

        document.getElementById('searchBtn')?.addEventListener('click', () => {
            const keyword = document.getElementById('searchInput')?.value.trim();
            if (keyword) searchFolders(keyword);
        });

        document.getElementById('searchInput')?.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') document.getElementById('searchBtn')?.click();
        });
    }

    // 搜索文件夹(处理token有效性)
    async function searchFolders(keyword) {
        if (!isTargetDomain) return;

        const cachedToken = getCachedToken();
        if (!cachedToken) {
            // 无有效token,跳转获取
            const currentUrl = window.location.href;
            console.log(`[无有效token] 准备跳转至${tokenSourceDomain},原地址=${currentUrl}`);
            alert('需要从www.123pan.com获取授权,请点击确定跳转');
            window.location.href = `https://${tokenSourceDomain}?redirect=${encodeURIComponent(currentUrl)}`;
            return;
        }

        let allFiles = [];
        let lastFileId = 0;

        try {
            for (let i = 0; i < 3; i++) {
                const response = await fetch(`https://open-api.123pan.com/api/v2/file/list?parentFileId=0&searchData=${encodeURIComponent(keyword)}&searchMode=1&limit=100&lastFileId=${lastFileId}`, {
                    method: 'GET',
                    headers: {
                        'Authorization': `Bearer ${cachedToken}`,
                        'Platform': 'open_platform',
                        'LoginUuid': globalState.loginUuid || ''
                    }
                });
                const data = await response.json();

                // 检查API返回的错误是否为token过期
                if (data.code === 401 || data.message?.includes('expired')) {
                    throw new Error('token expired');
                }
                if (data.code !== 0) throw new Error(`[${data.code}] ${data.message}`);

                allFiles = allFiles.concat(data.data.fileList);
                lastFileId = data.data.lastFileId;

                if (lastFileId === -1) {
                    break;
                }
            }

            const folders = allFiles
               .filter(item => item.type === 1)
               .slice(0, 20);

            for (const folder of folders) {
                folder.fullPath = await buildFullPath(folder);
            }
            showFolderList(folders);
        } catch (e) {
            console.error('[搜索失败]', e);
            // 若错误为token过期,触发重新获取
            if (e.message.includes('expired')) {
                handleTokenExpired();
            } else {
                alert(`搜索失败: ${e.message}`);
            }
        }
    }

    // 获取文件详情(处理token过期)
    async function getFileDetail(fileId) {
        if (globalState.folderCache[fileId]) {
            return globalState.folderCache[fileId];
        }
        try {
            const cachedToken = getCachedToken();
            const response = await fetch(`https://open-api.123pan.com/api/v1/file/detail?fileID=${fileId}`, {
                method: 'GET',
                headers: {
                    'Authorization': `Bearer ${cachedToken}`,
                    'Platform': 'open_platform',
                    'LoginUuid': globalState.loginUuid || ''
                }
            });
            const data = await response.json();

            // 检查token过期
            if (data.code === 401 || data.message?.includes('expired')) {
                throw new Error('token expired');
            }
            if (data.code !== 0) throw new Error(`[${data.code}] ${data.message}`);

            const detail = {
                filename: data.data.filename,
                parentFileID: data.data.parentFileID
            };
            globalState.folderCache[fileId] = detail;
            Storage.setObj('folderCache', globalState.folderCache);
            return detail;
        } catch (e) {
            console.error(`获取文件${fileId}详情失败:`, e);
            if (e.message.includes('expired')) {
                handleTokenExpired();
            }
            return null;
        }
    }

    // 构建文件路径
    async function buildFullPath(folder) {
        const pathParts = [folder.filename];
        let currentId = folder.parentFileId;
        for (let i = 0; i < 2; i++) {
            if (!currentId || currentId === 0) break;
            const parent = await getFileDetail(currentId);
            if (!parent) break;
            pathParts.unshift(parent.filename);
            currentId = parent.parentFileID;
        }
        return pathParts.join('/');
    }

    // 显示文件夹列表
    function showFolderList(folders) {
        const container = document.getElementById('folderListContainer');
        const searchBox = document.getElementById('shareSearchBox');
        if (folders.length === 0) {
            container.innerHTML = '<div style="padding: 10px; color: #666;">没有找到匹配的文件夹</div>';
            container.style.display = 'block';
            searchBox.style.display = 'none';
            return;
        }

        let html = `
            <div style="margin-bottom: 10px; font-weight: bold;">
                请选择文件夹(共${folders.length}个)
            </div>
            <div style="margin-bottom: 10px;">
                <input type="text" id="folderIndex" style="
                    width: 60px;
                    padding: 4px;
                    border: 1px solid ${StyleConfig.BORDER_COLOR};
                    border-radius: 4px;
                " placeholder="序号">
                <button id="goBtn" style="
                    background: ${StyleConfig.ACCENT_COLOR};
                    color: white;
                    border: none;
                    padding: 4px 8px;
                    border-radius: 4px;
                    cursor: pointer;
                    margin-left: 5px;
                ">确认</button>
            </div>
            <div style="border-top: 1px solid ${StyleConfig.BORDER_COLOR}; padding-top: 10px;">
        `;

        folders.forEach((folder, index) => {
            const idx = index + 1;
            html += `
                <div class="folderItem" data-index="${index}" style="
                    padding: 10px;
                    margin-bottom: 8px;
                    border-radius: 4px;
                    background: white;
                    border: 1px solid ${StyleConfig.BORDER_COLOR};
                    cursor: pointer;
                ">
                    <div style="margin-bottom: 4px;">${idx}. ${folder.fullPath}</div>
                    <div style="font-size: 12px; color: ${StyleConfig.LIGHT_TEXT}">ID: ${folder.fileId}</div>
                </div>
            `;
        });
        html += '</div>';
        container.innerHTML = html;
        container.style.display = 'block';
        searchBox.style.display = 'none';

        // 绑定事件
        document.querySelectorAll('.folderItem').forEach(item => {
            item.addEventListener('click', () => {
                const index = parseInt(item.dataset.index);
                handleFolderSelect(folders[index]);
            });
            item.addEventListener('mouseover', () => item.style.backgroundColor = StyleConfig.HOVER_COLOR);
            item.addEventListener('mouseout', () => item.style.backgroundColor = 'white');
        });

        document.getElementById('goBtn').addEventListener('click', () => {
            const num = parseInt(document.getElementById('folderIndex').value) - 1;
            if (num >= 0 && num < folders.length) {
                handleFolderSelect(folders[num]);
            } else {
                alert('请输入有效的序号');
            }
        });

        document.getElementById('folderIndex').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') document.getElementById('goBtn').click();
        });
    }

    // 处理文件夹选择(处理token过期)
    async function handleFolderSelect(folder) {
        const container = document.getElementById('folderListContainer');
        const folderName = folder.filename;
        const folderId = folder.fileId;

        try {
            const existingLink = await getExistingShareLink(folderName);
            if (existingLink) {
                copyToClipboard(existingLink);
                alert(`已找到现有分享链接,已复制:\n${existingLink}`);
                container.style.display = 'none';
                return;
            }

            const newLink = await createShareLink(folderId, folderName);
            if (newLink) {
                copyToClipboard(newLink);
                alert(`分享链接已创建,已复制:\n${newLink}`);
            }
            container.style.display = 'none';
        } catch (e) {
            console.error('[处理文件夹选择失败]', e);
            if (e.message.includes('expired')) {
                handleTokenExpired();
            } else {
                alert(`操作失败: ${e.message}`);
            }
        }
    }

    // 获取已有分享链接(处理token过期)
    async function getExistingShareLink(targetName) {
        let lastShareId = 0;
        try {
            const cachedToken = getCachedToken();
            while (true) {
                const response = await fetch(`https://open-api.123pan.com/api/v1/share/list?limit=100&lastShareId=${lastShareId}`, {
                    method: 'GET',
                    headers: {
                        'Authorization': `Bearer ${cachedToken}`,
                        'Platform': 'open_platform'
                    }
                });
                const data = await response.json();

                if (data.code === 401 || data.message?.includes('expired')) {
                    throw new Error('token expired');
                }
                if (data.code !== 0) throw new Error(`[${data.code}] ${data.message}`);

                for (const share of data.data.shareList) {
                    if (share.shareName === targetName && !share.sharePwd && !share.expired) {
                        return `https://www.123pan.com/s/${share.shareKey}`;
                    }
                }
                if (data.data.lastShareId === -1) break;
                lastShareId = data.data.lastShareId;
            }
            return null;
        } catch (e) {
            console.error('[查询已有分享失败]', e);
            if (e.message.includes('expired')) {
                throw e; // 抛给上层处理
            }
            alert(`查询已有分享失败: ${e.message}`);
            return null;
        }
    }

    // 创建分享链接(处理token过期)
    async function createShareLink(fileId, name) {
        try {
            const cachedToken = getCachedToken();
            const response = await fetch('https://open-api.123pan.com/api/v1/share/create', {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${cachedToken}`,
                    'Platform': 'open_platform',
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    shareName: name,
                    shareExpire: 0,
                    fileIDList: [fileId].join(','),
                    sharePwd: null
                })
            });
            const data = await response.json();

            if (data.code === 401 || data.message?.includes('expired')) {
                throw new Error('token expired');
            }
            if (data.code !== 0) throw new Error(`[${data.code}] ${data.message}`);

            return `https://www.123pan.com/s/${data.data.shareKey}`;
        } catch (e) {
            console.error('[创建分享链接失败]', e);
            if (e.message.includes('expired')) {
                throw e; // 抛给上层处理
            }
            alert(`创建分享链接失败: ${e.message}`);
            return null;
        }
    }

    // 复制到剪贴板
    function copyToClipboard(text) {
        navigator.clipboard.writeText(text).catch(() => {
            const textarea = document.createElement('textarea');
            textarea.value = text;
            document.body.appendChild(textarea);
            textarea.select();
            document.execCommand('copy');
            document.body.removeChild(textarea);
        });
    }

    // 初始化执行
    (function init() {
        // 目标域名:接收token并创建UI
        if (isTargetDomain) {
            handleTokenFromURL();
            createUI();
        }
        // token来源域名:仅在有跳转参数时执行逻辑(完全静默)
        if (isTokenSource) {
            fetchTokenAndRedirect();
        }
    })();
})();