Greasy Fork

Greasy Fork is available in English.

UnlockVip

CSDN 文章页:创建下载任务并轮询至完成,内嵌预览或新标签打开预览/下载。

当前为 2025-09-29 提交的版本,查看 最新版本

// ==UserScript==
// @name         UnlockVip
// @namespace    https://example.com/
// @version      1.0.0
// @description  CSDN 文章页:创建下载任务并轮询至完成,内嵌预览或新标签打开预览/下载。
// @author       chatgpt
// @match        https://blog.csdn.net/*/article/details/*
// @match        https://*.blog.csdn.net/article/details/*
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @icon         https://g.csdnimg.cn/static/logo/favicon32.ico
// @connect      a.liaoyouliang.com
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // ========== 配置 ==========
    // 将 ENABLE_LOG 改为 true 可在右下角查看运行日志
    const ENABLE_LOG = false;

    // 可通过 localStorage 覆盖:
    //   localStorage.setItem('csdn_unlock_v2_auth_key', '你的auth_key')
    const DEFAULTS = {
        authKey: '1d5ea9283c3e4d059c1f',
        pollIntervalMs: 1500,
        pollTimeoutMs: 120000,
        prefer: 'preview' // 'preview' | 'download'
    };

    const API = {
        add: 'https://a.liaoyouliang.com/api/download/add',
        tasks: 'https://a.liaoyouliang.com/api/download/tasks',
        previewBase: 'https://a.liaoyouliang.com/api/preview/file/',
        downloadBase: 'https://a.liaoyouliang.com/api/download/file/'
    };

    // ========== 简易日志与面板 ==========
    let logPanel;
    let logList;
    let resultOverlay;
    let resultIframe;
    let resultOpenOrigin;
    const resultCache = new Map();

    const ensureLogPanel = () => {
        if (!ENABLE_LOG) return { logPanel: null, logList: null };
        if (logPanel && logList) return { logPanel, logList };

        logPanel = document.createElement('div');
        logPanel.id = 'csdn-unlockv2-log-panel';
        logPanel.style.cssText = `
            position: fixed !important;
            bottom: 16px !important;
            right: 16px !important;
            width: 380px !important;
            max-height: 60vh !important;
            display: flex !important;
            flex-direction: column !important;
            background: rgba(0,0,0,0.9) !important;
            color: #fff !important;
            font-size: 12px !important;
            border-radius: 8px !important;
            z-index: 2147483647 !important;
            overflow: hidden !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
        `;

        const header = document.createElement('div');
        header.style.cssText = `
            display: flex !important;
            align-items: center !important;
            justify-content: space-between !important;
            padding: 6px 10px !important;
            background: rgba(255,255,255,0.08) !important;
            border-bottom: 1px solid rgba(255,255,255,0.12) !important;
        `;
        const title = document.createElement('span');
        title.textContent = 'CSDN 解锁 v2 日志';
        const clearBtn = document.createElement('button');
        clearBtn.textContent = '清空';
        clearBtn.style.cssText = `
            background: rgba(255,255,255,0.15) !important;
            border: none !important;
            color: #fff !important;
            font-size: 12px !important;
            padding: 2px 8px !important;
            border-radius: 4px !important;
            cursor: pointer !important;`;
        clearBtn.onclick = () => { if (logList) logList.textContent = ''; };
        header.appendChild(title);
        header.appendChild(clearBtn);

        logList = document.createElement('div');
        logList.style.cssText = 'padding: 8px 10px; overflow: auto;';

        logPanel.appendChild(header);
        logPanel.appendChild(logList);

        document.documentElement.appendChild(logPanel);
        return { logPanel, logList };
    };

    const appendToLogPanel = (args) => {
        if (!ENABLE_LOG) return;
        const { logList } = ensureLogPanel();
        if (!logList) return;
        const line = document.createElement('div');
        const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
        try {
            const msg = args.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ');
            line.textContent = `${ts} ${msg}`;
        } catch {
            line.textContent = `${ts} ${args.join(' ')}`;
        }
        logList.appendChild(line);
        while (logList.childNodes.length > 200) logList.removeChild(logList.firstChild);
        logList.scrollTop = logList.scrollHeight;
    };

    const log = (...args) => {
        if (!ENABLE_LOG) return;
        try { console.log(...args); } catch {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow?.console?.log) unsafeWindow.console.log(...args); } catch {}
        appendToLogPanel(args);
    };

    // ========== 结果内嵌展示 ==========
    const ensureResultOverlay = () => {
        if (resultOverlay && resultIframe && resultOpenOrigin) return { overlay: resultOverlay, iframe: resultIframe, openOrigin: resultOpenOrigin };

        const overlay = document.createElement('div');
        overlay.style.cssText = `
            position: fixed !important;
            inset: 0 !important;
            z-index: 2147483647 !important;
            background: rgba(0,0,0,0.75) !important;
            display: none !important;
            align-items: center !important;
            justify-content: center !important;
            padding: 28px !important;`;

        const wrap = document.createElement('div');
        wrap.style.cssText = `
            width: min(1100px, 92vw) !important;
            height: min(90vh, 1000px) !important;
            background: #0a0a0a !important;
            border-radius: 12px !important;
            overflow: hidden !important;
            display: flex !important;
            flex-direction: column !important;`;

        const header = document.createElement('div');
        header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 14px;color:#fff;background:rgba(255,255,255,0.08)';
        const title = document.createElement('span');
        title.textContent = '解锁结果已加载';
        const actions = document.createElement('div');
        actions.style.cssText = 'display:flex;gap:8px;align-items:center';
        const openOrigin = document.createElement('a');
        openOrigin.textContent = '新标签打开预览';
        openOrigin.href = '#';
        openOrigin.target = '_blank';
        openOrigin.rel = 'noopener noreferrer';
        openOrigin.style.cssText = 'color:#7bdcff;text-decoration:none;border:1px solid rgba(123,220,255,.4);padding:3px 8px;border-radius:6px';
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '关闭';
        closeBtn.style.cssText = 'color:#fff;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);padding:3px 8px;border-radius:6px;cursor:pointer';
        closeBtn.onclick = () => { overlay.style.display = 'none'; iframe.removeAttribute('srcdoc'); };
        actions.appendChild(openOrigin);
        actions.appendChild(closeBtn);
        header.appendChild(title);
        header.appendChild(actions);

        const iframe = document.createElement('iframe');
        iframe.style.cssText = 'flex:1;border:none;background:#fff';
        iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-modals allow-popups');

        wrap.appendChild(header);
        wrap.appendChild(iframe);
        overlay.appendChild(wrap);
        document.documentElement.appendChild(overlay);

        resultOverlay = overlay;
        resultIframe = iframe;
        resultOpenOrigin = openOrigin;
        return { overlay, iframe, openOrigin };
    };

    const withBaseHref = (html, sourceUrl) => {
        if (!sourceUrl) return html;
        try {
            const u = new URL(sourceUrl);
            const baseHref = `${u.protocol}//${u.host}${u.pathname.replace(/[^/]*$/, '')}`;
            const base = `<base href="${baseHref}">`;
            if (/<head[^>]*>/i.test(html)) return html.replace(/<head([^>]*)>/i, m => `${m}${base}`);
            if (/<html[^>]*>/i.test(html)) return html.replace(/<html([^>]*)>/i, m => `${m}<head>${base}</head>`);
            return `<head>${base}</head>${html}`;
        } catch { return html; }
    };

    // 直接通过 URL 在 overlay 内展示(跨域 iframe)
    const showUrlInOverlay = (url, linkText = '新标签打开预览') => {
        const { overlay, iframe, openOrigin } = ensureResultOverlay();
        openOrigin.textContent = linkText;
        openOrigin.href = url;
        openOrigin.target = '_blank';
        iframe.removeAttribute('srcdoc');
        iframe.src = url;
        overlay.style.display = 'flex';
    };

    // ========== 工具 ==========
    const gmRequest = typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest
        : (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function' ? GM.xmlHttpRequest : null);

    const buildTasksUrl = (authKey) => `${API.tasks}?auth_key=${encodeURIComponent(authKey)}`;

    const normalizeUrl = (url) => {
        try { const u = new URL(url); return `${u.protocol}//${u.host}${u.pathname}`; } catch { return url.split('?')[0].split('#')[0]; }
    };

    const gmFetchJson = async (url, options = {}) => {
        if (!gmRequest) {
            const resp = await fetch(url, options);
            if (!resp.ok) throw new Error(`请求失败 ${resp.status}`);
            return await resp.json();
        }
        return await new Promise((resolve, reject) => {
            try {
                gmRequest({
                    method: options.method || 'GET',
                    url,
                    data: options.body,
                    headers: options.headers || { 'Accept': 'application/json, text/plain, */*' },
                    onload: (res) => {
                        try { resolve(JSON.parse(res.responseText || '{}')); }
                        catch { reject(new Error('解析 JSON 失败')); }
                    },
                    onerror: () => reject(new Error('GM 请求失败')),
                    ontimeout: () => reject(new Error('GM 请求超时')),
                });
            } catch (e) { reject(e); }
        });
    };

    async function addTask(articleUrl, authKey) {
        const payload = { to_download_url: articleUrl, auth_key: authKey };
        log('[v2] 添加任务:', payload);
        const json = await gmFetchJson(API.add, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
            body: JSON.stringify(payload)
        });
        if (!json) throw new Error('接口无响应');
        const taskId = json?.data?.task_id;
        if (!taskId) throw new Error(json?.message || '未返回 task_id');
        return taskId;
    }

    async function fetchTasks(authKey) {
        const url = buildTasksUrl(authKey);
        log('[v2] 查询任务列表:', url);
        const json = await gmFetchJson(url, { method: 'GET', headers: { 'Accept': 'application/json' } });
        if (!json) throw new Error('接口无响应');
        return json?.data || [];
    }

    const delay = (ms) => new Promise(r => setTimeout(r, ms));

    async function waitForTaskDone(taskId, authKey, onProgress) {
        const start = Date.now();
        const { pollIntervalMs, pollTimeoutMs } = DEFAULTS;
        while (Date.now() - start < pollTimeoutMs) {
            const list = await fetchTasks(authKey);
            const item = Array.isArray(list) ? list.find(x => x?.id === taskId) : null;
            if (item) {
                onProgress?.(item);
                // 2 = 下载成功
                if (Number(item.status) === 2) return item;
                // 3/4 可能为失败或其他状态(未知),这里简单处理为错误
                if (Number(item.status) > 2) throw new Error(item.msg || '任务失败');
            }
            await delay(pollIntervalMs);
        }
        throw new Error('轮询超时,稍后再试');
    }

    // ========== 页面集成 ==========
    function setupCSDNPage() {
        const articleUrl = normalizeUrl(window.location.href);
        const vipLink = document.querySelector('a.article-vip-box[href="https://mall.csdn.net/vip"]');
        if (!vipLink) { log('[v2] 未发现 VIP 元素,跳过渲染'); return; }

        const btn = document.createElement('button');
        btn.textContent = '点击解析';
        btn.style.cssText = 'padding:4px 10px;font-size:14px;background:#4CAF50;color:#fff;border:none;border-radius:4px;cursor:pointer;flex-shrink:0;margin-left:8px';

        let busy = false;
        btn.onclick = async () => {
            if (busy) return;
            busy = true;
            const oldText = btn.textContent;
            btn.textContent = '解析中...';
            btn.disabled = true;
            try {
                const authKey = localStorage.getItem('csdn_unlock_v2_auth_key') || DEFAULTS.authKey;
                // 1) 添加任务
                const taskId = await addTask(articleUrl, authKey);
                btn.textContent = '任务已创建,等待中...';
                // 2) 轮询任务状态
                const task = await waitForTaskDone(taskId, authKey, (item) => {
                    btn.textContent = `${item.msg || '处理中'}...`;
                });
                log('[v2] 任务完成: ', task);
                // 3) 展示预览/下载
                const previewUrl = API.previewBase + taskId;
                const downloadUrl = API.downloadBase + taskId + '?attachment=False';
                const prefer = DEFAULTS.prefer;
                if (prefer === 'download') {
                    showUrlInOverlay(downloadUrl, '新标签打开下载');
                } else {
                    showUrlInOverlay(previewUrl, '新标签打开预览');
                }
                btn.textContent = '已展示内容';
            } catch (e) {
                console.error('解锁失败(v2):', e);
                alert(`解锁失败(v2):${e.message || e}`);
                btn.textContent = oldText;
                btn.disabled = false;
                busy = false;
                return;
            }

            setTimeout(() => {
                btn.textContent = oldText;
                btn.disabled = false;
                busy = false;
            }, 1500);
        };

        const barContent = document.querySelector('.article-bar-top .bar-content') || document.querySelector('.article-bar-top');
        if (barContent) barContent.appendChild(btn); else document.body.appendChild(btn);
    }

    const host = window.location.hostname;
    if (/blog\.csdn\.net$/.test(host) || /\.blog\.csdn\.net$/.test(host)) {
        const init = () => setupCSDNPage();
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
    }
})();