// ==UserScript== // @name BiliBili自动添加视频收藏 // @version 0.4.0 // @license GPL-3 // @namespace https://github.com/AliubYiero/TamperMonkeyScripts // @run-at document-start // @author Yiero // @homepage https://github.com/AliubYiero/TamperMonkeyScripts // @description 进入视频页面后, 自动添加视频到收藏夹中. // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/s/video/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_info // @connect api.bilibili.com // @icon https://www.bilibili.com/favicon.ico // ==/UserScript== /* ==UserConfig== 配置项: favouriteTitle: title: 指定收藏夹标题 description: 更改指定收藏夹标题 type: text default: fun userUid: title: 用户uid description: 设置用户uid type: text default: "" ==/UserConfig== */ 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 userUidConfig = { key: "userUid" }; const UserConfigs = { "\u914d\u7f6e\u9879": { favouriteTitle: { title: "\u6307\u5b9a\u6536\u85cf\u5939\u6807\u9898", description: "\u66f4\u6539\u6307\u5b9a\u6536\u85cf\u5939\u6807\u9898", type: "text", default: "fun" }, userUid: { title: "\u7528\u6237uid", description: "\u8bbe\u7f6e\u7528\u6237uid", type: "text", default: "" } } }; class GMStorageExtra extends Storage { constructor() { super(); } static get length() { return this.keys().length; } static getItem(key, defaultValue, group) { (() => {})(this.createKey(key, group)); return GM_getValue(this.createKey(key, group), defaultValue); } static hasItem(key, group) { return Boolean(this.getItem(key, group)); } static setItem(key, value, group) { GM_setValue(this.createKey(key, group), value); } static removeItem(key, group) { GM_deleteValue(this.createKey(key, group)); } static clear() { const keyList = GM_listValues(); for (const key of keyList) { GM_deleteValue(key); } } static key(index) { return this.keys()[index]; } static keys() { return GM_listValues(); } static groups() { const keyList = this.keys(); return keyList.map((key => { const splitKeyList = key.split("."); if (splitKeyList.length === 2) { return splitKeyList[0]; } return ""; })).filter((item => item)); } static createKey(key, group) { if (group) { return `${group}.${key}`; } for (let groupName in UserConfigs) { const configGroup = UserConfigs[groupName]; for (let configKey in configGroup) { if (configKey === key) { return `${groupName}.${key}`; } } } return key; } } const getUserUid = async () => { let userUid = GMStorageExtra.getItem(userUidConfig.key, ""); if (!userUid) { const selector = 'a.header-entry-mini[href^="//space.bilibili.com/"]'; await getElement(document, selector, 6e4); const userDom = document.querySelector(selector); if (!userDom) { return ""; } userUid = new URL(userDom.href).pathname.split("/")[1]; } return Promise.resolve(userUid); }; const setUserUid = uid => { GMStorageExtra.setItem(userUidConfig.key, uid); }; const favouriteTitleConfig = { key: "favouriteTitle", title: "fun" }; const getFavouriteTitle = () => GMStorageExtra.getItem(favouriteTitleConfig.key, favouriteTitleConfig.title); const setFavouriteTitle = title => { GMStorageExtra.setItem(favouriteTitleConfig.key, title); }; const hasFavouriteTitle = () => GMStorageExtra.hasItem(favouriteTitleConfig.key); const codeConfig = { XOR_CODE: 23442827791579n, MASK_CODE: 2251799813685247n, MAX_AID: 1n << 51n, BASE: 58n, data: "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf" }; function bvToAv(bvid) { const {MASK_CODE: MASK_CODE, XOR_CODE: XOR_CODE, data: data, BASE: 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); } const checkScriptCatEnvironment = () => GM_info.scriptHandler === "Tampermonkey"; const sleep = timeoutPerSecond => new Promise((resolve => { setTimeout(resolve, timeoutPerSecond * 1e3); })); 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.data); })); xhr.addEventListener("error", (() => reject(xhr.status))); xhr.send(new URLSearchParams(data)); })); }; const api_collectVideoToFavorite = (videoId, favoriteId) => { const formData = { rid: videoId, type: "2", add_media_ids: favoriteId, csrf: requestConfig.csrf }; return xhrRequest("/x/v3/fav/resource/deal", "POST", formData); }; const api_createFavorites = favTitle => xhrRequest("/x/v3/fav/folder/add", "POST", { title: favTitle, privacy: 1, 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 = { timeout: 2e4, ...GMXmlHttpRequestConfig, url: url, method: 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 => xhrRequest("/x/v3/fav/folder/sort", "POST", { sort: favoriteIdList.toString(), csrf: requestConfig.csrf }); const getReadFavouriteList = async userUid => { const favoriteList = await api_listAllFavorites(userUid); const favouriteTitle = getFavouriteTitle(); const readFavouriteList = favoriteList.filter((favoriteInfo => favoriteInfo.title.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; }; const menuList = [ { title: "\u8f93\u5165\u60a8\u7684uid", onClick: async () => { const uid = prompt("\u8bf7\u8f93\u5165\u60a8\u7684\u7528\u6237uid (\u9ed8\u8ba4\u5c06\u4ece\u9875\u9762\u4e2d\u83b7\u53d6uid)\n\u5982\u679c\u8bbe\u7f6e\u4e86\u7528\u6237uid\u4f1a\u8ba9\u811a\u672c\u54cd\u5e94\u901f\u5ea6\u66f4\u5feb, \u4e0d\u7528\u7b49\u5f85\u9875\u9762\u8f7d\u5165\u83b7\u53d6uid\n(\u5982\u679c\u60a8\u4e0d\u77e5\u9053uid\u662f\u4ec0\u4e48, \u8bf7\u4e0d\u8981\u968f\u610f\u8f93\u5165)\n(\u7528\u6237uid\u662f\u60a8\u7684\u4e3b\u9875\u4e0a\u7f51\u5740\u7684\u4e00\u4e32\u6570\u5b57 'https://space.bilibili.com/')", await getUserUid()); if (!uid) { return; } setUserUid(uid); } }, { title: "\u8bbe\u7f6e\u6536\u85cf\u5939\u6807\u9898", onClick: () => { if (hasFavouriteTitle()) { setFavouriteTitle(getFavouriteTitle()); } const title = prompt("\u8bf7\u8f93\u5165\u6536\u85cf\u5939\u6807\u9898 (\u9ed8\u8ba4\u4f7f\u7528 'fun'\u4f5c\u4e3a\u6536\u85cf\u5939\u6807\u9898)\n", getFavouriteTitle()); if (!title) { return; } setFavouriteTitle(title); } } ]; const registerMenu = () => { menuList.forEach((menuInfo => { const {title: title, onClick: onClick} = menuInfo; GM_registerMenuCommand(title, onClick); })); }; const config = { MAX_MEDIA_COUNT: 1e3 }; const checkFavoriteIsFull = favoriteInfo => favoriteInfo.media_count === config.MAX_MEDIA_COUNT; const getVideoAvId = () => { const urlPathNameList = new URL(window.location.href).pathname.split("/"); let videoId = urlPathNameList.find((urlPathName => urlPathName.startsWith("BV1") || urlPathName.startsWith("av"))); 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); } return videoId; }; const addVideoToFavorite = async (videoId, latestFavorite) => { const latestFavoriteId = String(latestFavorite.id); const res = await api_collectVideoToFavorite(videoId, latestFavoriteId); const successfullyAdd = (res == null ? void 0 : res.success_num) === 0; if (!successfullyAdd) { return; } (() => {})(`\u5f53\u524d\u89c6\u9891\u5df2\u6dfb\u52a0\u81f3\u6536\u85cf\u5939 [${latestFavorite.title}]`); }; const createNewFavorite = title => api_createFavorites(title); const sortOlderFavoritesToLast = async userUid => { const favoriteList = await api_listAllFavorites(userUid); const favoriteTitle = getFavouriteTitle(); let readFavouriteList = favoriteList.filter((favoriteInfo => favoriteInfo.title.startsWith(favoriteTitle))); let otherFavouriteList = favoriteList.filter((favoriteInfo => !favoriteInfo.title.startsWith(favoriteTitle))); readFavouriteList.sort(((a, b) => { const aIndex = Number(a.title.slice(favoriteTitle.length)); const bIndex = Number(b.title.slice(favoriteTitle.length)); return bIndex - aIndex; })); if (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)); api_sortFavorites(newFavoriteIdList); }; const api_isFavorVideo = () => request("/x/v2/fav/video/favoured", "GET", { aid: getVideoAvId() }).then((res => { if (res.code !== 0) { throw new Error(res.message); } return res.data.favoured; })); const autoAddVideoToFavourites = async () => { let isFavorVideo = await api_isFavorVideo(); if (isFavorVideo) { return; } const userUid = await getUserUid(); if (!userUid) { throw new Error("\u83b7\u53d6\u7528\u6237uid\u5931\u8d25"); } const readFavouriteList = await getReadFavouriteList(userUid); if (!readFavouriteList.length) { const favoriteTitle = getFavouriteTitle(); await createNewFavorite(favoriteTitle + "1"); await autoAddVideoToFavourites(); return; } const videoId = getVideoAvId(); const latestFavourite = readFavouriteList[0]; const isFullInFavorite = checkFavoriteIsFull(readFavouriteList[0]); if (!isFullInFavorite) { await addVideoToFavorite(videoId, latestFavourite); } if (isFullInFavorite) { const favoriteTitle = getFavouriteTitle(); const latestFavouriteId = Number(latestFavourite.title.slice(favoriteTitle.length)); await createNewFavorite(`${favoriteTitle}${latestFavouriteId + 1}`); await sleep(1); await sortOlderFavoritesToLast(userUid); await autoAddVideoToFavourites(); return; } isFavorVideo = 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"); } }; !checkScriptCatEnvironment() && registerMenu(); autoAddVideoToFavourites();