BiliBili自动添加视频收藏
// ==UserScript==
// @name BiliBili自动添加视频收藏
// @name:en Bilibili Auto Add Favorites
// @description 进入视频页面后, 自动添加视频到收藏夹中.
// @version 0.6.0
// @author Yiero
// @namespace https://github.com/AliubYiero/TamperMonkeyScripts
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_info
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/s/video/*
// @match https://www.bilibili.com/bangumi/play/*
// @run-at document-start
// @license GPL-3
// @connect api.bilibili.com
// ==/UserScript==
/* ==UserConfig==
配置项:
favouriteTitle:
title: 指定收藏夹标题
description: 更改指定收藏夹标题
type: text
default: fun
==/UserConfig== */
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
/*
* @module : @yiero/gmlib
* @author : Yiero
* @version : 0.1.12
* @description : GM Lib for Tampermonkey
* @keywords : tampermonkey, lib, scriptcat, utils
* @license : MIT
* @repository : git+https://github.com/AliubYiero/GmLib.git
*/
var __defProp2 = Object.defineProperty;
var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField2 = (obj, key, value) => __defNormalProp2(obj, key + "", value);
const environmentTest = () => {
return GM_info.scriptHandler;
};
function getCookie(content, key) {
const isTextCookie = [/^\w+=[^=;]+$/, /^\w+=[^=;]+;/].some((reg) => reg.test(content));
if (isTextCookie) {
const cookieList = content.split(/;\s?/).map((cookie) => cookie.split("="));
const cookieMap = new Map(cookieList);
const cookieValue = cookieMap.get(key);
if (!cookieValue) {
return Promise.reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, key \u4E0D\u5B58\u5728."));
}
return Promise.resolve(cookieValue);
}
return new Promise((resolve, reject) => {
const env = environmentTest();
if (env !== "ScriptCat") {
reject(`\u5F53\u524D\u811A\u672C\u4E0D\u652F\u6301 ${env} \u73AF\u5883, \u4EC5\u652F\u6301 ScriptCat .`);
}
GM_cookie("list", {
domain: content
}, (cookieList) => {
if (!cookieList) {
reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, \u8BE5\u57DF\u540D\u4E0B\u6CA1\u6709 cookie. "));
return;
}
const userIdCookie = cookieList.find(
(cookie) => cookie.name === key
);
if (!userIdCookie) {
reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, key \u4E0D\u5B58\u5728. "));
return;
}
resolve(userIdCookie.value);
});
});
}
const parseResponseText = (responseText) => {
try {
return JSON.parse(responseText);
} catch (e) {
try {
const domParser = new DOMParser();
return domParser.parseFromString(responseText, "text/html");
} catch (e2) {
return responseText;
}
}
};
function gmRequest(param1, method, body, GMXmlHttpRequestConfig) {
const unifiedParameters = () => {
if (typeof param1 !== "string") {
return {
url: param1.url,
method: param1.method || "GET",
param: param1.method === "POST" ? param1.data : void 0,
GMXmlHttpRequestConfig: param1
};
}
return {
url: param1,
method,
param: body,
GMXmlHttpRequestConfig: {}
};
};
const params = unifiedParameters();
if (params.method === "GET" && params.param && typeof params.param === "object") {
params.url = `${params.url}?${new URLSearchParams(params.param).toString()}`;
}
if (params.method === "POST" && params.param) {
params.GMXmlHttpRequestConfig.data = JSON.stringify(params.param);
}
return new Promise((resolve, reject) => {
const newGMXmlHttpRequestConfig = {
// 默认20s的超时等待
timeout: 2e4,
// 请求地址, 请求方法和请求返回
url: params.url,
method: params.method,
onload(response) {
resolve(parseResponseText(response.responseText));
},
onerror(error) {
reject(error);
},
ontimeout() {
reject(new Error("Request timed out"));
},
headers: {
"Content-Type": "application/json"
},
// 用户自定义的油猴配置项
...params.GMXmlHttpRequestConfig
};
GM_xmlhttpRequest(newGMXmlHttpRequestConfig);
});
}
const returnElement = (selector, options, resolve, reject) => {
setTimeout(() => {
const element = options.parent.querySelector(selector);
if (!element) {
reject(new Error("Void Element"));
return;
}
resolve(element);
}, options.delayPerSecond * 1e3);
};
const getElementByTimer = (selector, options, resolve, reject) => {
const intervalDelay = 100;
let intervalCounter = 0;
const maxIntervalCounter = Math.ceil(options.timeoutPerSecond * 1e3 / intervalDelay);
const timer = window.setInterval(() => {
if (++intervalCounter > maxIntervalCounter) {
clearInterval(timer);
returnElement(selector, options, resolve, reject);
return;
}
const element = options.parent.querySelector(selector);
if (element) {
clearInterval(timer);
returnElement(selector, options, resolve, reject);
}
}, intervalDelay);
};
const getElementByMutationObserver = (selector, options, resolve, reject) => {
const timer = options.timeoutPerSecond && window.setTimeout(() => {
observer.disconnect();
returnElement(selector, options, resolve, reject);
}, options.timeoutPerSecond * 1e3);
const observeElementCallback = (mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((addNode) => {
if (addNode.nodeType !== Node.ELEMENT_NODE) {
return;
}
const addedElement = addNode;
const element = addedElement.matches(selector) ? addedElement : addedElement.querySelector(selector);
if (element) {
timer && clearTimeout(timer);
returnElement(selector, options, resolve, reject);
}
});
});
};
const observer = new MutationObserver(observeElementCallback);
observer.observe(options.parent, {
subtree: true,
childList: true
});
return true;
};
function elementWaiter(selector, options) {
const elementWaiterOptions = {
parent: document,
timeoutPerSecond: 20,
delayPerSecond: 0.5,
...options
};
return new Promise((resolve, reject) => {
const targetElement = elementWaiterOptions.parent.querySelector(selector);
if (targetElement) {
returnElement(selector, elementWaiterOptions, resolve, reject);
return;
}
if (MutationObserver) {
getElementByMutationObserver(selector, elementWaiterOptions, resolve, reject);
return;
}
getElementByTimer(selector, elementWaiterOptions, resolve, reject);
});
}
class GmStorage {
constructor(key, defaultValue) {
__publicField2(this, "listenerId", 0);
this.key = key;
this.defaultValue = defaultValue;
this.key = key;
this.defaultValue = defaultValue;
}
/**
* 获取当前存储的值
*
* @alias get()
*/
get value() {
return this.get();
}
/**
* 获取当前存储的值
*/
get() {
return GM_getValue(this.key, this.defaultValue);
}
/**
* 给当前存储设置一个新值
*/
set(value) {
return GM_setValue(this.key, value);
}
/**
* 移除当前键
*/
remove() {
GM_deleteValue(this.key);
}
/**
* 监听元素更新, 同时只能存在 1 个监听器
*/
updateListener(callback) {
this.removeListener();
this.listenerId = GM_addValueChangeListener(this.key, (key, oldValue, newValue, remote) => {
callback({
key,
oldValue,
newValue,
remote
});
});
}
/**
* 移除元素更新回调
*/
removeListener() {
GM_removeValueChangeListener(this.listenerId);
}
}
const getUserUid = async () => {
const uid = await getCookie(document.cookie, "DedeUserID");
if (!uid) {
return Promise.reject("\u7528\u6237\u672A\u767B\u5F55");
}
return Promise.resolve(uid);
};
const codeConfig = {
XOR_CODE: 23442827791579n,
MASK_CODE: 2251799813685247n,
MAX_AID: 1n << 51n,
BASE: 58n,
data: "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf"
};
function bvToAv(bvid) {
const { MASK_CODE, XOR_CODE, data, BASE } = codeConfig;
const bvidArr = Array.from(bvid);
[bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]];
[bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]];
bvidArr.splice(0, 3);
const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)), 0n);
return Number(tmp & MASK_CODE ^ XOR_CODE);
}
async function freshListenerPushState(callback, delayPerSecond = 1) {
let _pushState = window.history.pushState.bind(window.history);
window.history.pushState = function() {
setTimeout(callback, delayPerSecond * 1e3);
return _pushState.apply(this, arguments);
};
}
const requestConfig = {
baseURL: "https://api.bilibili.com",
csrf: new URLSearchParams(document.cookie.split("; ").join("&")).get("bili_jct") || ""
};
const xhrRequest = (url, method, data) => {
if (!url.startsWith("http")) {
url = requestConfig.baseURL + url;
}
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
return new Promise((resolve, reject) => {
xhr.addEventListener("load", () => {
const response = JSON.parse(xhr.response);
if (response.code !== 0) {
return reject(response.message);
}
return resolve(response);
});
xhr.addEventListener("error", () => reject(xhr.status));
xhr.send(new URLSearchParams(data));
});
};
const getVideoEpId = async () => {
let urlPathNameList = new URL(window.location.href).pathname.split("/");
let videoId = urlPathNameList.find(
(urlPathName) => urlPathName.startsWith("ep") || urlPathName.startsWith("ss")
);
if (!videoId) return void 0;
if (videoId.startsWith("ss")) {
const linkNode = await elementWaiter('link[rel="canonical"]', { parent: document });
if (!linkNode) return void 0;
urlPathNameList = new URL(linkNode.href).pathname.split("/");
videoId = urlPathNameList.find((urlPathName) => urlPathName.startsWith("ep"));
if (!videoId) return void 0;
}
videoId = videoId.slice(2);
return videoId;
};
const api_collectVideoToFavorite = async (videoId, favoriteId) => {
const epId = await getVideoEpId();
const formData = {
rid: videoId,
type: epId ? "42" : "2",
add_media_ids: favoriteId,
csrf: requestConfig.csrf
};
return xhrRequest(
"/x/v3/fav/resource/deal",
"POST",
formData
);
};
const api_createFavorites = (favTitle) => {
return xhrRequest("/x/v3/fav/folder/add", "POST", {
// 视频标题
title: favTitle,
// 默认私密收藏夹
privacy: 1,
// csrf
csrf: requestConfig.csrf
});
};
function request(url, method = "GET", paramOrData, GMXmlHttpRequestConfig = {}) {
if (!url.startsWith("http")) {
url = requestConfig.baseURL + url;
}
if (paramOrData && method === "GET") {
url += "?" + new URLSearchParams(paramOrData).toString();
} else if (paramOrData && method === "POST") {
GMXmlHttpRequestConfig.data = JSON.stringify(paramOrData);
}
return new Promise((resolve, reject) => {
const newGMXmlHttpRequestConfig = {
// 默认20s的超时等待
timeout: 2e4,
// 用户自定义的油猴配置项
...GMXmlHttpRequestConfig,
// 请求地址, 请求方法和请求返回,权重最高
url,
method,
onload(response) {
resolve(JSON.parse(response.responseText));
},
onerror(error) {
reject(error);
},
ontimeout() {
reject(new Error("Request timed out"));
}
};
GM_xmlhttpRequest(newGMXmlHttpRequestConfig);
});
}
const api_listAllFavorites = async (upUid) => {
const res = await request("/x/v3/fav/folder/created/list-all", "GET", {
up_mid: upUid
});
if (res.code !== 0) {
throw new Error(res.message);
}
return res.data.list;
};
const api_sortFavorites = async (favoriteIdList) => {
return xhrRequest("/x/v3/fav/folder/sort", "POST", {
sort: favoriteIdList.toString(),
csrf: requestConfig.csrf
});
};
const favoriteTitleStorage = new GmStorage("\u914D\u7F6E\u9879.favouriteTitle", "fun");
class ReadFavouriteList {
/**
* 获取用户用于收藏已看视频的收藏夹目录
* */
static async get(userUid) {
if (!this.favoriteList) {
this.favoriteList = await api_listAllFavorites(userUid);
}
const favoriteList = this.favoriteList;
const favouriteTitle = favoriteTitleStorage.get();
const readFavouriteList = favoriteList.filter(
(favoriteInfo) => {
return favoriteInfo.title.trim().match(new RegExp(`^${favouriteTitle}\\d*$`));
}
);
readFavouriteList.sort((a, b) => {
const aIndex = Number(a.title.slice(favouriteTitle.length));
const bIndex = Number(b.title.slice(favouriteTitle.length));
return bIndex - aIndex;
});
return readFavouriteList;
}
}
__publicField(ReadFavouriteList, "favoriteList");
const registerMenu = () => {
const menuList = [{
title: "\u8BBE\u7F6E\u6536\u85CF\u5939\u6807\u9898",
onClick: () => {
const title = prompt(
`\u8BF7\u8F93\u5165\u6536\u85CF\u5939\u6807\u9898 (\u9ED8\u8BA4\u4F7F\u7528 'fun'\u4F5C\u4E3A\u6536\u85CF\u5939\u6807\u9898)
${favoriteTitleStorage.get()}`
);
if (!title) {
return;
}
favoriteTitleStorage.set(title);
}
}];
menuList.forEach((menuInfo) => {
const { title, onClick } = menuInfo;
GM_registerMenuCommand(title, onClick);
});
};
const config = {
MAX_MEDIA_COUNT: 1e3
};
const checkFavoriteIsFull = (favoriteInfo) => {
if (favoriteTitleStorage.get() === "\u9ED8\u8BA4\u6536\u85CF\u5939") {
return false;
}
return favoriteInfo.media_count >= config.MAX_MEDIA_COUNT;
};
const api_getEpInfo = async (epId) => {
const response = await gmRequest("https://api.bilibili.com/pgc/view/web/season", "GET", {
ep_id: epId
});
const episode = response.result.episodes.find((item) => item.id === Number(epId));
if (!episode) return Promise.reject("\u83B7\u53D6\u756A\u5267\u4FE1\u606F\u5931\u8D25");
return episode;
};
const getVideoAvId = async () => {
const urlPathNameList = new URL(window.location.href).pathname.split("/");
let videoId = urlPathNameList.find(
(urlPathName) => urlPathName.startsWith("BV1") || urlPathName.startsWith("av") || urlPathName.startsWith("ep") || urlPathName.startsWith("ss")
);
if (!videoId) {
throw new Error("\u6CA1\u6709\u83B7\u53D6\u5230\u89C6\u9891id");
}
if (videoId.startsWith("BV1")) {
videoId = String(bvToAv(videoId));
}
if (videoId.startsWith("av")) {
videoId = videoId.slice(2);
}
if (videoId.startsWith("ep") || videoId.startsWith("ss")) {
const epId = await getVideoEpId();
if (!epId) throw new Error("\u6CA1\u6709\u83B7\u53D6\u5230\u89C6\u9891id");
const epInfo = await api_getEpInfo(epId);
videoId = String(epInfo.aid);
}
return videoId;
};
const addVideoToFavorite = async (videoId, latestFavorite) => {
const latestFavoriteId = String(latestFavorite.id);
const res = await api_collectVideoToFavorite(videoId, latestFavoriteId);
const successfullyAdd = res.data.success_num === 0;
if (!successfullyAdd) {
return;
}
/* @__PURE__ */ (() => {
})(`\u5F53\u524D\u89C6\u9891\u5DF2\u6DFB\u52A0\u81F3\u6536\u85CF\u5939 [${latestFavorite.title}]`);
};
const createNewFavorite = (title) => {
return api_createFavorites(title);
};
const sortOlderFavoritesToLast = async (userUid) => {
const favoriteList = await ReadFavouriteList.get(userUid);
const favoriteTitle = favoriteTitleStorage.get();
let readFavouriteList = favoriteList.filter((favoriteInfo) => {
return favoriteInfo.title.match(new RegExp(`^${favoriteTitle}\\d*$`));
});
let otherFavouriteList = favoriteList.filter((favoriteInfo) => {
return !favoriteInfo.title.match(new RegExp(`^${favoriteTitle}\\d*$`));
});
const getFavoriteIndex = (favoriteInfo) => Number(favoriteInfo.title.slice(favoriteTitle.length) || 0);
const readFavouriteIndexList = readFavouriteList.map(getFavoriteIndex);
readFavouriteList.sort((a, b) => {
const aIndex = getFavoriteIndex(a);
const bIndex = getFavoriteIndex(b);
return bIndex - aIndex;
});
const sortedReadFavouriteIndexList = readFavouriteList.map(getFavoriteIndex);
if (JSON.stringify(sortedReadFavouriteIndexList) === JSON.stringify(readFavouriteIndexList)) {
return;
}
if (favoriteTitleStorage.get() !== "\u9ED8\u8BA4\u6536\u85CF\u5939" && readFavouriteList[0].media_count >= config.MAX_MEDIA_COUNT) {
throw new Error("The latest favorite folder is full.");
}
const latestFavourite = readFavouriteList[0];
readFavouriteList = readFavouriteList.slice(1);
const defaultFavourite = otherFavouriteList[0];
otherFavouriteList = otherFavouriteList.slice(1);
const newFavoriteIdList = [
defaultFavourite,
latestFavourite,
...otherFavouriteList,
...readFavouriteList
].map((info) => info.id);
await api_sortFavorites(newFavoriteIdList);
};
const api_isFavorVideo = async () => {
const aid = await getVideoAvId();
const res = await gmRequest("https://api.bilibili.com/x/v2/fav/video/favoured", "GET", {
aid
});
if (res.code !== 0) {
throw new Error(res.message);
}
return res.data.favoured;
};
function getElement(parent, selector, timeout = 0) {
return new Promise((resolve) => {
let result = parent.querySelector(selector);
if (result) return resolve(result);
let timer;
const mutationObserver = window.MutationObserver || window.WebkitMutationObserver || window.MozMutationObserver;
if (mutationObserver) {
const observer = new mutationObserver((mutations) => {
for (let mutation of mutations) {
for (let addedNode of mutation.addedNodes) {
if (addedNode instanceof Element) {
result = addedNode.matches(selector) ? addedNode : addedNode.querySelector(selector);
if (result) {
observer.disconnect();
timer && clearTimeout(timer);
setTimeout(() => resolve(result), 300);
}
}
}
}
});
observer.observe(parent, {
childList: true,
subtree: true
});
if (timeout > 0) {
timer = setTimeout(() => {
observer.disconnect();
return resolve(null);
}, timeout);
}
} else {
const listener = (e) => {
if (e.target instanceof Element) {
result = e.target.matches(selector) ? e.target : e.target.querySelector(selector);
if (result) {
parent.removeEventListener("DOMNodeInserted", listener, true);
timer && clearTimeout(timer);
return resolve(result);
}
}
};
parent.addEventListener("DOMNodeInserted", listener, true);
if (timeout > 0) {
timer = setTimeout(() => {
parent.removeEventListener("DOMNodeInserted", listener, true);
return resolve(null);
}, timeout);
}
}
});
}
const sleep = (milliseconds) => {
return new Promise((res) => setTimeout(res, milliseconds));
};
const autoAddVideoToFavourites = async () => {
let isFavorVideo = await api_isFavorVideo();
if (isFavorVideo) {
/* @__PURE__ */ (() => {
})("\u5F53\u524D\u89C6\u9891\u5DF2\u7ECF\u88AB\u6536\u85CF:", isFavorVideo, `av${await getVideoAvId()}`);
return;
}
const userUid = await getUserUid();
const readFavouriteList = await ReadFavouriteList.get(userUid);
if (!readFavouriteList.length) {
const favoriteTitle = favoriteTitleStorage.get();
await createNewFavorite(favoriteTitle + "1");
await autoAddVideoToFavourites();
return;
}
const videoId = await getVideoAvId();
const latestFavourite = readFavouriteList[0];
const isFullInFavorite = checkFavoriteIsFull(readFavouriteList[0]);
if (!isFullInFavorite) {
await addVideoToFavorite(videoId, latestFavourite);
await sortOlderFavoritesToLast(userUid);
}
if (isFullInFavorite) {
const favoriteTitle = favoriteTitleStorage.get();
const latestFavouriteId = Number(latestFavourite.title.slice(favoriteTitle.length));
await createNewFavorite(`${favoriteTitle}${latestFavouriteId + 1}`);
await sleep(1e3);
await sortOlderFavoritesToLast(userUid);
await autoAddVideoToFavourites();
return;
}
isFavorVideo = await getVideoEpId() ? true : await api_isFavorVideo();
if (!isFavorVideo) {
throw new Error("\u6536\u85CF\u5931\u8D25");
}
const favButtonSelector = ".video-fav.video-toolbar-left-item:not(.on)";
const favButtonDom = await getElement(document, favButtonSelector);
if (favButtonDom) {
favButtonDom.classList.add("on");
}
};
const isScriptCatEnvironment = () => {
return GM_info.scriptHandler === "ScriptCat";
};
(async () => {
!isScriptCatEnvironment() && registerMenu();
try {
await autoAddVideoToFavourites();
} catch (e) {
console.error(e);
}
await freshListenerPushState(async () => {
try {
await autoAddVideoToFavourites();
} catch (e) {
console.error(e);
}
}, 5);
})();