// ==UserScript==
// @name Bilibili Music Extractor
// @namespace http://tampermonkey.net/
// @version 0.4.1
// @description 从B站上提取带封面的音乐
// @author ☆
// @include https://www.bilibili.com/video/*
// @include https://www.bilibili.com/festival/*
// @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 sanitizeStringAsFilename = (name) => {
const allowedLength = 64;
const replacement = '_';
const reRelativePath = /^\.+(\\|\/)|^\.+$/;
const reTrailingPeriods = /\.+$/;
const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
const reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g;
const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
const windowsReservedNameRegex= /^(con|prn|aux|nul|com\d|lpt\d)$/i;
name = name.replace(reRepeatedReservedCharacters, '$1')
name = name.normalize('NFD');
name = name.replace(reRelativePath, replacement);
name = name.replace(filenameReservedRegex, replacement);
name = name.replace(reControlChars, replacement);
name = name.replace(reTrailingPeriods, '');
if (name[0] === '.') {
name = replacement + name;
}
if (name[name.length - 1] === '.') {
name += replacement;
}
name = windowsReservedNameRegex.test(name) ? name + replacement : name;
if (name.length > allowedLength) {
const extensionIndex = name.lastIndexOf('.');
if (extensionIndex === -1) {
name = name.slice(0, allowedLength);
} else {
const filename = name.slice(0, extensionIndex);
const extension = name.slice(extensionIndex);
name = filename.slice(0, Math.max(1, allowedLength - extension.length)) + extension;
}
}
return name;
}
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 = async (baseUrl, start, end) => {
const headers = {
'Range': 'bytes=' + start + '-' + end,
'Referer': location.href
};
const result = [];
console.log('start fetching piece...');
try {
const response = await fetch(baseUrl, {
method: 'GET',
cache: 'no-cache',
headers,
referrerPolicy: 'no-referrer-when-downgrade',
});
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;
}
const audioBuffer = await response.blob();
result.push(audioBuffer);
const buffers = await getAudioPieces(baseUrl, end + 1, end + CHUNK_SIZE);
return result.concat(buffers);
} catch (err) {
if (err.status === 204) {
return result;
} else if (err.status === 416) {
const lastPiece = await getLastAudioPiece(baseUrl, start)
result.push(lastPiece);
return result;
} else {
throw err;
}
}
}
const getLastAudioPiece = async (baseUrl, start) => {
const headers = {
'Range': '' + start + '-',
'Referer': location.href
};
console.log('start fetching last piece...');
const response = await fetch(baseUrl, {
method: 'GET',
cache: 'no-cache',
headers,
referrerPolicy: 'no-referrer-when-downgrade',
})
if (!response.ok) {
console.error(response);
throw new Error('Network response was not ok');
}
return await 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;
}
if (info.length < 1 && __INITIAL_STATE__) {
// If we fail to get info from head elements,
// then we try to get it from __INITIAL_STATE__ or other element
switch (fieldname) {
case 'image': {
const videoItems = document.querySelectorAll(".video-episode-card.video-episode-card-title-hover");
const activeVideoItem = Array.from(videoItems).find(item => item.textContent.includes(getInfo("name")));
if (activeVideoItem) {
const activeVideoCover = activeVideoItem.querySelector(".activity-image-card.cover-link-image .activity-image-card__image");
if (activeVideoCover) {
info = activeVideoCover.style.backgroundImage;
info = info.replace(/url\("(.+)@.*"\)/, "$1");
}
}
break;
}
case 'name':
if (__INITIAL_STATE__.videoInfo) {
info = __INITIAL_STATE__.videoInfo.title || '';
} else if (__INITIAL_STATE__.videoData) {
info = __INITIAL_STATE__.videoData.title || '';
}
break;
case 'author':
if (__INITIAL_STATE__.videoInfo) {
info = __INITIAL_STATE__.videoInfo.upName || '';
} else if (__INITIAL_STATE__.videoData) {
info = __INITIAL_STATE__.videoData.author || __INITIAL_STATE__.videoData.owner?.name || '';
}
break;
case 'cid': {
const videoData = __INITIAL_STATE__.videoInfo || __INITIAL_STATE__.videoData;
if (videoData && Array.isArray(videoData.pages) && videoData.pages.length > 0) {
let page = parseInt(__INITIAL_STATE__.p);
if (Number.isNaN(page)) {
page = 0;
} else {
page = Math.max(page - 1, 0);
}
info = `${videoData.pages[page].cid}`;
break;
}
// otherwise, fallback to default handler
}
default:
if (__INITIAL_STATE__.videoInfo) {
info = __INITIAL_STATE__.videoInfo[fieldname] || '';
} else if (__INITIAL_STATE__.videoData) {
info = __INITIAL_STATE__.videoData[fieldname] || '';
}
info = `${info}`;
break;
}
}
if (fieldname === 'image') {
// try to get original image url
try {
info = info.replace(/(.+)(@.*)/, "$1");
info = `http:${info}`;
} catch (e) {
}
}
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 = async () => {
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;
const response = await fetch(defaultLyricsUrl.replace('http', 'https'));
const lyricsObject = await response.json();
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 = async () => {
try {
const bvid = getInfo("bvid");
const cid = getInfo("cid");
// api from: https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/video/videostream_url.md
const videoMetadataResponse = await fetch(`https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&fnval=80`, {
method: 'GET',
cache: 'no-cache',
referrerPolicy: 'no-referrer-when-downgrade',
});
const videoMetadata = await videoMetadataResponse.json();
const audioUrlList = videoMetadata.data.dash.audio;
if (Array.isArray(audioUrlList) && audioUrlList.length > 0) {
const {baseUrl, mimeType} = audioUrlList[0];
const audioResult = await getAudio(baseUrl);
const wholeBlob = new Blob(audioResult, {type: mimeType});
const buffer = await wholeBlob.arrayBuffer();
console.log("audio buffer fetched");
return { buffer, mimeType };
}
} catch (err) {
console.error('There has been a problem with your fetch operation:', err);
}
throw new Error("failed to get audio data");
}
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 = `-${styles.spacing.xlarge}`;
box.style.top = 0;
box.style.transition = box.style.webkitTransition = 'all 0.25s ease';
box.style.width = box.style.height = styles.spacing.xlarge;
box.style.borderRadius = styles.spacing.xsmall;
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';
box.style.boxShadow = "none";
};
const openBox = () => {
box.style.width = '40rem';
box.style.height = '40rem';
box.style.backgroundColor = 'white';
box.style.cursor = 'auto';
box.style.boxShadow = "0 0 6px gainsboro";
box.isOpen = true;
coverImage.src = coverImageUrl = getInfo('image');
}
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 icon = new DOMParser().parseFromString('<svg id="channel-icon-music" viewBox="0 0 1024 1024" class="icon"><path d="M881.92 460.8a335.36 335.36 0 0 0-334.336-335.104h-73.216A335.616 335.616 0 0 0 139.776 460.8v313.6a18.688 18.688 0 0 0 18.432 18.688h41.984c13.568 46.336 37.888 80.384 88.576 80.384h98.304a37.376 37.376 0 0 0 37.376-36.864l1.28-284.672a36.864 36.864 0 0 0-37.12-37.12h-99.84a111.616 111.616 0 0 0-51.2 12.8V454.4a242.432 242.432 0 0 1 241.664-241.664h67.328A242.176 242.176 0 0 1 787.968 454.4v74.496a110.592 110.592 0 0 0-54.272-14.08h-99.84a36.864 36.864 0 0 0-37.12 37.12v284.672a37.376 37.376 0 0 0 37.376 36.864h98.304c51.2 0 75.008-34.048 88.576-80.384h41.984a18.688 18.688 0 0 0 18.432-18.688z" fill="#45C7DD"></path><path d="m646.1859999999999 792.7090000000001.274-196.096q.046-32.512 32.558-32.466l1.024.001q32.512.045 32.466 32.557l-.274 196.096q-.045 32.512-32.557 32.467l-1.024-.002q-32.512-.045-32.467-32.557ZM307.26800000000003 792.7349999999999l.274-196.096q.045-32.512 32.557-32.467l1.024.002q32.512.045 32.467 32.557l-.274 196.096q-.045 32.512-32.557 32.466l-1.024-.001q-32.512-.045-32.467-32.557Z" fill="#FF5C7A"></path></svg>', 'text/html').getElementById('channel-icon-music');
icon.style.width = icon.style.height = styles.spacing.large;
icon.style.flexShrink = 0;
// ------------- Icon END -------------
// ------------- Close Button START -------------
const closeIcon = new DOMParser().parseFromString('<svg id="download__close-button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 6.939l3.182-3.182a.75.75 0 111.061 1.061L9.061 8l3.182 3.182a.75.75 0 11-1.061 1.061L8 9.061l-3.182 3.182a.75.75 0 11-1.061-1.061L6.939 8 3.757 4.818a.75.75 0 111.061-1.061L8 6.939z"></path></svg>', 'text/html').getElementById('download__close-button');
const closeButton = document.createElement('button');
closeButton.className = 'bilifont';
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();
});
closeButton.appendChild(closeIcon);
// ------------- 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;
};
let coverImageUrl = getInfo('image');
console.log('coverImageUrl set to: ', coverImageUrl);
// ------------- 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.style.flexBasis = '3em';
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.className = "bi-btn";
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', async (e) => {
if (downloadButton.disabled) return;
e.stopPropagation();
downloadButton.textContent = strings.download.processing;
downloadButton.disabled = true;
downloadButton.style.cursor = 'not-allowed';
try {
let encoding = false;
const title = sanitizeStringAsFilename(titleItem.textInput.value);
const author = sanitizeStringAsFilename(authorItem.textInput.value);
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({
// log: true,
progress: p => {
if (encoding) {
console.log(p.ratio);
downloadButton.textContent = `${strings.download.processing}${(p.ratio / 100).toFixed(0)}%`;
}
},
corePath: "https://unpkg.zhimg.com/@ffmpeg/core-st/dist/ffmpeg-core.js", // https://unpkg.com/@ffmpeg/core-st/dist/ffmpeg-core.js
mainName: "main"
});
const { buffer, mimeType } = await parse(filenameItem.textInput.value);
await ffmpeg.load()
const imageResponse = await fetch(coverImageUrl.replace('http', 'https'));
const coverImageBlob = await imageResponse.blob();
const imageFile = await fetchFile(coverImageBlob);
ffmpeg.FS('writeFile', 'cover.jpg', imageFile);
console.log('cover image fetched');
const file = await fetchFile(buffer);
ffmpeg.FS('writeFile', 'original.mp3', file);
console.log('encoding file...');
encoding = true;
await ffmpeg.run(
'-i', 'original.mp3',
'-i', 'cover.jpg',
'-map', '0',
'-map', '1:v',
'-ar', '44100',
'-b:a', '320k',
'-disposition:v:1', 'attached_pic',
'out.mp3'
);
encoding = false;
console.log('file encoded...');
const fileBuffer = await ffmpeg.FS('readFile', 'out.mp3');
const fileBlob = new Blob([fileBuffer]);
const fileBlobURL = URL.createObjectURL(fileBlob);
await ffmpeg.exit();
await ffmpeg.load();
await ffmpeg.FS('writeFile', 'out.mp3', fileBuffer);
console.log('adding metadata...');
const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video')
|| document.querySelector('#bilibiliPlayer .bilibili-player-video video')
|| document.querySelector('#bilibili-player bwp-video')
|| document.querySelector('#bilibili-player video');
await ffmpeg.run(
'-i', 'out.mp3',
'-codec', 'copy',
'-t', `${videoElement.duration}`,
'-metadata', `title=${title}`,
'-metadata', `artist=${author}`,
'-metadata', `publisher=https://${window.location.hostname + window.location.pathname}`,
'outWithMetadata.mp3'
);
const { buffer: encodedBuffer } = ffmpeg.FS('readFile', 'outWithMetadata.mp3');
const audioBlob = new Blob([encodedBuffer], {type: mimeType});
const audioUrl = URL.createObjectURL(audioBlob);
await download(audioUrl, filenameItem.textInput.value);
} catch (err) {
console.error("Failed: ", err);
} finally {
downloadButton.textContent = strings.download.idle;
downloadButton.disabled = false;
downloadButton.style.cursor = 'pointer';
}
});
const downloadLyricsButton = downloadButton.cloneNode();
downloadLyricsButton.className = "bi-btn";
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') || document.querySelector('#bilibili-player');
if (bilibiliPlayer) {
const pluginBox = buildPluginElement();
bilibiliPlayer.appendChild(pluginBox);
}
})();