Chub.AI Timeline - Followed users only

Only show cards from followed users, not tags

От 30.07.2025. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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