您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Make timeline easier to use
当前为
// ==UserScript== // @name Chub.AI Timeline Improvements // @description Make timeline easier to use // @match https://chub.ai/* // @grant GM.setValue // @grant GM.getValue // @grant GM_registerMenuCommand // @version 2025.09.13a // @author anden3 // @license MIT // @require https://cdn.jsdelivr.net/npm/@violentmonkey/url#sha384-MW/Hes7CLT6ZD4zwzTUVdtXL/VaIDQ3uMFVuOx46Q0xILNG6vEueFrCaYNKw+YE3 // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @namespace https://greasyfork.org/users/1499640 // ==/UserScript== /* globals VM */ /* jshint esversion: 11 */ let API_KEY = "YOUR API KEY HERE"; const HIDE_RECOMMENDED_CARDS = false; const API_BASE = "https://inference.chub.ai/api"; const INTERNAL_API_BASE = "https://gateway.chub.ai"; const HIDDEN_CARDS_KEY = "hiddenCards"; (async function() { 'use strict'; // Acquire API key. First check localStorage, next cache, and last the hardcoded value. const localStorageAPIKey = localStorage.getItem("URQL_TOKEN"); if (localStorageAPIKey !== null) { API_KEY = localStorageAPIKey; await GM.setValue("API_KEY", API_KEY); } else { const possiblyCachedAPIKey = await GM.getValue("API_KEY", null) ?? null; if (possiblyCachedAPIKey !== null) { API_KEY = possiblyCachedAPIKey; } else if (API_KEY !== "YOUR API KEY HERE") { await GM.setValue("API_KEY", API_KEY); } else { throw new Error("No API key could be located, please log in."); } } const { onNavigate } = VM; async function queryApi(url, queryParams = {}, method = "GET") { const searchParams = new URLSearchParams(queryParams); console.log(`${url}?${searchParams}`); const response = await fetch(`${url}?${searchParams}`, { method, headers: { "Content-Type": "application/json", "CH-API-KEY": API_KEY, }, }); if (!response.ok) { throw new Error(`Response status: ${response.status}`); } const result = await response.json(); if (result.error !== undefined) { console.error(`API Query failed: ${result}`); throw new Error(result); } return result; } async function queryPublicApi(endpoint, queryParams = {}, method = "GET") { return await queryApi(`${API_BASE}/${endpoint}`, queryParams, method); } async function queryInternalApi(endpoint, queryParams = {}, method = "GET") { return await queryApi(`${INTERNAL_API_BASE}/${endpoint}`, queryParams, method); } async function getProfile() { return await queryInternalApi("api/account"); } async function fetchCharacter(author, character) { const data = await queryInternalApi(`api/characters/${author}/${character}`, { full: true }); return data?.node ?? null; } async function fetchTimeline(page) { const cards = await queryInternalApi("api/timeline/v1", { page, count: false }); return cards.data.nodes.map(card => { const author = card.fullPath.split("/")[0]; return { ...card, author, }; }); } async function fetchSearchResults(searchParams, extraKeys = {}) { const items = await queryInternalApi("search", searchParams, "POST"); return items.data.nodes.map(card => { return { ...card, ...extraKeys, }; }); } async function fetchAuthorCards(author, page) { const PAGE_SIZE = 50; return await fetchSearchResults({ page, username: author, first: PAGE_SIZE, namespace: 'characters', nsfw: true, nsfl: true, chub: true, count: true, exclude_mine: true, include_forks: true, sort: 'created_at', min_tokens: 0, }, { author }); } async function fetchFavorites() { return await queryPublicApi("favorites"); } async function fetchFollows() { const profile = await getProfile(); if (profile.user_name === "You") { alert("The API key provided to this userscript (Chub.AI Timeline Improvements) is invalid.\nDisable this userscript if this alert is annoying."); throw new Error("API key failed to authenticate user, most likely invalid."); } let follows = { users: [], tags: [], }; let page = 1; while (true) { let followData = await queryInternalApi(`api/follows/${profile.user_name}`, { page: page }); if (followData.follows.length + followData.tag_follows.length === 0) { break; } follows.users.push(...followData.follows); follows.tags.push(...followData.tag_follows); page += 1; // Sleep await new Promise(r => setTimeout(r, 1000)); } return follows; } async function getVisibleCards() { // Wait for cards to exist. try { await waitForElement("#chara-list", 5000); } catch { return []; } return Array.from(document.querySelectorAll("#chara-list > a")) .map(node => { const path = node.attributes.href.value; const forkUrl = node.querySelector("a:has(.anticon-fork)")?.attributes?.href?.value ?? null; const fork = forkUrl?.replace(/^\/characters\//, "") ?? null; return { node, path, fork, }; }); } function modifyCard(element, isFavorited, forkOfFave, isNew, isHidden, highlightUser, tagsToHighlight, onHideButtonClick, onHiddenCardClick) { const cardBody = element.querySelector(".ant-card-body"); // Add hide button. const moreButton = element.querySelector("button:has(span.anticon-more)"); const buttonRow = moreButton.parentElement; let hideButton = document.createElement("button"); hideButton.style = moreButton.style; hideButton.type = "button"; hideButton.classList = moreButton.classList; hideButton.innerText = "🗑"; moreButton.style.display = "none"; buttonRow.appendChild(hideButton); hideButton.addEventListener("click", evt => { evt.stopPropagation(); evt.preventDefault(); cardBody.style.display = "none"; isHidden = true; onHideButtonClick(); }); element.addEventListener("click", evt => { if (isHidden) { evt.stopImmediatePropagation(); evt.stopPropagation(); evt.preventDefault(); cardBody.style.display = "unset"; isHidden = false; onHiddenCardClick(); } }); if (isFavorited) { element.style.setProperty("border-top", "thick solid pink", "important"); isHidden = true; } if (isHidden) { cardBody.style.display = "none"; } if (!isNew) { element.style.opacity = "0.5"; } if (highlightUser) { const userLink = element.querySelector(".ant-row > p > a"); userLink.style.color = "cyan"; } if (forkOfFave) { const fork = element.querySelector("a:has(.anticon-fork)"); fork.style.color = "cyan"; } if (tagsToHighlight.length > 0) { const lowercaseTags = tagsToHighlight.map(tag => tag.toLowerCase()); let tagsNotInContainer = tagsToHighlight; const tagContainer = element.querySelector(".ant-row > div:has(> span.cursor-pointer > .ant-tag)"); const tags = Array.from( tagContainer.querySelectorAll("span.cursor-pointer > .ant-tag > span:first-child") ); if (tags.length === 0) { // Need to create our own tag elements. return; } const tagToCopy = tags[0].parentElement.parentElement; const existingTags = new Map(tags.map(tag => [tag.textContent.toLowerCase(), [tag.parentElement.parentElement, tag]])); let tagElementsToHighlight = []; for (let i = tagsToHighlight.length - 1; i >= 0; i--) { if (existingTags.has(lowercaseTags[i])) { tagsNotInContainer.splice(i, 1); const [tagElement, tagTextElement] = existingTags.get(lowercaseTags[i]); tagElementsToHighlight.push([tagElement, tagTextElement]); } else { const clonedTag = tagToCopy.cloneNode(true); const clonedTagText = clonedTag.querySelector(".ant-tag > span:first-child"); clonedTagText.textContent = tagsToHighlight[i]; tagElementsToHighlight.push([clonedTag, clonedTagText]); } } tagElementsToHighlight.sort((a, b) => a[1].textContent.toLowerCase() < b[1].textContent.toLowerCase() ? -1 : 1); for (let i = tagElementsToHighlight.length - 1; i >= 0; i--) { const [tagElement, tagTextElement] = tagElementsToHighlight[i]; tagTextElement.style.color = "cyan"; tagContainer.insertBefore(tagElement, tagContainer.children[0]); } } } async function modifyCharacterPage(author, rating, highlightDiscussion, hideGallery, publicChatCount, forkCount, highlightUser, tagsToHighlight) { let element; try { element = await waitForElement(".ant-row-center", 5000); } catch { console.error("no character elements found"); return; } if (HIDE_RECOMMENDED_CARDS) { document.querySelector("#chara-list").style.display = "none"; } const headers = element.querySelectorAll(".ant-collapse-header-text"); for (const header of headers) { switch (header.textContent) { case "Discussion": if (highlightDiscussion) { header.style.color = "cyan"; header.appendChild(document.createTextNode(` - Rating ${rating.toFixed(1)}/5.0`)); } break; case "Shared public chats": if (publicChatCount == 0) { header.parentElement.style.display = "none"; } else { header.appendChild(document.createTextNode(` - ${publicChatCount}`)); } break; case "Gallery": if (hideGallery) { header.parentElement.style.display = "none"; } break; case "Forks": if (forkCount == 0) { header.parentElement.style.display = "none"; } else { header.appendChild(document.createTextNode(` - ${forkCount}`)); } break; default: break; } } const authorLink = element.querySelector(`i > a[href="/users/${author}"]`); if (authorLink) { authorLink.style.color = "cyan"; } const existingTags = new Map( Array.from(element.querySelectorAll(".ant-tag")) .map(tag => [tag.textContent.toLowerCase(), tag]) ); for (const followedTag of tagsToHighlight.map(tag => tag.toLowerCase())) { const matchingTagElement = existingTags.get(followedTag); if (matchingTagElement !== undefined) { matchingTagElement.style.color = "cyan"; } } } async function cacheIsStale(key, thresholdMs = 60 * 1000) { const lastCacheUpdate = await GM.getValue(key, null); if (lastCacheUpdate === null) { return true; } const msSinceUpdate = Math.max(new Date() - new Date(lastCacheUpdate), 0); return msSinceUpdate >= thresholdMs; } const DEFAULT_CACHE_OPTIONS = { staleThresholdMs: 60 * 1000, mappingFn: foo => foo, }; // Tries from cache if fresh, else fetches values. async function getCachedCollection( cacheKey, fetchFn, cacheOptions, lastUpdateKey = `last${cacheKey}Update`, ) { const cache_options = { ...DEFAULT_CACHE_OPTIONS, ...cacheOptions, }; const _cmd = GM_registerMenuCommand(`Update ${cacheKey}`, async _ev => { const values = cache_options.mappingFn(await fetchFn); await GM.setValue(cacheKey, values); await GM.setValue(lastUpdateKey, new Date().toISOString()); }, { title: `Update Chub.AI ${cacheKey} cache` }); const cachedValues = await GM.getValue(cacheKey, null) ?? null; if (cachedValues === null || await cacheIsStale(lastUpdateKey, cache_options.staleThresholdMs)) { const values = cache_options.mappingFn(await fetchFn()); await GM.setValue(cacheKey, values); await GM.setValue(lastUpdateKey, new Date().toISOString()); return values; } else { return cachedValues; } } async function getFavorites() { const favorites = await getCachedCollection( "favorites", fetchFavorites, { mappingFn: faves => faves.nodes }, "lastFavoriteUpdate" ); return { ids: new Set(favorites.map(fave => fave.id)), paths: new Set(favorites.map(fave => fave.fullPath)), }; } async function getFollows() { let followsData = await getCachedCollection("follows", fetchFollows, { staleThresholdMs: 60 * 60 * 1000 }); return { users: new Set(followsData.users.map(u => u.username)), tags: new Set(followsData.tags.map(t => t.tagname.toLowerCase())), }; } async function getHidden() { const hiddenCards = await GM.getValue(HIDDEN_CARDS_KEY, []) ?? []; return new Set(hiddenCards); } async function saveHidden(hiddenCards) { const hiddenCardsArray = Array.from(hiddenCards); await GM.setValue(HIDDEN_CARDS_KEY, hiddenCardsArray); } async function isTimelineSelected() { await new Promise(r => setTimeout(r, 2000)); let selectedItem; // Wait for labels to exist. try { selectedItem = await waitForElement(".ant-select-selection-item", 2000); } catch { return false; } const selectedCategory = selectedItem?.title ?? ""; return selectedCategory === "Timeline"; } async function getCurrentListPage() { let activePage; try { activePage = await waitForElement(".ant-pagination-item-active", 2000); } catch { throw new Error("could not find active page"); } return parseInt(activePage.title); } async function characterListLoaded(dataFn) { const [favorites, follows, currentHiddenCards, cards, cardElements] = await Promise.all([getFavorites(), getFollows(), getHidden(), dataFn, getVisibleCards()]); let hiddenCards = currentHiddenCards; // Match up card with element. const cardsWithElements = new Map(); const elements = new Map( cardElements .filter(e => e !== null && e !== undefined) .map(e => [e.path.split('/')[3], e]) ); for (const card of cards) { if (card === null || card === undefined) { continue; } const cardName = card.fullPath.split('/')[1]; const foundElement = elements.get(cardName); if (foundElement !== undefined) { elements.delete(cardName); cardsWithElements.set(card, foundElement); } } for (const [card, element] of cardsWithElements) { const msSinceUpdate = new Date(card.lastActivityAt) - new Date(card.createdAt); const hoursSinceUpdate = msSinceUpdate / (1000 * 60 * 60); const followedTags = card.topics.filter(tag => follows.tags.has(tag.toLowerCase())); const followingUser = follows.users.has(card.author); const isFavorited = favorites.ids.has(card.id); const forkOfFave = (element?.fork !== null) ? favorites.paths.has(element.fork) : false; const isNew = hoursSinceUpdate <= 24; const isHidden = hiddenCards.has(card.id); try { modifyCard(element.node, isFavorited, forkOfFave, isNew, isHidden, followingUser, followedTags, () => { if (!isFavorited) { hiddenCards.add(card.id); saveHidden(hiddenCards); } }, () => { hiddenCards.delete(card.id); saveHidden(hiddenCards); }); } catch (e) { console.error(`failed to modify card: ${e}`); } } } async function timelineLoaded(page) { await characterListLoaded(fetchTimeline(page)); } async function userLoaded(username) { async function userListChanged() { const currentPage = await getCurrentListPage(); await characterListLoaded(fetchAuthorCards(username, currentPage)); } let charList; try { charList = await waitForElement("#chara-list", 5000); } catch { return; } userListChanged(); while (true) { try { await waitForListRehydration(charList); userListChanged(); } catch { break; } } } async function characterLoaded(author, char) { const [favorites, follows, charData] = await Promise.all([getFavorites(), getFollows(), fetchCharacter(author, char)]); const rating = charData.rating; const highlightDiscussion = charData.ratingCount > 0; const hideGallery = !charData.hasGallery; const publicChatCount = charData.n_public_chats; const forkCount = charData.forksCount; const followingUser = follows.users.has(author); const followedTags = charData.topics.filter(tag => follows.tags.has(tag.toLowerCase())); await modifyCharacterPage(author, rating, highlightDiscussion, hideGallery, publicChatCount, forkCount, followingUser, followedTags); } async function tagLoaded(tag, searchParams = null) { let search; if (searchParams !== null) { search = searchParams; } else { search = { topics: tag, page: 1, first: 20, namespace: "*", asc: false, sort: "default", min_tags: 2, include_forks: true, nsfw: true, nsfl: true, chub: true, nsfw_only: false, min_ai_rating: 0, min_tokens: 50, max_tokens: 100000, exclude_mine: false, }; } const BAD_KEYS = new Set([ "special_mode", "name_like", "only_mine", "inclusive_or", "max_days_ago", "min_users_chatted" ]); const OVERRIDES = { exclude_mine: false, } search = Object.keys(search).filter(key => !BAD_KEYS.has(key)).reduce((obj, key) => { obj[key] = search[key]; return obj; }, {}); await characterListLoaded(fetchSearchResults({ ...search, ...OVERRIDES })); } async function handleNavigate() { console.log(window.location); const urlParams = new URLSearchParams(window.location.search); const path = window.location.pathname; // Check if on timeline. if (path == "/") { if ((urlParams.size === 0 && await isTimelineSelected()) || urlParams.get("segment") === "timeline") { timelineLoaded(parseInt(urlParams.get("page") ?? "1")); } else { return; } } // Check if on character page. else if (path.startsWith("/characters/")) { const authorAndChar = path.replace(/^\/characters\//, "").split('/'); characterLoaded(...authorAndChar); } else if (path.startsWith("/users/")) { const username = path.replace(/^\/users\//, ""); userLoaded(username); } else if (path.startsWith("/tags/")) { const tag = path.replace(/^\/tags\//, ""); tagLoaded(tag, (urlParams.size > 0) ? Object.fromEntries(urlParams) : null); } } addEventListener("popstate", (event) => { handleNavigate(); }); // Watch route change VM.onNavigate(handleNavigate); // Call it once for the initial state handleNavigate(); // Source: https://stackoverflow.com/a/61511955 function waitForElement(selector, timeout = -1) { return new Promise((resolve, reject) => { let existingElement = document.querySelector(selector); if (existingElement) { return resolve(existingElement); } let timeoutPid; if (timeout >= 0) { timeoutPid = setTimeout(() => { observer.disconnect(); reject(`Timed out after ${timeout} ms.`); }, timeout); } const observer = new MutationObserver(mutations => { let foundElement = document.querySelector(selector); if (foundElement) { if (timeoutPid) { clearTimeout(timeoutPid); } observer.disconnect(); resolve(foundElement); } }); // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336 observer.observe(document.body, { childList: true, subtree: true }); }); } function waitForListRehydration(element, timeout = -1, debounceMs = 100, onlyDirectChildren = true) { return new Promise((resolve, reject) => { let timeoutPid; if (timeout >= 0) { timeoutPid = setTimeout(() => { observer.disconnect(); reject(`Timed out after ${timeout} ms.`); }, timeout); } const observer = new MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.addedNodes.length > 0) { if (timeoutPid) { clearTimeout(timeoutPid); } timeoutPid = setTimeout(() => { observer.disconnect(); resolve(); }, debounceMs); } } }); observer.observe(element, { childList: true, subtree: !onlyDirectChildren }); }); } })();