吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1120|回复: 32
收起左侧

[其他转载] 喜马拉雅静默下载器 Pro 油猴脚本 AI 重写

[复制链接]
bld2018 发表于 2026-10-30 22:18
本帖最后由 bld2018 于 2026-10-30 22:34 编辑

论坛里看到有人分享过,我试了我本地用不了,这个让AI 改了改,使用脚本后:打开专辑页会自动浮出下载面板,勾选想下的集、点一下自动下载到文件夹。
专辑整批下载、单集下载,勾选哪集下哪集,MP3 / M4A 格式任选。

怎么用
  • 谷歌内核类支持脚本扩展的浏览器装个 Tampermonkey 插件
  • 点击 Tampermonkey 插件的添加新脚本按钮,复制代码进去,然后保存,启用脚本(也可以把 .user.js 文件直接拖进插件)
  • 浏览器登录喜马拉雅网页版(下载需要登录态,没登录拿不到音频)
  • 打开专辑页刷新一下,右上角出现面板,勾选集数点"下载选中"就行

脚本纯本地运行,不收集数据,放心用。
脚本是 AI 写的,难免有疏漏,用着要是报错或者哪里不对,多见谅,也欢迎回帖告诉我。

截屏2026-10-30 下午10.07.10.png

[HTML] 纯文本查看 复制代码
// ==UserScript==
// @name         喜马拉雅静默下载器 Pro
// @namespace    https://xmly.local/
// @version      6.4.0
// @description  勾选批量下载 · 单集下载 · MP3 64k / M4A 48k · 免签名接口 · 零依赖单文件
// @author       XX (rewritten)
// @match        *://www.ximalaya.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        unsafeWindow
// @connect      ximalaya.com
// @connect      xmcdn.com
// @connect      *
// @run-at       document-start
// @noframes
// @license      MIT
// ==/UserScript==



(function () {
    'use strict';

    var W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;

    var STORE_KEY = 'xmly_dl_pro_v6';
    var CONCURRENCY = 3;
    var PAGE_SIZE = 30;
    var QUALITY = 1;
    var MAX_DL_RETRY = 2;
    var MAX_INFO_RETRY = 4;
    var AES_KEY_HEX = 'aaad3e4fd540b0f79dca95606e72bf93';

    var CFG = loadCfg();
    var STATE = {
        running: false,
        mode: 'all',        // all | rest | sel,决定进度显示在哪个按钮上
        done: 0,
        total: 0,
        fail: [],
        tracks: [],
        rowState: {},       // trackId -> idle | loading | done | error
        selected: {}        // trackId -> true,待下载队列
    };
    var dirHandle = null;
    var els = {};
    var lastHref = location.href;

    function loadCfg() {
        var c = GM_getValue(STORE_KEY);
        if (!c || typeof c !== 'object') c = {};
        c.top = typeof c.top === 'number' ? c.top : 20;
        c.left = (typeof c.left === 'number') ? c.left : null;
        // 默认 MP3:实测同一集 M4A 只有 49.1kbps(URL 里是 aacv2-48K),
        // MP3 是 64.4kbps,MP3 比特率更高、信息量更大。可切回 M4A 省体积。
        c.format = c.format === 'm4a' ? 'm4a' : 'mp3';
        return c;
    }
    function saveCfg() { GM_setValue(STORE_KEY, CFG); }
    function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }

    function addStyle(css) {
        if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; }
        var s = document.createElement('style');
        s.textContent = css;
        (document.head || document.documentElement).appendChild(s);
    }

    /* ======================= 网络 ======================= */

    function req(url, opts) {
        opts = opts || {};
        // 显式传 headers 就用传的(传 {} = 完全不带额外头);
        // 不传才加 Referer。音频 CDN 不能带 Referer + credentials,
        // 否则 CORS 下会 ERR_FAILED(Access-Control-Allow-Origin: * 不允许带凭据)。
        var headers = (opts.headers !== undefined)
            ? opts.headers
            : { 'Referer': 'https://www.ximalaya.com/' };
        return new Promise(function (resolve, reject) {
            try {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: opts.binary ? 'arraybuffer' : 'text',
                    timeout: opts.timeout || 20000,
                    headers: headers,
                    onload: function (r) {
                        if (r.status >= 200 && r.status < 400) resolve(r);
                        else reject(new Error('HTTP ' + r.status));
                    },
                    onerror: function () { reject(new Error('网络错误')); },
                    ontimeout: function () { reject(new Error('请求超时')); },
                    onabort: function () { reject(new Error('已取消')); }
                });
            } catch (e) { reject(e); }
        });
    }

    function toJson(text) {
        try { return JSON.parse(text); } catch (e) { return null; }
    }

    /* ======================= 页面识别 ======================= */

    function pageInfo() {
        var m = location.pathname.match(/^\/album\/(\d+)/);
        if (m) return { type: 'album', id: m[1] };
        m = location.pathname.match(/\/(?:sound|track)\/(\d+)/);
        if (m) return { type: 'track', id: m[1] };
        return null;
    }

    /* ======================= 曲目列表 ======================= */

    function normTrack(t, i) {
        var id = String(t.trackId || t.id || t.soundId || '');
        var title = String(t.title || t.trackTitle || t.soundTitle || '').trim();
        return {
            id: id,
            title: title || ('audio_' + id),
            index: typeof t.index === 'number' ? t.index : (i + 1),
            isPaid: !!(t.isPaid || t.isVipFirst)
        };
    }

    async function fetchTrackPage(albumId, pageNum) {
        var url = 'https://www.ximalaya.com/revision/album/getTracksList?albumId=' + albumId +
                  '&pageNum=' + pageNum + '&pageSize=' + PAGE_SIZE;
        var r = await req(url, { timeout: 20000 });
        var j = toJson(r.responseText);
        if (!j || j.ret !== 200) {
            if (j && j.ret === 401) throw new Error('需要登录(401)');
            throw new Error('列表接口异常:' + (j ? (j.msg || j.ret) : '无响应'));
        }
        var d = j.data || {};
        var arr = d.tracks || d.list || d.trackList || [];
        return {
            tracks: arr.map(normTrack).filter(function (x) { return x.id; }),
            total: d.trackTotalCount || 0
        };
    }

    async function loadAllTracks(albumId, onProgress) {
        var all = [];
        var seen = Object.create(null);
        var total = 0;

        for (var p = 1; p <= 200; p++) {
            var page = await fetchTrackPage(albumId, p);
            if (page.total) total = page.total;
            if (!page.tracks.length) break;

            var added = 0;
            page.tracks.forEach(function (t) {
                if (seen[t.id]) return;
                seen[t.id] = 1;
                all.push(t);
                added++;
            });
            if (onProgress) onProgress(all.length, total);
            if (!added) break;
            await sleep(200);
        }

        all.sort(function (a, b) { return (a.index || 0) - (b.index || 0); });
        return { tracks: all, total: total || all.length };
    }

    async function fetchAlbumTitle(albumId) {
        try {
            var r = await req('https://www.ximalaya.com/revision/album?albumId=' + albumId, { timeout: 15000 });
            var j = toJson(r.responseText);
            return (j && j.data && j.data.mainInfo && j.data.mainInfo.albumTitle) || '';
        } catch (e) { return ''; }
    }

    /* ============================================== */
    var AES = (function () {
        function gmul(a, b) {
            var p = 0;
            for (var i = 0; i < 8; i++) {
                if (b & 1) p ^= a;
                var hi = a & 0x80;
                a = (a << 1) & 0xff;
                if (hi) a ^= 0x1b;
                b >>= 1;
            }
            return p & 0xff;
        }
        var SBOX = new Uint8Array(256), INV = new Uint8Array(256);
        for (var i = 0; i < 256; i++) {
            var inv = 0;
            if (i) { for (var j = 1; j < 256; j++) { if (gmul(i, j) === 1) { inv = j; break; } } }
            var s = inv, x = inv;
            for (var k = 0; k < 4; k++) { x = ((x << 1) | (x >> 7)) & 0xff; s ^= x; }
            s ^= 0x63;
            SBOX[i] = s; INV[s] = i;
        }
        var M9 = new Uint8Array(256), M11 = new Uint8Array(256),
            M13 = new Uint8Array(256), M14 = new Uint8Array(256);
        for (var m = 0; m < 256; m++) {
            M9[m] = gmul(m, 9); M11[m] = gmul(m, 11);
            M13[m] = gmul(m, 13); M14[m] = gmul(m, 14);
        }

        function expand(key) {
            var w = new Uint8Array(176);
            w.set(key);
            var rcon = 1;
            for (var i2 = 16; i2 < 176; i2 += 4) {
                var t = w.slice(i2 - 4, i2);
                if (i2 % 16 === 0) {
                    t = new Uint8Array([SBOX[t[1]] ^ rcon, SBOX[t[2]], SBOX[t[3]], SBOX[t[0]]]);
                    rcon = gmul(rcon, 2);
                }
                for (var j2 = 0; j2 < 4; j2++) w[i2 + j2] = w[i2 - 16 + j2] ^ t[j2];
            }
            return w;
        }
        function ark(s, w, r) { for (var i3 = 0; i3 < 16; i3++) s[i3] ^= w[r * 16 + i3]; }
        function invSub(s) { for (var i4 = 0; i4 < 16; i4++) s[i4] = INV[s[i4]]; }
        function invShift(s) {
            var t = s.slice();
            s[1] = t[13]; s[5] = t[1]; s[9] = t[5]; s[13] = t[9];
            s[2] = t[10]; s[6] = t[14]; s[10] = t[2]; s[14] = t[6];
            s[3] = t[7]; s[7] = t[11]; s[11] = t[15]; s[15] = t[3];
        }
        function invMix(s) {
            for (var c = 0; c < 4; c++) {
                var b = c * 4;
                var a0 = s[b], a1 = s[b + 1], a2 = s[b + 2], a3 = s[b + 3];
                s[b]     = M14[a0] ^ M11[a1] ^ M13[a2] ^ M9[a3];
                s[b + 1] = M9[a0]  ^ M14[a1] ^ M11[a2] ^ M13[a3];
                s[b + 2] = M13[a0] ^ M9[a1]  ^ M14[a2] ^ M11[a3];
                s[b + 3] = M11[a0] ^ M13[a1] ^ M9[a2]  ^ M14[a3];
            }
        }
        function hex(h) {
            var o = new Uint8Array(h.length / 2);
            for (var i5 = 0; i5 < o.length; i5++) o[i5] = parseInt(h.substr(i5 * 2, 2), 16);
            return o;
        }
        function decrypt(ct, keyHex) {
            var rk = expand(hex(keyHex));
            var out = new Uint8Array(ct.length);
            var s = new Uint8Array(16);
            for (var off = 0; off < ct.length; off += 16) {
                s.set(ct.subarray(off, off + 16));
                ark(s, rk, 10);
                for (var r = 9; r >= 1; r--) { invShift(s); invSub(s); ark(s, rk, r); invMix(s); }
                invShift(s); invSub(s); ark(s, rk, 0);
                out.set(s, off);
            }
            var pad = out[out.length - 1];
            if (!(pad > 0 && pad <= 16)) pad = 0;
            return out.subarray(0, out.length - pad);
        }
        return { decrypt: decrypt };
    })();

    function b64urlToBytes(s) {
        var b = String(s).replace(/-/g, '+').replace(/_/g, '/');
        while (b.length % 4) b += '=';
        var bin = atob(b);
        var out = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
        return out;
    }

    function decryptPlay(enc) {
        if (!enc) return '';
        try {
            var bytes = AES.decrypt(b64urlToBytes(enc), AES_KEY_HEX);
            var txt = new TextDecoder('utf-8').decode(bytes);
            return /^https?:\/\//i.test(txt) ? txt : '';
        } catch (e) { return ''; }
    }

    /* ======================= 音频地址 ======================= */

    // 按用户选的格式挑一条:m4a / mp3 / best
    function pickByFormat(list, fmt) {
        var sorted = list.slice().sort(function (a, b) {
            return (b.qualityLevel || 0) - (a.qualityLevel || 0);
        });
        if (fmt === 'mp3') {
            for (var i = 0; i < sorted.length; i++) if (/MP3/i.test(sorted[i].type || '')) return sorted[i];
        } else if (fmt === 'm4a') {
            for (var j = 0; j < sorted.length; j++) if (/M4A/i.test(sorted[j].type || '')) return sorted[j];
        }
        return sorted[0];
    }

    async function resolveOnce(trackId) {
        var url = 'https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/' + Date.now() +
                  '?device=web&trackId=' + encodeURIComponent(trackId) + '&trackQualityLevel=' + QUALITY;
        var r = await req(url, { timeout: 12000 });
        var j = toJson(r.responseText);
        if (!j) throw new Error('返回非 JSON');
        if (j.ret) throw new Error(j.msg || ('接口码 ' + j.ret));

        var info = j.trackInfo || {};
        var list = info.playUrlList || [];
        if (!list.length) throw new Error('无播放地址(无权限或已下架)');

        var order = (CFG.format === 'm4a') ? ['m4a', 'mp3'] : ['mp3', 'm4a'];

        for (var k = 0; k < order.length; k++) {
            var it = pickByFormat(list, order[k]);
            if (!it) continue;
            var plain = /^https?:\/\//i.test(it.url) ? it.url : decryptPlay(it.url);
            if (!plain) continue;
            // 付费专辑未购买 / VIP 不覆盖时,服务端只给 _preview_ 试听片段
            if (/_preview_/i.test(plain)) {
                throw new Error('仅试听片段(该专辑需购买或开通 VIP)');
            }
            return { url: plain, quality: it.qualityLevel || 0, type: it.type || '' };
        }
        throw new Error('地址解密失败');
    }

    async function resolveAudio(trackId) {
        var last = null;
        for (var i = 0; i <= MAX_INFO_RETRY; i++) {
            try { return await resolveOnce(trackId); }
            catch (e) {
                last = e;
                if (i < MAX_INFO_RETRY) await sleep(500 + Math.random() * 300);
            }
        }
        throw last || new Error('解析失败');
    }

    /* ======================= 落盘 ======================= */

    function extOf(url) {
        var p = String(url).split('#')[0].split('?')[0];
        var m = p.match(/\.(mp3|m4a|aac|wav|flac|ogg|mp4)$/i);
        return m ? m[1].toLowerCase() : 'm4a';
    }

    function safeName(s) {
        var t = String(s || '')
            .replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
            .replace(/\s+/g, ' ')
            .replace(/^[.\s]+/, '')
            .replace(/[.\s]+$/, '')
            .trim()
            .slice(0, 80);
        return t || 'audio';
    }

    function pad3(n) { n = Number(n) || 0; return ('000' + n).slice(-3); }

    function supportsDirPicker() {
        return typeof (W.showDirectoryPicker || window.showDirectoryPicker) === 'function';
    }

    async function pickDirectory() {
        if (!supportsDirPicker()) { setMsg('当前浏览器不支持选文件夹,将走普通下载', 'warning'); return false; }
        try {
            dirHandle = await (W.showDirectoryPicker || window.showDirectoryPicker).call(W, { id: 'xmly-dl', mode: 'readwrite' });
            setDirLabel();
            setMsg('已选择文件夹:' + dirHandle.name, 'success');
            return true;
        } catch (e) { return false; }
    }

    async function ensureDirPermission() {
        if (!dirHandle) return false;
        try {
            if (typeof dirHandle.queryPermission !== 'function') return true;
            var p = await dirHandle.queryPermission({ mode: 'readwrite' });
            if (p === 'granted') return true;
            p = await dirHandle.requestPermission({ mode: 'readwrite' });
            return p === 'granted';
        } catch (e) { return false; }
    }

    async function writeToDir(name, buf) {
        if (!dirHandle) return false;
        if (!(await ensureDirPermission())) return false;
        try {
            var fh = await dirHandle.getFileHandle(name, { create: true });
            var w = await fh.createWritable();
            await w.write(buf);
            await w.close();
            return true;
        } catch (e) { return false; }
    }

    function saveByBlob(buf, name) {
        var blob = new Blob([buf], { type: 'application/octet-stream' });
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = name;
        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
    }

    /* ======================= 下载 ======================= */

    async function downloadOne(track, seq) {
        var audio = await resolveAudio(track.id);
        var name = pad3(seq || track.index || 0) + '_' + safeName(track.title) + '.' + extOf(audio.url);
        var r = await req(audio.url, { binary: true, timeout: 300000, headers: {} });
        var buf = r && r.response;
        if (!buf || buf.byteLength === 0) throw new Error('音频为空');

        if (dirHandle && await writeToDir(name, buf)) return name;
        saveByBlob(buf, name);
        return name;
    }

    async function withRetry(track, seq) {
        var last = null;
        for (var i = 0; i <= MAX_DL_RETRY; i++) {
            try { return await downloadOne(track, seq); }
            catch (e) {
                last = e;
                if (i < MAX_DL_RETRY) await sleep(900 + Math.random() * 800);
            }
        }
        throw last || new Error('未知错误');
    }

    async function pool(list, limit, worker) {
        var idx = 0;
        var size = Math.max(1, Math.min(limit, list.length));
        var runners = [];
        for (var k = 0; k < size; k++) {
            runners.push((async function () {
                while (true) {
                    var i = idx++;
                    if (i >= list.length) return;
                    await worker(list[i], i);
                }
            })());
        }
        await Promise.all(runners);
    }

    // 下载单条,同步更新列表行状态
    async function runOne(track, seq) {
        setRow(track.id, 'loading');
        try {
            var name = await withRetry(track, seq);
            setRow(track.id, 'done');
            return { ok: true, name: name };
        } catch (e) {
            setRow(track.id, 'error');
            var msg = (e && e.message) || String(e);
            STATE.fail.push('第' + seq + '集 ' + track.title + '|' + msg);
            return { ok: false, err: msg };
        }
    }

    /* ======================= 主流程 ======================= */

    // 下载勾选的(待下载队列)
    async function downloadSelected() {
        if (STATE.running) return;
        var list = selectedTracks();
        if (!list.length) { setMsg('还没勾选任何集数,点左边方框选一下', 'warning'); return; }

        STATE.running = true;
        STATE.mode = 'sel';
        STATE.done = 0;
        STATE.total = list.length;
        STATE.fail = [];
        setBusy(true);
        setMsg('下载选中的 ' + list.length + ' 集…', 'loading');
        render();

        await pool(list, CONCURRENCY, async function (track) {
            var seq = STATE.tracks.indexOf(track) + 1;
            await runOne(track, seq);
            STATE.done++;
            render();
        });

        STATE.running = false;
        setBusy(false);
        finishMsg();
    }

    // 只下没下过的(含失败重试)
    async function downloadRest() {
        if (STATE.running) return;
        var tracks = STATE.tracks.filter(function (t) {
            return STATE.rowState[t.id] !== 'done';
        });
        if (!tracks.length) { setMsg('都下完了', 'success'); return; }
        STATE.running = true;
        STATE.mode = 'rest';
        STATE.done = 0;
        STATE.total = tracks.length;
        STATE.fail = [];
        setBusy(true);
        setMsg('补下 ' + tracks.length + ' 集…', 'loading');

        await pool(tracks, CONCURRENCY, async function (track) {
            var seq = STATE.tracks.indexOf(track) + 1;
            await runOne(track, seq);
            STATE.done++;
            render();
        });

        STATE.running = false;
        setBusy(false);
        finishMsg();
    }

    async function downloadSingle(track) {
        if (STATE.rowState[track.id] === 'loading') return;
        var before = STATE.rowState[track.id];
        if (before === 'done') {
            if (!confirm('这一集已经下过了,要再下一次吗?')) return;
        }
        setMsg('正在下载:' + track.title.slice(0, 22) + '…', 'loading');
        var seq = STATE.tracks.indexOf(track) + 1;
        var r = await runOne(track, seq || track.index || 1);
        if (r.ok) setMsg('&#9989; 已保存:' + r.name, 'success');
        else setMsg('&#10060; 失败:' + r.err, 'error');
    }

    function finishMsg() {
        var ok = STATE.total - STATE.fail.length;
        if (STATE.fail.length) {
            setMsg('完成 ' + ok + '/' + STATE.total + ',失败 ' + STATE.fail.length + ' 条(明细见控制台)', 'warning');
            console.warn('[喜马拉雅下载器] 失败清单:\n' + STATE.fail.join('\n'));
        } else {
            setMsg('&#127881; 全部完成!共 ' + STATE.total + ' 集', 'success');
        }
        STATE.fail = [];
    }

    async function loadList() {
        var pi = pageInfo();
        if (!pi) { setMsg('当前页面不是专辑或单集页', 'error'); return; }
        if (STATE.running) return;

        setList('正在读取列表…');

        var tracks = [], total = 0, title = '';

        try {
            if (pi.type === 'album') {
                title = await fetchAlbumTitle(pi.id);
                var res = await loadAllTracks(pi.id, function (got, all) {
                    setList('读取列表 ' + got + (all ? '/' + all : '') + ' 集…');
                });
                tracks = res.tracks;
                total = res.total;
                setTitle(title || ('专辑_' + pi.id), tracks.length, total);
            } else {
                var h = document.querySelector('h1');
                var t = h ? (h.getAttribute('title') || h.textContent || '').trim() : '';
                tracks = [{ id: pi.id, title: t || ('audio_' + pi.id), index: 1, isPaid: false }];
                total = 1;
                setTitle(t || '单集', 1, 1);
            }
        } catch (e) {
            var em = (e && e.message) || String(e);
            setMsg('读取失败:' + em, 'error');
            setList('读取失败:' + em);
            console.error('[喜马拉雅下载器] 读列表失败', e);
            return;
        }

        STATE.tracks = tracks;
        STATE.rowState = {};
        STATE.selected = {};
        setMsg('', '');

        if (!tracks.length) { setMsg('没找到可下载的集数', 'error'); return; }
        renderList();
    }

    /* ======================= UI ======================= */

    var CSS = "" +
        "#xmly-dl{position:fixed;z-index:2147483000;width:340px;border-radius:16px;" +
        "background:rgba(255,255,255,.97);backdrop-filter:blur(20px) saturate(180%);" +
        "-webkit-backdrop-filter:blur(20px) saturate(180%);" +
        "box-shadow:0 10px 38px rgba(0,0,0,.16),0 0 0 1px rgba(0,0,0,.05);" +
        "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;" +
        "overflow:hidden;user-select:none;}" +
        ".xm-hd{display:flex;align-items:center;gap:8px;padding:12px 14px 4px;cursor:grab;}" +
        ".xm-hd:active{cursor:grabbing;}" +
        ".xm-ic{font-size:18px;line-height:1;}" +
        ".xm-tt{font-size:14px;font-weight:700;color:#1a1a1a;flex:1;}" +
        ".xm-x{width:20px;height:20px;line-height:18px;text-align:center;border-radius:50%;background:rgba(0,0,0,.06);" +
        "color:#888;font-size:14px;cursor:pointer;font-weight:400;}" +
        ".xm-x:hover{background:rgba(0,0,0,.12);color:#333;}" +
        ".xm-sub{margin:0 14px 8px;font-size:11px;color:#8a8a8a;line-height:1.4;" +
        "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}" +
        ".xm-tool{display:flex;gap:6px;margin:0 14px 8px;align-items:stretch;}" +
        ".xm-tool select{flex:0 0 92px;height:32px;padding:0 20px 0 9px;border-radius:8px;border:1px solid #E6E6E6;" +
        "background:#FAFAFA;font-size:11.5px;color:#444;outline:none;cursor:pointer;" +
        "font-family:inherit;-webkit-appearance:none;appearance:none;" +
        "background-image:linear-gradient(45deg,transparent 50%,#999 50%),linear-gradient(135deg,#999 50%,transparent 50%);" +
        "background-position:calc(100% - 12px) 14px,calc(100% - 8px) 14px;background-size:4px 4px;background-repeat:no-repeat;}" +
        ".xm-tool select:focus{border-color:#FC6D26;}" +
        ".xm-btn{display:flex;align-items:center;justify-content:center;gap:3px;border:none;border-radius:8px;" +
        "height:32px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;" +
        "white-space:nowrap;flex-shrink:0;transition:background .2s;overflow:hidden;}" +
        ".xm-sel{flex:1;min-width:0;background:linear-gradient(135deg,#FC6D26,#F4511E);color:#fff;" +
        "box-shadow:0 3px 10px rgba(244,81,30,.26);}" +
        ".xm-sel:hover{background:linear-gradient(135deg,#E64A19,#D84315);}" +
        ".xm-sel[disabled]{background:#F0F0F0;color:#B5B5B5;box-shadow:none;cursor:default;}" +
        ".xm-all{flex:0 0 54px;background:rgba(0,0,0,.05);color:#5a5a5a;font-weight:500;}" +
        ".xm-all:hover{background:rgba(0,0,0,.09);}" +
        ".xm-rest{flex:0 0 44px;background:rgba(0,0,0,.05);color:#5a5a5a;font-weight:500;}" +
        ".xm-rest:hover{background:rgba(0,0,0,.09);}" +
        ".xm-list{margin:0 14px 8px;max-height:236px;overflow-y:auto;overflow-x:hidden;" +
        "border-radius:10px;background:#FAFAFA;padding:4px;}" +
        ".xm-list::-webkit-scrollbar{width:6px;}" +
        ".xm-list::-webkit-scrollbar-thumb{background:#D8D8D8;border-radius:3px;}" +
        ".xm-row{display:flex;align-items:center;gap:5px;padding:5px 6px;border-radius:7px;cursor:pointer;}" +
        ".xm-row:hover{background:rgba(0,0,0,.035);}" +
        ".xm-row.sel{background:rgba(244,81,30,.07);}" +
        ".xm-row.sel .n{color:#C2410C;font-weight:500;}" +
        ".xm-row .c{flex:0 0 13px;width:13px;height:13px;margin:0;padding:0;accent-color:#F4511E;cursor:pointer;}" +
        ".xm-row .i{flex:0 0 26px;font-size:10px;color:#B0B0B0;font-variant-numeric:tabular-nums;}" +
        ".xm-row .n{flex:1;font-size:11.5px;color:#333;white-space:nowrap;overflow:hidden;" +
        "text-overflow:ellipsis;line-height:1.5;}" +
        ".xm-row.error .n{color:#C62828;}" +
        ".xm-row.done .n{color:#2E7D32;}" +
        ".xm-row .d{flex:0 0 42px;height:22px;border:none;border-radius:6px;background:#FFF;color:#F4511E;" +
        "font-size:11px;font-weight:600;cursor:pointer;box-shadow:0 0 0 1px rgba(244,81,30,.28);font-family:inherit;}" +
        ".xm-row .d:hover{background:#F4511E;color:#fff;}" +
        ".xm-row .d[disabled]{background:#F2F2F2;color:#BBB;box-shadow:none;cursor:default;}" +
        ".xm-row.done .d{background:#E8F5E9;color:#2E7D32;box-shadow:none;}" +
        ".xm-row.error .d{background:#FFEBEE;color:#C62828;box-shadow:none;}" +
        ".xm-empty{padding:14px 0;text-align:center;font-size:11px;color:#AAA;}" +
        ".xm-barwrap{display:none;margin:0 14px 8px;height:4px;border-radius:3px;background:#F0F0F0;overflow:hidden;}" +
        ".xm-bar{height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,#FC6D26,#FFA726);transition:width .25s;}" +
        ".xm-dir{display:flex;align-items:center;justify-content:center;width:calc(100% - 28px);margin:0 14px 8px;" +
        "padding:7px 0;border:none;border-radius:8px;background:rgba(0,0,0,.04);color:#666;font-size:11.5px;" +
        "cursor:pointer;font-family:inherit;}" +
        ".xm-dir:hover{background:rgba(0,0,0,.08);}" +
        ".xm-msg{display:none;margin:0 14px 12px;padding:8px 10px;border-radius:9px;font-size:11.5px;" +
        "font-weight:500;text-align:center;line-height:1.5;word-break:break-all;}" +
        ".xm-msg.success{display:block;background:#E8F5E9;color:#2E7D32;}" +
        ".xm-msg.warning{display:block;background:#FFF3E0;color:#E65100;}" +
        ".xm-msg.error{display:block;background:#FFEBEE;color:#C62828;}" +
        ".xm-msg.loading{display:block;background:#E3F2FD;color:#1565C0;}";

    function buildUI() {
        if (document.getElementById('xmly-dl')) return;
        var d = document.createElement('div');
        d.id = 'xmly-dl';
        d.style.top = (CFG.top || 20) + 'px';
        if (CFG.left == null) { d.style.right = '20px'; } else { d.style.left = CFG.left + 'px'; }

        d.innerHTML =
            '<div class="xm-hd">' +
                '<span class="xm-ic">&#127925;</span>' +
                '<span class="xm-tt">喜马拉雅下载器</span>' +
                '<span class="xm-x" id="xm-min" title="收起/展开">–</span>' +
            '</div>' +
            '<div class="xm-sub" id="xm-sub">正在识别页面…</div>' +
            '<div id="xm-body">' +
                '<div class="xm-tool">' +
                    '<select id="xm-fmt" title="下载格式">' +
                        '<option value="mp3">MP3 64k</option>' +
                        '<option value="m4a">M4A 48k</option>' +
                    '</select>' +
                    '<button class="xm-btn xm-all" id="xm-all">全选</button>' +
                    '<button class="xm-btn xm-rest" id="xm-rest" title="只下载还没下过的">补缺</button>' +
                    '<button class="xm-btn xm-sel" id="xm-sel" title="下载勾选的集数">下载选中</button>' +
                '</div>' +
                '<div class="xm-barwrap" id="xm-barwrap"><div class="xm-bar" id="xm-bar"></div></div>' +
                '<div class="xm-list" id="xm-list"><div class="xm-empty">正在读取列表…</div></div>' +
                '<button class="xm-dir" id="xm-dir">&#128193; 选择保存文件夹</button>' +
                '<div class="xm-msg" id="xm-msg"></div>' +
            '</div>';

        document.body.appendChild(d);

        els.root = d;
        els.sub = d.querySelector('#xm-sub');
        els.body = d.querySelector('#xm-body');
        els.fmt = d.querySelector('#xm-fmt');
        els.sel = d.querySelector('#xm-sel');
        els.all = d.querySelector('#xm-all');
        els.rest = d.querySelector('#xm-rest');
        els.barwrap = d.querySelector('#xm-barwrap');
        els.bar = d.querySelector('#xm-bar');
        els.list = d.querySelector('#xm-list');
        els.dir = d.querySelector('#xm-dir');
        els.msg = d.querySelector('#xm-msg');
        els.min = d.querySelector('#xm-min');

        els.fmt.value = CFG.format;
        els.fmt.addEventListener('change', function () {
            CFG.format = els.fmt.value;
            saveCfg();
            setMsg('格式已切换为 ' + (els.fmt.options[els.fmt.selectedIndex].text), 'success');
        });

        els.sel.addEventListener('click', function () { downloadSelected(); });
        els.all.addEventListener('click', function () { toggleSelectAll(); });
        els.rest.addEventListener('click', function () { downloadRest(); });
        els.dir.addEventListener('click', function () { pickDirectory(); });
        els.min.addEventListener('click', function () {
            var hidden = els.body.style.display === 'none';
            els.body.style.display = hidden ? 'block' : 'none';
            els.min.textContent = hidden ? '–' : '+';
        });

        enableDrag(d);
        setDirLabel();
        loadList();
    }

    function enableDrag(el) {
        var handle = el.querySelector('.xm-hd');
        handle.addEventListener('mousedown', function (e) {
            if (e.button !== 0 || e.target.id === 'xm-min') return;
            e.preventDefault();
            var rect = el.getBoundingClientRect();
            var dx = e.clientX - rect.left;
            var dy = e.clientY - rect.top;
            el.style.left = rect.left + 'px';
            el.style.top = rect.top + 'px';
            el.style.right = 'auto';
            function onMove(ev) {
                var x = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, ev.clientX - dx));
                var y = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, ev.clientY - dy));
                el.style.left = x + 'px';
                el.style.top = y + 'px';
            }
            function onUp() {
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
                CFG.left = parseInt(el.style.left, 10) || 0;
                CFG.top = parseInt(el.style.top, 10) || 0;
                saveCfg();
            }
            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup', onUp);
        });
    }

    function renderList() {
        if (!els.list) return;
        if (!STATE.tracks.length) {
            els.list.innerHTML = '<div class="xm-empty">没有曲目</div>';
            updateSelBtn();
            return;
        }
        var frag = document.createDocumentFragment();
        STATE.tracks.forEach(function (t, i) {
            var row = document.createElement('div');
            row.className = 'xm-row' + (STATE.selected[t.id] ? ' sel' : '');
            row.dataset.id = t.id;
            row.innerHTML =
                '<input type="checkbox" class="c"' + (STATE.selected[t.id] ? ' checked' : '') + '>' +
                '<span class="i">' + pad3(i + 1) + '</span>' +
                '<span class="n" title="' + escapeAttr(t.title) + '">' + escapeHtml(t.title) + '</span>' +
                '<button class="d">下载</button>';

            var cb = row.querySelector('.c');
            // 点整行(除了「下载」按钮)都能切换勾选
            row.addEventListener('click', function (e) {
                if (e.target.closest('.d')) return;
                if (e.target === cb) { setSelected(t.id, cb.checked); return; }
                cb.checked = !cb.checked;
                setSelected(t.id, cb.checked);
            });
            cb.addEventListener('change', function () { setSelected(t.id, cb.checked); });
            row.querySelector('.d').addEventListener('click', function () { downloadSingle(t); });

            frag.appendChild(row);
        });
        els.list.innerHTML = '';
        els.list.appendChild(frag);
        updateSelBtn();
    }

    function setSelected(trackId, on) {
        if (on) STATE.selected[trackId] = true;
        else delete STATE.selected[trackId];
        var row = els.list && els.list.querySelector('.xm-row[data-id="' + trackId + '"]');
        if (row) {
            row.classList.toggle('sel', !!on);
            var cb = row.querySelector('.c');
            if (cb) cb.checked = !!on;
        }
        updateSelBtn();
    }

    function selectedTracks() {
        return STATE.tracks.filter(function (t) { return STATE.selected[t.id]; });
    }

    function updateSelBtn() {
        if (!els.sel) return;
        var n = selectedTracks().length;
        var total = STATE.tracks.length;
        els.sel.textContent = n ? ('下载选中 ' + n) : '下载选中';
        els.sel.disabled = (n === 0) || STATE.running;
        if (els.all) {
            els.all.textContent = (total > 0 && n === total) ? '取消全选' : '全选';
            els.all.disabled = STATE.running || total === 0;
        }
    }

    function toggleSelectAll() {
        if (STATE.running || !STATE.tracks.length) return;
        var allOn = selectedTracks().length === STATE.tracks.length;
        STATE.selected = {};
        if (!allOn) STATE.tracks.forEach(function (t) { STATE.selected[t.id] = true; });
        // 同步 DOM
        els.list.querySelectorAll('.xm-row').forEach(function (row) {
            var on = !!STATE.selected[row.dataset.id];
            row.classList.toggle('sel', on);
            var cb = row.querySelector('.c');
            if (cb) cb.checked = on;
        });
        updateSelBtn();
        setMsg(allOn ? '已取消全选' : ('已全选 ' + STATE.tracks.length + ' 集'), 'success');
    }

    function setRow(trackId, state) {
        STATE.rowState[trackId] = state;
        var row = els.list && els.list.querySelector('.xm-row[data-id="' + trackId + '"]');
        if (!row) return;
        row.classList.remove('loading', 'done', 'error');
        if (state !== 'idle') row.classList.add(state);
        var btn = row.querySelector('.d');
        if (!btn) return;
        if (state === 'loading') { btn.disabled = true; btn.textContent = '…'; }
        else if (state === 'done') { btn.disabled = false; btn.textContent = '&#10003;'; }
        else if (state === 'error') { btn.disabled = false; btn.textContent = '重试'; }
        else { btn.disabled = false; btn.textContent = '下载'; }
    }

    function setList(html) {
        if (els.list) els.list.innerHTML = '<div class="xm-empty">' + escapeHtml(html) + '</div>';
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, function (c) {
            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
        });
    }
    function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }

    function setMsg(text, type) {
        if (!els.msg) return;
        els.msg.className = 'xm-msg' + (text ? ' ' + (type || 'success') : '');
        els.msg.textContent = text || '';
    }

    function setBusy(busy) {
        if (!els.sel) return;
        els.sel.disabled = busy || selectedTracks().length === 0;
        els.rest.disabled = busy;
        if (els.all) els.all.disabled = busy || !STATE.tracks.length;
        if (!busy) {
            els.rest.textContent = '补缺';
            updateSelBtn();
        }
        els.barwrap.style.display = busy ? 'block' : 'none';
        if (!busy) els.bar.style.width = '0%';
    }

    function render() {
        var pct = STATE.total ? Math.round(STATE.done / STATE.total * 100) : 0;
        if (els.bar) els.bar.style.width = pct + '%';
        if (!STATE.running) return;
        var txt = STATE.done + '/' + STATE.total;
        if (STATE.mode === 'rest' && els.rest) els.rest.textContent = txt;
        else if (els.sel) { els.sel.textContent = txt; els.sel.disabled = true; }
    }

    function setDirLabel() {
        if (!els.dir) return;
        els.dir.textContent = dirHandle ? ('&#128193; ' + dirHandle.name) : '&#128193; 选择保存文件夹';
    }

    function setTitle(title, got, total) {
        if (!els.sub) return;
        els.sub.textContent = title + ' · 共 ' + total + ' 集' + (got < total ? '(已读 ' + got + ')' : '');
        els.sub.title = title;
    }

    setInterval(function () {
        if (location.href === lastHref) return;
        lastHref = location.href;
        if (STATE.running) return;
        STATE.tracks = [];
        STATE.rowState = {};
        STATE.selected = {};
        setMsg('', '');
        setList('页面已切换,正在读取…');
        loadList();
    }, 900);

    addStyle(CSS);
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', buildUI, { once: true });
    } else {
        buildUI();
    }
})();

免费评分

参与人数 12吾爱币 +18 热心值 +9 收起 理由
ookk + 1 + 1 谢谢@Thanks!
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
笨笨家的唯一 + 1 + 1 我很赞同!
三北豆酥糖 + 1 + 1 谢谢@Thanks!
xiaoxi9826 + 2 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
如实知见 + 1 + 1 谢谢@Thanks!
shengyu30 + 1 热心回复!
cux666 + 1 谢谢@Thanks!
adime2018 + 1 + 1 谢谢@Thanks!
helian147 + 1 + 1 热心回复!
qq9953 + 1 谢谢@Thanks!
freedomhaoyi + 1 谢谢@Thanks!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| bld2018 发表于 2026-10-31 19:21
jiang1232 发表于 2026-10-31 18:52
有需求,但不会用,想听的都是拼xx上找人代下,花钱省时间

https://www.52pojie.cn/forum.php?mod=redirect&goto=findpost&ptid=2117760&pid=55581363  之前我那个网盘链接转成按钮的有写过怎么使用脚本,或者自己可以问AI怎么安装。
头像被屏蔽
Cleverwwh 发表于 2026-10-31 10:15
箫风1972 发表于 2026-10-31 07:14
感谢楼主,这个不太会用啊!先摸索一下,不成了再向楼主请教学习。
lovekezi 发表于 2026-10-31 08:30
感谢分享
dxyzlrj 发表于 2026-10-31 08:37
显示“系统繁忙稍后再试”
Zhangys2026 发表于 2026-10-31 09:13
博主,需要VIP的也可以吗
Zhangys2026 发表于 2026-10-31 09:28
Zhangys2026 发表于 2026-10-31 09:13
博主,需要VIP的也可以吗

绕不过VIP 只是个下载界面 方便下载,这个很不错,可以批量一次性下载。
zjfa 发表于 2026-10-31 09:42
Zhangys2026 发表于 2026-10-31 09:28
绕不过VIP 只是个下载界面 方便下载,这个很不错,可以批量一次性下载。

要是可绕过VIP,那就牛b了,不过软件周期估计也就不用多久就不能用了
geotian 发表于 2026-10-31 10:22
学习一下,感谢分享
hosi212 发表于 2026-10-31 11:35
很好用 感谢分享
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-5 10:48

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表