NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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();
}
})();