青丘白泽 / 追梦之旅铺路卷 音频下载

// ==UserScript==
// @name         追梦之旅铺路卷 音频下载
// @namespace    zhizhuma.com
// @version      2.0.2
// @author       青丘白泽
// @description  追梦之旅铺路卷 音频下载 - 支持 zhizhuma.com - 悬浮下载按钮版
// @copyright    2023-2026, 青丘白泽 (https://openuserjs.org/users/青丘白泽)
// @match        *://*.zhizhuma.com/*
// @icon         https://mprescdn.bookln.cn/qrcode/img/favicon/favicon.ico
// @grant        none
// @homepageURL  https://github.com/simon7073
// @license      Apache-2.0
// @note         2026-06-03 v2.0.0 重构为悬浮下载按钮,支持 SPA 动态音频切换
// ==/UserScript==

(function () {
    'use strict';

    // ==================== 配置 ====================
    const CONFIG = {
        audioSelector: 'audio[hidden]',
        buttonId: 'zm-download-btn',
        buttonZIndex: 999999,
        pollInterval: 500,       // 轮询间隔(ms)
        pollTimeout: 30000,      // 轮询超时(ms)
        // 文件名取自该 XPath 元素的文本内容
        titleXPath: '/html/body/div[1]/div/div[3]/div[1]/div/span',
    };

    // ==================== 状态 ====================
    let downloadUrl = '';
    let currentTitle = '';
    let isDragging = false;
    let buttonPosition = (() => {
        try {
            const saved = localStorage.getItem('zm_download_btn_pos');
            return saved ? JSON.parse(saved) : { bottom: 20, right: 20 };
        } catch {
            return { bottom: 20, right: 20 };
        }
    })();

    // ==================== 工具函数 ====================

    /** 通过 XPath 获取标题文本 */
    function getTitleFromXPath() {
        try {
            const result = document.evaluate(
                CONFIG.titleXPath,
                document,
                null,
                XPathResult.FIRST_ORDERED_NODE_TYPE,
                null
            );
            const node = result.singleNodeValue;
            return node ? node.textContent.trim() : '';
        } catch (e) {
            console.warn('[音频下载] XPath 取值失败:', e);
            return '';
        }
    }

    /** 生成下载文件名: 标题.mp3 */
    function generateFilename() {
        // 优先使用 XPath 元素文本,回退到 document.title
        const titleText = getTitleFromXPath() || document.title || 'audio';
        const cleanTitle = titleText.replace(/[\\/:*?"<>|]/g, '_').trim();
        return `${cleanTitle}.mp3`;
    }

    /** 通过 fetch 下载 MP3 并保存 */
    async function downloadMp3(url, filename) {
        try {
            console.log(`[音频下载] 开始下载: ${filename}`);
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            const blob = await response.blob();
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            // 延迟释放内存
            setTimeout(() => URL.revokeObjectURL(a.href), 10000);
            console.log(`[音频下载] 下载完成: ${filename}`);
        } catch (err) {
            console.error('[音频下载] 下载失败:', err);
            alert('音频下载失败,请检查网络后重试');
        }
    }

    // ==================== UI 组件 ====================

    /** 注入按钮样式 */
    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #${CONFIG.buttonId} {
                position: fixed;
                bottom: ${buttonPosition.bottom}px;
                right: ${buttonPosition.right}px;
                z-index: ${CONFIG.buttonZIndex};
                display: flex;
                align-items: center;
                gap: 8px;
                padding: 10px 18px;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                color: #fff;
                border: none;
                border-radius: 50px;
                cursor: pointer;
                box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
                font-family: Arial, 'Microsoft YaHei', sans-serif;
                font-size: 14px;
                user-select: none;
                white-space: nowrap;
                opacity: 0;
                transform: translateY(20px) scale(0.9);
                pointer-events: none;
                transition: opacity 0.3s ease, transform 0.3s ease, box-shadow 0.2s;
                line-height: 1;
            }
            #${CONFIG.buttonId}.visible {
                opacity: 1;
                transform: translateY(0) scale(1);
                pointer-events: auto;
            }
            #${CONFIG.buttonId}:hover {
                box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
            }
            #${CONFIG.buttonId}:active {
                transform: scale(0.95);
            }
            #${CONFIG.buttonId} .zm-btn-icon {
                font-size: 18px;
                line-height: 1;
                flex-shrink: 0;
            }
            #${CONFIG.buttonId} .zm-btn-label {
                max-width: 220px;
                overflow: hidden;
                text-overflow: ellipsis;
                font-weight: 500;
            }
            #${CONFIG.buttonId}.dragging {
                transition: none !important;
                box-shadow: 0 8px 25px rgba(102, 126, 234, 0.7);
                transform: scale(1.08);
                cursor: grabbing;
            }
        `;
        document.head.appendChild(style);
    }

    /** 创建悬浮按钮(如已存在则直接返回) */
    function createFloatingButton() {
        let btn = document.getElementById(CONFIG.buttonId);
        if (btn) return btn;

        injectStyles();

        btn = document.createElement('div');
        btn.id = CONFIG.buttonId;
        btn.innerHTML = `
            <span class="zm-btn-icon">⬇</span>
            <span class="zm-btn-label">暂无音频</span>
        `;
        document.body.appendChild(btn);

        setupDrag(btn);

        // 点击下载
        btn.addEventListener('click', (e) => {
            if (isDragging) return;
            if (!downloadUrl) {
                alert('暂无可用音频');
                return;
            }
            const filename = generateFilename();
            downloadMp3(downloadUrl, filename);
        });

        return btn;
    }

    /** 设置拖拽功能(鼠标 + 触屏) */
    function setupDrag(btn) {
        function getCoords(e) {
            if (e.type.startsWith('touch')) {
                return { x: e.touches[0].clientX, y: e.touches[0].clientY };
            }
            return { x: e.clientX, y: e.clientY };
        }

        function startDrag(e) {
            if (e.type === 'mousedown' && e.button !== 0) return;
            e.preventDefault();

            isDragging = false;
            btn.classList.add('dragging');

            const { x, y } = getCoords(e);
            const rect = btn.getBoundingClientRect();
            const offset = { x: x - rect.left, y: y - rect.top };

            function onMove(ev) {
                const { x: cx, y: cy } = getCoords(ev);
                btn.style.left = (cx - offset.x) + 'px';
                btn.style.top = (cy - offset.y) + 'px';
                btn.style.right = 'auto';
                btn.style.bottom = 'auto';
                isDragging = true;
            }

            function onEnd() {
                btn.classList.remove('dragging');

                if (isDragging) {
                    const rect = btn.getBoundingClientRect();
                    buttonPosition = {
                        bottom: window.innerHeight - rect.bottom,
                        right: window.innerWidth - rect.right,
                    };
                    btn.style.bottom = buttonPosition.bottom + 'px';
                    btn.style.right = buttonPosition.right + 'px';
                    btn.style.left = 'auto';
                    btn.style.top = 'auto';
                    try {
                        localStorage.setItem('zm_download_btn_pos', JSON.stringify(buttonPosition));
                    } catch { /* ignore */ }
                }

                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onEnd);
                document.removeEventListener('touchmove', onMove);
                document.removeEventListener('touchend', onEnd);
            }

            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup', onEnd);
            document.addEventListener('touchmove', onMove, { passive: true });
            document.addEventListener('touchend', onEnd);
        }

        btn.addEventListener('mousedown', startDrag);
        btn.addEventListener('touchstart', startDrag, { passive: true });
    }

    /** 更新按钮状态 */
    function updateDownloadButton(url) {
        if (!url) {
            downloadUrl = '';
            const btn = document.getElementById(CONFIG.buttonId);
            if (btn) {
                btn.classList.remove('visible');
                btn.querySelector('.zm-btn-label').textContent = '暂无音频';
            }
            return;
        }

        downloadUrl = url;
        currentTitle = getTitleFromXPath() || document.title;

        const btn = createFloatingButton();
        const label = btn.querySelector('.zm-btn-label');
        // 显示部分标题,太长则截断
        label.textContent = currentTitle.length > 15
            ? `下载 ${currentTitle.slice(0, 15)}…`
            : `下载 ${currentTitle}`;
        btn.classList.add('visible');

        console.log(`[音频下载] 检测到新音频: ${url}`);
        console.log(`[音频下载] 标题: ${currentTitle}`);
    }

    // ==================== 核心逻辑 ====================

    /** 监听 audio[hidden] 元素及其 src 变化 */
    function observeAudio() {
        const timer = setInterval(() => {
            const audio = document.querySelector(CONFIG.audioSelector);
            if (!audio) return;

            clearInterval(timer);

            // 处理初始 src
            if (audio.src) {
                updateDownloadButton(audio.src);
            }

            // 监听 src 属性变化
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
                        updateDownloadButton(audio.src);
                    }
                });
            });

            observer.observe(audio, {
                attributes: true,
                attributeFilter: ['src'],
            });
        }, CONFIG.pollInterval);

        // 超时停止轮询
        setTimeout(() => clearInterval(timer), CONFIG.pollTimeout);
    }

    // ==================== 启动 ====================
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', observeAudio);
    } else {
        observeAudio();
    }
})();