Chub.AI Timeline - Followed users only

Only show cards from followed users, not tags

Versión del día 30/07/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Chub.AI Timeline - Followed users only
// @description  Only show cards from followed users, not tags
// @match        https://chub.ai/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_registerMenuCommand
// @version      2025-07-30
// @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 LAST_FOLLOWS_UPDATE_KEY = "lastfollowsUpdate";

(async function() {
  'use strict';

  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 fetchFollows() {
    const profile = await getProfile();
    console.log(profile);

    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,
        }
      });
  }

  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,
    isEmptyFn: values => values.length === 0,
  };

  // 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 cachedValues = await GM.getValue(cacheKey, []) ?? [];

    if (cache_options.isEmptyFn(cachedValues) || 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 getFollows() {
    let followsData = await getCachedCollection("follows", fetchFollows, {
      staleThresholdMs: 5 * 60 * 1000,
      isEmptyFn: follows => follows.users.length + follows.tags.length === 0,
    });

    return {
      users: new Set(followsData.users.map(u => u.username)),
      tags: new Set(followsData.tags.map(t => t.tagname.toLowerCase())),
    }
  }

  async function isTimelineSelected() {
    await new Promise(r => setTimeout(r, 2000));

    // Wait for labels to exist.
    try {
      await waitForElement(".ant-segmented-item-label", 2000);
    } catch {
      return false;
    }

    const selectedLabels = Array.from(document.querySelectorAll("label.ant-segmented-item-selected .ant-segmented-item-label"));

    if (selectedLabels.length === 0) {
      return false;
    }

    return selectedLabels.some(e => e.textContent?.includes("Timeline") ?? false);
  }

  async function timelineLoaded(page) {
    const follows = await getFollows();

    const cards = await fetchTimeline(page);
    const cardElements = await getVisibleCards();

    for (let i in cards) {
      const followingUser = follows.users.has(cards[i].author);

      if (!followingUser) {
        cardElements[i].node.style.display = "none";
      }
    }
  }

  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"));
    }
  }

  // Cache API key so that upgrades aren't a pain.
  if (API_KEY === "YOUR API KEY HERE") {
    const possiblyCachedAPIKey = await GM.getValue("API_KEY", null) ?? null;
    if (possiblyCachedAPIKey !== null) {
      API_KEY = possiblyCachedAPIKey;
    } else {
      throw new Error("No API key has been provided!");
    }
  } else {
    await GM.setValue("API_KEY", API_KEY);
  }

  console.log(API_KEY);

  const updateFollowsCmd = GM_registerMenuCommand("Update Follows", async _ev => {
    const follows = await fetchFollows();
    await GM.setValue("follows", follows);
    await GM.setValue(LAST_FOLLOWS_UPDATE_KEY, new Date().toISOString());
  }, {
    title: "Update Chub.AI follows cache"
  });

  // 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
      });
    });
  }
})();