Bilibili Music Extractor

从B站上提取带封面的音乐

目前为 2021-08-31 提交的版本。查看 最新版本

// ==UserScript==
// @name         Bilibili Music Extractor
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  从B站上提取带封面的音乐
// @author       ☆
// @include      https://www.bilibili.com/video/*
// @icon         https://www.bilibili.com/favicon.ico
// @require      https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Your code here...

    const CHUNK_SIZE = 1024 * 1024 * 1;

    const download = (url, filename) => {
        const stubLink = document.createElement('a');
        stubLink.style.display = 'none';
        stubLink.href = url;
        stubLink.download = filename;
        document.body.appendChild(stubLink);
        stubLink.click();
        document.body.removeChild(stubLink);
    }

    const getAudioPieces = (baseUrl, start, end) => {
        const headers = {
            'Range': 'bytes=' + start + '-' + end,
            'Referer': location.href
        };
        const result = [];
        console.log('start fetching piece...');
        return fetch(baseUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers,
            referrerPolicy: 'no-referrer-when-downgrade',
        }).then(response => {
            if (response.status === 416) {
                console.log('reached last piece');
                throw response;
            }
            if (!response.ok) {
                console.error(response);
                throw new Error('Network response was not ok');
            }
            if (!response.headers.get('Content-Range')) {
                console.log('content reached the end');
                const endError = new Error('reached the end');
                endError.status = 204;
                throw endError;
            }
            return response.blob();
        }).then(buffer => {
            result.push(buffer);
            return getAudioPieces(baseUrl, end + 1, end + CHUNK_SIZE);
        }).then(buffers => {
            return result.concat(buffers);
        }).catch(error => {
            if (error.status === 204) {
                return result;
            } else if (error.status === 416) {
                return getLastAudioPiece(baseUrl, start).then(lastPiece => {
                    result.push(lastPiece);
                    return result;
                });
            } else throw error;
        })
    }

    const getLastAudioPiece = (baseUrl, start) => {
        const headers = {
            'Range': '' + start + '-',
            'Referer': location.href
        };
        console.log('start fetching last piece...');
        return fetch(baseUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers,
            referrerPolicy: 'no-referrer-when-downgrade',
        }).then(response => {
            if (!response.ok) {
                console.error(response);
                throw new Error('Network response was not ok');
            }
            return response.blob();
        })
    }

    const getAudio = (baseUrl) => {
        const start = 0;
        const end = CHUNK_SIZE - 1;
        return getAudioPieces(baseUrl, start, end);
    }

    const getInfo = (fieldname) => {
        let info = '';
        const infoMetadataElement = document.head.querySelector(`meta[itemprop="${fieldname}"]`);
        if (infoMetadataElement) {
            info = infoMetadataElement.content;
        }
        return info.trim();
    }

    const getLyricsTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const rest = seconds - minutes * 60;
        return `${minutes < 10 ? '0' : ''}${minutes}:${rest < 10 ? '0' : ''}${rest.toFixed(2)}`;
    };

    const getLyrics = () => {
        if (
            !__INITIAL_STATE__
            || !__INITIAL_STATE__.videoData
            || !__INITIAL_STATE__.videoData.subtitle
            || !Array.isArray(__INITIAL_STATE__.videoData.subtitle.list)
            || __INITIAL_STATE__.videoData.subtitle.list.length === 0
        ) return Promise.resolve(null);
        const defaultLyricsUrl = __INITIAL_STATE__.videoData.subtitle.list[0].subtitle_url;
        return fetch(defaultLyricsUrl.replace('http', 'https'))
            .then(response => response.json())
        .then(lyricsObject => {
            if (!lyricsObject) return null;
            const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');
            if (!videoElement) return null;
            const totalLength = videoElement.duration;
            const lyrics = lyricsObject.body;
            const lyricsText = lyricsObject.body.reduce((accu, current) => {
                accu += `[${getLyricsTime(current.from)}]${current.content}\r\n`;
                return accu;
            }, '');
            return lyricsText;
        });
    }

    const parse = () => {
        const playInfoHead = 'window.__playinfo__=';
        const scriptElements = document.head.getElementsByTagName('script');
        const playInfoScript = Array.from(scriptElements).find(script => script.text.trim().startsWith(playInfoHead));
        if (playInfoScript) {
            const playInfoText = playInfoScript.text.trim().substring(playInfoHead.length);
            const playInfo = JSON.parse(playInfoText);
            const audioUrlList = playInfo.data.dash.audio;
            if (Array.isArray(audioUrlList) && audioUrlList.length > 0) {
                const {baseUrl, mimeType} = audioUrlList[0];
                return getAudio(baseUrl).then(result => {
                    const wholeBlob = new Blob(result, {type: mimeType});

                    return wholeBlob.arrayBuffer().then(buffer => ({ buffer, mimeType }));
                }).catch(error => {
                    console.error('There has been a problem with your fetch operation:', error);
                });
            }
        }
        return Promise.resolve();
    }

    const buildPluginElement = () => {
        const styles = {
            color: {
                primary: '#00a1d6',
                secondary: '#fb7299',
                lightText: '#f4f4f4'
            },
            spacing: {
                xsmall: '0.25rem',
                small: '0.5rem',
                medium: '1rem',
                large: '2rem',
                xlarge: '3rem'
            }
        };
        const strings = {
            cover: {
                title: '封面'
            },
            infoItems: {
                filename: '文件名',
                title: '标题',
                author: '作者'
            },
            download: {
                idle: '下载音乐',
                processing: '处理中…',
                lyrics: '下载歌词',
                noLyrics: '无歌词'
            }
        }

        const box = document.createElement('div');
        box.isOpen = false;
        // ------------- Container Box START -------------
        const resetBoxStyle = () => {
            box.style.position = 'absolute';
            box.style.left = `calc(-${styles.spacing.xlarge} - ${styles.spacing.small})`;
            box.style.top = 0;
            box.style.transition = box.style.webkitTransition = 'all 0.25s ease';
            box.style.width = box.style.height = `calc(${styles.spacing.xlarge} + ${styles.spacing.small})`;
            box.style.border = `solid 0.25rem ${styles.color.primary}`;
            box.style.opacity = 0.5;
            box.style.cursor = 'pointer';
            box.style.zIndex = 100;
            box.style.boxSizing = 'border-box';
            box.style.overflow = 'hidden';
            box.style.padding = styles.spacing.small;
            box.style.display = 'flex';
            box.style.flexDirection = 'column';
        };
        const openBox = () => {
            box.style.width = '40rem';
            box.style.height = '40rem';
            box.style.backgroundColor = 'white';
            box.style.cursor = 'auto';

            box.isOpen = true;
        }
        const closeBox = () => {
            resetBoxStyle();
            box.isOpen = false;
        }
        resetBoxStyle();
        box.addEventListener('mouseenter', () => {
            box.style.opacity = 1;
        });
        box.addEventListener('mouseleave', () => {
            if (!box.isOpen) box.style.opacity = 0.5;
        });
        box.addEventListener('click', () => {
            if (!box.isOpen) openBox();
        });
        // ------------- Container Box END -------------

        // ------------- Icon START -------------
        const svgNamespace = 'http://www.w3.org/2000/svg';
        const icon = document.createElementNS(svgNamespace, 'svg');
        const useIcon = document.createElementNS(svgNamespace, 'use');
        useIcon.setAttributeNS('http://www.w3.org/1999/xlink', "href", "#bili-music");
        icon.style.width = icon.style.height = styles.spacing.large;
        icon.style.flexShrink = 0;
        icon.appendChild(useIcon);
        // ------------- Icon END -------------

        // ------------- Close Button START -------------
        const closeButton = document.createElement('button');
        closeButton.className = 'bilifont bili-icon_sousuo_yichu cancel-icon';
        closeButton.style.width = closeButton.style.height = styles.spacing.large;
        closeButton.style.position = 'absolute';
        closeButton.style.left = `max(${styles.spacing.xlarge} + ${styles.spacing.small}, 100% - ${styles.spacing.large} - ${styles.spacing.small})`;
        closeButton.style.top = styles.spacing.small;
        closeButton.style.display = 'flex';
        closeButton.style.alignItems = 'center';
        closeButton.style.justifyContent = 'center';
        closeButton.style.fontSize = '1.5em';
        closeButton.style.color = styles.color.primary;
        closeButton.addEventListener('click', (e) => {
            e.stopPropagation();
            closeBox();
        });
        // ------------- Close Button END -------------

        // ------------- Panel START -------------
        const panel = document.createElement('div');
        panel.style.flex = '1';
        panel.style.margin = '0';
        panel.style.alignSelf = 'stretch';
        panel.style.overflow = 'auto';
        panel.style.marginTop = styles.spacing.small;
        panel.style.paddingTop = styles.spacing.small;
        panel.style.borderTop = `solid 0.125rem ${styles.color.primary}`;
        // ------------- Panel END -------------

        const setTitleStyles = element => {
            element.style.lineHeight = 1.5;
            element.style.margin = 0;
            element.style.padding = 0;
            element.style.color = styles.color.primary;
        };

        const coverImageUrl = getInfo('image');

        // ------------- Cover START -------------
        const coverContainer = document.createElement('div');
        coverContainer.style.width = '100%';
        coverContainer.style.marginBottom = styles.spacing.small;
        const coverTitle = document.createElement('h5');
        coverTitle.textContent = strings.cover.title;
        setTitleStyles(coverTitle);
        const coverImage = document.createElement('img');
        coverImage.style.width = '100%';
        coverImage.objectFit = 'contain';
        coverImage.src = coverImageUrl;
        coverContainer.append(coverTitle, coverImage);
        // ------------- Cover END -------------

        // ------------- Info Item START -------------
        const buildInfoItem = (title, text) => {
            const infoContainer = document.createElement('div');
            infoContainer.style.width = '100%';
            infoContainer.style.display = 'flex';
            infoContainer.style.alignItems = 'center';
            infoContainer.style.flexWrap = 'nowrap';
            infoContainer.style.overflow = 'hidden';
            infoContainer.style.marginBottom = styles.spacing.small;
            const infoTitle = document.createElement('h5');
            infoTitle.textContent = title;
            setTitleStyles(infoTitle);
            infoTitle.display = 'inline';
            const infoText = document.createElement('input');
            infoText.type = 'text';
            infoText.value = text;
            infoText.style.flex = '1';
            infoText.style.marginLeft = styles.spacing.xsmall;
            infoText.style.background = 'none';
            infoText.style.border = '0';
            infoText.style.borderBottom = `solid 1px ${styles.color.primary}`;
            infoText.style.padding = styles.spacing.xsmall;
            infoContainer.append(infoTitle, infoText);
            infoContainer.textInput = infoText;
            return infoContainer;
        }

        const dummyText = /_哔哩哔哩.+/;
        const titleText = getInfo('name').replace(dummyText, '');
        const filenameItem = buildInfoItem(strings.infoItems.filename, titleText + '.mp3');
        const titleItem = buildInfoItem(strings.infoItems.title, titleText);
        const authorItem = buildInfoItem(strings.infoItems.author, getInfo('author'));
        // ------------- Info Item END -------------

        // ------------- Download Button START -------------
        const downloadButton = document.createElement('button');
        downloadButton.textContent = strings.download.idle;
        downloadButton.style.background = 'none';
        downloadButton.style.border = '0';
        downloadButton.style.backgroundColor = styles.color.primary;
        downloadButton.style.color = styles.color.lightText;
        downloadButton.style.width = '45%';
        downloadButton.style.cursor = 'pointer';
        downloadButton.style.textAlign = 'center';
        downloadButton.style.padding = styles.spacing.small;
        downloadButton.style.marginBottom = styles.spacing.small;
        downloadButton.style.transition = downloadButton.style.webkitTransition = 'all 0.25s ease';
        downloadButton.addEventListener('mouseenter', () => {
            downloadButton.style.filter = 'brightness(1.1)';
        });
        downloadButton.addEventListener('mouseleave', () => {
            downloadButton.style.filter = 'none';
        });
        downloadButton.addEventListener('mousedown', () => {
            downloadButton.style.filter = 'brightness(0.9)';
        });
        downloadButton.addEventListener('mouseup', () => {
            downloadButton.style.filter = 'brightness(1.1)';
        });
        downloadButton.addEventListener('click', (e) => {
            if (downloadButton.disabled) return;
            e.stopPropagation();
            downloadButton.textContent = strings.download.processing;
            downloadButton.disabled = true;
            downloadButton.style.cursor = 'not-allowed';
            const title = unescape(encodeURIComponent(titleItem.textInput.value));
            const author = unescape(encodeURIComponent(authorItem.textInput.value));
            return parse(filenameItem.textInput.value)
                .then(({ buffer, mimeType }) => {
                const {
                    createFFmpeg,
                    fetchFile
                } = FFmpeg;
                const ffmpeg = createFFmpeg();
                const transcode = (blob, filename) => fetchFile(blob)
                .then((file) => {
                    ffmpeg.FS('writeFile', 'original.mp3', file);
                    console.log('encoding file...');
                    return ffmpeg.run(
                        '-i', 'original.mp3',
                        '-i', 'cover.jpg',
                        '-map', '0',
                        '-map', '1:v',
                        '-ar', '44100',
                        '-b:a', '192k',
                        '-disposition:v:1', 'attached_pic',
                        'out.mp3'
                    ).then(() => {
                        console.log('adding metadata...');
                        const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');

                        return ffmpeg.run(
                            '-i', 'out.mp3',
                            '-codec', 'copy',
                            '-t', `${videoElement.duration}`,
                            '-metadata', `title=${title}`,
                            '-metadata', `artist=${author}`,
                            'outWithMetadata.mp3'
                        );
                    });
                }).then(() => {
                    const data = ffmpeg.FS('readFile', 'outWithMetadata.mp3');
                    return data.buffer;
                });


                ffmpeg.load()
                    .then(() => fetch(coverImageUrl.replace('http', 'https')))
                    .then(response => response.blob())
                    .then(fetchFile)
                .then(imageFile => {
                    ffmpeg.FS('writeFile', 'cover.jpg', imageFile);
                    return transcode(buffer, filenameItem.textInput.value);
                }).then(encodedBuffer => {
                    const audioBlob = new Blob([encodedBuffer], {type: mimeType});
                    const audioUrl = URL.createObjectURL(audioBlob);
                    return download(audioUrl, filenameItem.textInput.value);
                })
                .finally(() => {
                    downloadButton.textContent = strings.download.idle;
                    downloadButton.disabled = false;
                    downloadButton.style.cursor = 'pointer';
                });
            });
        });
        const downloadLyricsButton = downloadButton.cloneNode();
        downloadLyricsButton.disabled = true;
        downloadLyricsButton.style.cursor = 'not-allowed';
        downloadLyricsButton.style.marginRight = '10%';
        downloadLyricsButton.textContent = strings.download.noLyrics;
        downloadLyricsButton.addEventListener('mouseenter', () => {
            downloadLyricsButton.style.filter = 'brightness(1.1)';
        });
        downloadLyricsButton.addEventListener('mouseleave', () => {
            downloadLyricsButton.style.filter = 'none';
        });
        downloadLyricsButton.addEventListener('mousedown', () => {
            downloadLyricsButton.style.filter = 'brightness(0.9)';
        });
        downloadLyricsButton.addEventListener('mouseup', () => {
            downloadLyricsButton.style.filter = 'brightness(1.1)';
        });
        let lyricsText = null;
        getLyrics().then(lyrics => {
            if (!lyrics) return;
            lyricsText = lyrics;
            downloadLyricsButton.disabled = false;
            downloadLyricsButton.style.cursor = 'pointer';
            downloadLyricsButton.textContent = strings.download.lyrics;
        })
        downloadLyricsButton.addEventListener('click', (e) => {
            if (downloadLyricsButton.disabled) return;
            e.stopPropagation();
            downloadLyricsButton.textContent = strings.download.processing;
            downloadLyricsButton.disabled = true;
            downloadLyricsButton.style.cursor = 'not-allowed';
            const title = titleItem.textInput.value;
            const author = authorItem.textInput.value;
            lyricsText = `[ti:${title}]\n[ar:${author}]\n${lyricsText}`.trim();
            const lyrics = new Blob([lyricsText], {type: 'text/plain'});
            const lyricsUrl = URL.createObjectURL(lyrics);
            download(lyricsUrl, filenameItem.textInput.value.replace(/\.[^\s\.]+$/, '.lrc'));
            downloadLyricsButton.textContent = strings.download.lyrics;
            downloadButton.disabled = false;
            downloadLyricsButton.style.cursor = 'pointer';
        });
        // ------------- Download Button END -------------
        panel.append(
            coverContainer,
            filenameItem,
            titleItem,
            authorItem,
            downloadLyricsButton,
            downloadButton
        );

        box.append(
            icon,
            closeButton,
            panel
        );

        return box;
    }

    const bilibiliPlayer = document.querySelector('#bilibiliPlayer');
    if (bilibiliPlayer) {
        const pluginBox = buildPluginElement();
        bilibiliPlayer.appendChild(pluginBox);
    }
})();













QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址