您需要先安装一个扩展,例如 篡改猴、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.08.09a // @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 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 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 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 fork = node.querySelector("a:has(.anticon-fork)")?.attributes?.href?.value ?? 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.borderTop = "thick solid pink"; 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 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)); // Wait for labels to exist. try { await waitForElement(".ant-select-selection-item", 2000); } catch { return false; } const selectedCategory = document.querySelector(".ant-select-selection-item")?.title ?? ""; return selectedCategory === "Timeline"; } async function timelineLoaded(page) { const favorites = await getFavorites(); const follows = await getFollows(); let hiddenCards = await getHidden(); const cards = await fetchTimeline(page); const cardElements = await getVisibleCards(); for (let i in cards) { const card = cards[i]; const element = cardElements[i]; 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.remove(card.id); saveHidden(hiddenCards); }); } catch (e) { console.error(`failed to modify card: ${e}`); } } } async function handleNavigate() { console.log(window.location); if (window.location.pathname !== "/") { return; } const urlParams = new URLSearchParams(window.location.search); if ((urlParams.size === 0 && await isTimelineSelected()) || urlParams.get("segment") === "timeline") { timelineLoaded(parseInt(urlParams.get("page") ?? "1")); } } // 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) => { if (document.querySelector(selector)) { return resolve(document.querySelector(selector)); } let timeoutPid; if (timeout >= 0) { timeoutPid = setTimeout(() => { observer.disconnect(); reject(`Timed out after ${timeout} ms.`); }, timeout); } const observer = new MutationObserver(mutations => { if (document.querySelector(selector)) { if (timeoutPid) { clearTimeout(timeoutPid); } observer.disconnect(); resolve(document.querySelector(selector)); } }); // 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 }); }); } })();