Skip to content

Instantly share code, notes, and snippets.

@khanonnie
Last active June 14, 2025 01:18
Show Gist options
  • Save khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75 to your computer and use it in GitHub Desktop.
Save khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75 to your computer and use it in GitHub Desktop.

Chub.ai Desloppifier

What is this?

This script tries to detect and hide extremely low quality cards on Chub to make browsing the Latest page usable again. Supports both chub.ai and characterhub.org.

image

It assigns a "slop score" based on various factors. Some factors increase the score, some factors decrease it.

You can also set up tag rules to assign a score based on the tags on a card (for example, applying a penalty to male, dominant characters but boosting male, femboy ones).

Installation

You need a userscript manager extension like Violentmonkey (Firefox) (Chrome) to use this script.

  1. Install a userscript manager browser extension
  2. Click the link below and choose Install.

https://gist.github.com/khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75/raw/ChubDeslop.user.js

Usage

After installing, it should start working automatically. If you want to change any of the default settings, click the Desloppifier button next to the user menu to open the configuration.

image

When configuring Desloppifier, consider that a higher score means more sloppy. Negative scores mean less sloppy. Assign your favorite tag combinations a negative score to make sure they're not marked as slop, and tags you hate a positive score to make them more likely to be marked slop.

image

Known issues

  • Sometimes the followers list takes a while (over an hour) to update when you follow/unfollow someone. This is a Chub API limitation, nothing I can do about it.

Feedback

Email: [email protected]

If reporting a bug, include any errors or messages in the browser console (press F12).

Changelog

  • 1.7 (2025-02-15)
    • Resolves issues on nu-Chub caused by questionable breaking markup change.
    • Adds "Fix card link" setting (default true) which reverts said questionable markup change that replaced standard <a> links with JavaScript onClick-handled <div> elements (this completely broke the ability to open links in a new tab or copy their URLs).
  • 1.6 (2024-10-24)
    • Fixes "Non-ASCII" heuristic not working.
    • Improves "Non-English" heuristic's word separation to better handle picking up words in non-Latin alphabets.
    • Improves display of characterhub.org's "New Characters" rail when lots of cards are hidden
  • 1.5 (2024-10-20)
    • Adds "Non-English" heuristic, matching character descriptions/taglines with a significant proportion of non-English words. Default +5 score.
    • Adds "Non-ASCII" heuristic, matching character names/taglines with a significant proportion of non-ASCII text (emoji, 𝖚𝖓𝖎𝖈𝖔𝖉𝖊 '𝖋𝖔𝖓𝖙𝖘', non-Latin alphabet). Default +5 score, >10% threshold.
    • Adds "Custom system prompt" heuristic, matching v2 cards with a system prompt override. Default -2 score.
    • Adds "Custom jailbreak" heuristic, matching v2 cards with a post-history instruction. Default -1 score.
    • Improves "Too few tokens" heuristic to optionally only count permanent tokens. Default enabled.
    • Improves "Consolidate tags" setting to show the total score received from tags on each card.
    • Reduces default "Too many tokens" score from +5 to +3.
  • 1.4 (2024-10-20)
    • Adds option to hide the config button next to your avatar. Access config from your userscript manager.
    • Adjusts "Too few tokens" heuristic to skip cards with a lorebook, since those might have all of their tokens in the lorebook.
    • Replaces "Ignore Followers" toggle with a configurable heuristic. By default, cards from followed users receive -50 score.
    • Adjusts how follower lists are saved, so they remain in sync across both chub.ai and characterhub.org.
    • Improves config UI on narrow screens.
    • Fixes script failing on legacy Chub's user profile page.
  • 1.3 (2024-10-19)
    • Adds "Short Description" slop heuristic, default +3 to score for descriptions/taglines under 30 chars.
    • Improves reliability of browser back button handling.
    • Fixes nonfunctional "Long name" slop heuristic.
  • 1.2 (2024-10-18)
    • Adds "Consolidate tag labels" setting, to show all matched tag rules under a single label. Default true.
    • Improves config popup compatibility.
    • Sorts tag rules in the config screen by title.
    • Fixes config screen issue when trying to save after deleting a tag.
  • 1.1 (2024-10-18)
    • Add "Anonymous User" slop heuristic, default +3 to score
    • Fix inability to toggle hidden cards when there are characters on the page submitted by users you follow
  • 1.0 (2024-10-18)
    • Initial release
// ==UserScript==
// @name Chub Desloppifier
// @namespace Violentmonkey Scripts
// @match https://*.chub.ai/*
// @match https://*.characterhub.org/*
// @version 1.7
// @author khanonnie
// @description Tries to clean up the flood of shit cards on chub.ai.
// @require https://openuserjs.org/src/libs/sizzle/GM_config.js
// @homepageURL https://gist.github.com/khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75
// @downloadURL https://gist.github.com/khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75/raw/ChubDeslop.user.js
// @updateURL https://gist.github.com/khanonnie/b357f20bfe4e920d8e05fd47f1e6fa75/raw/ChubDeslop.user.js
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
"use strict";
const modal = document.createElement("div");
const configFrame = document.createElement("div");
modal.appendChild(configFrame);
GM_config.init({
id: "chubfix-config",
title: "Chub Desloppifier",
frame: configFrame,
fields: {
// General
slopThreshold: {
section: ["General"],
label: "Slop threshold",
type: "custom",
hint: "Minimum score to be considered slop.",
default: { value: 10 },
},
alwaysShowSlopLabels: {
label: "Always show slop labels",
type: "custom",
hint: "Whether to show score labels even when slop cards are hidden.",
default: { value: false },
},
toggleSlopLocation: {
label: "Hide/show slop button location",
type: "custom",
hint: "Where to display the slop toggle button on the page.",
options: ["top", "bottom", "hidden"],
default: { value: "top" },
},
consolidateTagLabels: {
label: "Consolidate tag labels",
type: "custom",
hint: "Whether to display matched tag rules as a single label.",
default: { value: true },
},
hideConfigButton: {
label: "Hide config button",
type: "custom",
hint: "Whether to hide the Desloppifier config button from the page.<br>You'll need to use your userscript manager to access the config.",
default: { value: false },
},
fixCardLinks: {
label: "Fix card links",
type: "custom",
hint: "Whether to revert Feb 2025 chub.ai change that made card links unable to be opened in a new tab. [Affects chub.ai only]",
default: { value: true }
},
// Slop scoring
badUsername: {
section: ["Slop scoring"],
type: "custom",
label: "Default username",
hint: "Characters from botmakers with names like 'adjective_noun_1234'.",
default: { title: "Bad username", scoreMod: 10 },
},
emptyDescription: {
type: "custom",
label: "No description",
hint: "Characters with a completely empty description.",
default: { title: "No desc", scoreMod: 10 },
},
noTags: {
type: "custom",
label: "No tags",
hint: "Characters with no tags whatsoever.",
default: { title: "No tags", scoreMod: 10 },
},
insufficientTokens: {
type: "custom",
label: "Too few tokens",
hint: "Characters with token counts that are too low.",
default: {
title: "Low tokens",
usePermanent: true,
threshold: 250,
scoreMod: 10,
},
},
tooManyTokens: {
type: "custom",
label: "Too many tokens",
hint: "Characters with token counts that are too high.",
default: { title: "Bloat", threshold: 2500, scoreMod: 3 },
},
nsfwImage: {
type: "custom",
label: "NSFW avatar image",
hint: "Characters with avatars marked NSFW.",
default: { title: "Coomer image", scoreMod: 5 },
},
ratingsDisabled: {
type: "custom",
label: "Ratings disabled",
hint: "Characters with ratings disabled.",
default: { title: "Rate block", scoreMod: 5 },
},
anonymous: {
type: "custom",
label: "Anonymous",
hint: "Characters from anonymous botmakers.",
default: { title: "Anonymous", scoreMod: 5 },
},
forked: {
type: "custom",
label: "Forked character",
hint: "Characters forked from another character.",
default: { title: "Forked", scoreMod: 3 },
},
fewTags: {
type: "custom",
label: "Few tags",
hint: "Characters with too few tags.",
default: { title: "Few tags", threshold: 3, scoreMod: 5 },
},
longName: {
type: "custom",
label: "Long name",
hint: "Characters with excessively long names.",
default: { title: "Long name", threshold: 25, scoreMod: 3 },
},
shortDescription: {
type: "custom",
label: "Short description",
hint: "Characters with very short descriptions.",
default: { title: "Short desc", threshold: 30, scoreMod: 3 },
},
nonEnglish: {
type: "custom",
label: "Non-English description",
hint: "Characters with descriptions containing very few English words.",
default: { title: "Non-English", scoreMod: 5 },
},
nonAscii: {
type: "custom",
label: "Non-ASCII text",
hint: "Characters with names/taglines containing more than a certain proportion of non-ASCII text (emoji, unicode fonts, non-Latin chars, etc).",
default: { title: "Non-ASCII", thresholdPercent: 10, scoreMod: 5 },
},
recommended: {
type: "custom",
label: "Recommended character",
hint: "Characters recommended by Chub.",
default: { title: "Chub rec", scoreMod: -5 },
},
verified: {
type: "custom",
label: "Verified user",
hint: "Characters from botmakers who are Chub-verified.",
default: { title: "Verified", scoreMod: -5 },
},
hasSystemPrompt: {
type: "custom",
label: "Custom system prompt",
hint: "Characters with a system prompt override.",
default: { title: "Has sysprompt", scoreMod: -2 },
},
hasJailbreak: {
type: "custom",
label: "Custom jailbreak",
hint: "Characters with a customized jailbreak/post-history instruction prompt.",
default: { title: "Has JB", scoreMod: -1 },
},
hasLorebook: {
type: "custom",
label: "Included lorebook",
hint: "Characters with embedded lorebooks.",
default: { title: "Has lorebook", scoreMod: -5 },
},
hasGallery: {
type: "custom",
label: "Included gallery",
hint: "Characters with gallery images.",
default: { title: "Has gallery", scoreMod: -3 },
},
favorites: {
type: "custom",
label: "Many favorites",
hint: "Characters that have been favorited many times.",
default: { title: "Many favs", threshold: 80, scoreMod: -5 },
},
followed: {
type: "custom",
label: "Followed",
hint: "Characters from botmakers you follow.",
default: { title: "Followed", scoreMod: -50 },
},
// Tag scoring
tagMatch: {
section: ["Tag scoring"],
type: "custom_tag",
label: "Tag score rules",
hint: "<ul><li><b>Title:</b> Name of the rule, for reference.</li><li><b>Tags:</b> Comma-separated list of tags. ALL tags must be present for rule to apply.</li><li><b>Score:</b> Slop score adjustment when rule applies. Negative modifier = less slop.</li></ul>",
default: [
{ tags: ["nsfw"], scoreMod: 1, title: "Lewd shit" },
{
tags: ["scat"],
scoreMod: Number.MAX_SAFE_INTEGER,
title: "🤮💩🤮",
},
{ tags: ["male", "femboy"], scoreMod: -3, title: "😍Femboys😍" },
],
},
},
css: `
@media (prefers-color-scheme: dark) {
#chubfix-config { background-color: #1e1e1e; color: #d3d3d3; }
#chubfix-config .field_title { color: #ffffff; }
#chubfix-config input, #chubfix-config select { background-color: #2d2d2d; color: #ffffff; }
#chubfix-config .reset { color: #ffffff; }
}
@media (max-width: 550px) {
div#chubfix-config { max-height: 80vh; max-width: 100vw; }
#chubfix-config .config_var { grid-column: 1 / 3; }
}
#chubfix-config { font-family: sans-serif; font-size: 0.8em; padding: 8px; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 1000; overflow: auto; width: 1000px; max-width: 95vw; height: 1200px; max-height: 90vh; border: 1px solid black; }
#chubfix-config .section_header_holder { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#chubfix-config .section_header { grid-column: 1 / 3; font-size: 1.2em; font-weight: bold; margin-bottom: 10px; }
#chubfix-config .config_var { display: flex; flex-direction: column; align-items: flex-start; }
#chubfix-config .config_var.tag_match { grid-column: 1 / 3; }
#chubfix-config .field_title { font-size: 1em; font-weight: bold; }
#chubfix-config .config_fields { display: flex; flex-direction: row; align-items: center; gap: 10px; }
#chubfix-config .config_field { display: flex; flex-direction: column; align-items: center; }
#chubfix-config input[type="number"] { width: 6em }
#chubfix-config input[type="text"] { width: 12em }
#chubfix-config input, #chubfix-config select { padding: 5px; border: 1px solid #cccccc; border-radius: 3px; }
#chubfix-config button { color: #000000; background-color: #ffffff; border: 1px solid #cccccc; border-radius: 3px; padding: 5px; }
#chubfix-config .config_tag_fields { display: flex; flex-direction: column; align-items: center; gap: 10px; width: 100%; }
#chubfix-config .config_tag_field { display: flex; flex-direction: row; align-items: center; gap: 10px; max-width: 800px; width: 100%; flex-wrap: wrap; }
#chubfix-config .config_tag_field > input[type="text"] { flex-grow: 1; }`,
frameStyle: "",
types: {
// This basically reimplements the entire config renderer because the
// default one looks like shit and is very limited.
custom: {
toNode: function () {
const strings = {
scoreMod: "Score",
threshold: "Threshold",
usePermanent: "Only permanent tokens",
thresholdPercent: "Threshold (%)",
};
const configId = this.configId;
const field = this.settings;
const val = this.value || this.default;
const id = this.id;
const create = this.create;
// set up group with title, hint, and a collection of related fields
const group = create("div", { className: "config_var" });
group.appendChild(
create("span", { className: "field_title", innerHTML: field.label })
);
group.appendChild(
create("span", { innerHTML: field.hint, className: "field_hint" })
);
const fields = create("div", {
className: "config_fields",
id: `${configId}_${id}_fields`,
});
group.appendChild(fields);
// render each individual label/input pair
const { title, ...rest } = val;
for (const [k, v] of Object.entries(rest).reverse()) {
const fieldWrapper = create("div", { className: "config_field" });
const label = create("label", {
innerHTML: strings[k] || k,
for: `${configId}_field_${id}_${k}`,
className: "field_label",
});
let element;
let type = field.options ? "select" : "text";
switch (typeof v) {
case "number":
type = "number";
break;
case "boolean":
type = "checkbox";
break;
}
if (type === "select") {
element = create("select", {
id: `${configId}_field_${id}_${k}`,
className: "number",
});
for (const option of field.options) {
element.appendChild(
create("option", { value: option, innerHTML: option })
);
}
element.value = v;
} else {
element = create("input", {
id: `${configId}_field_${id}_${k}`,
type,
...(type === "checkbox" ? { checked: v } : { value: v }),
className: type,
});
}
// don't show label for simple values
if (k !== "value") {
fieldWrapper.appendChild(label);
}
fieldWrapper.appendChild(element);
fields.appendChild(fieldWrapper);
}
return group;
},
toValue: function () {
// extract values from a field group
const result = {};
for (const [k, v] of Object.entries(this.default)) {
const element = this.wrapper.querySelector(
`#${this.configId}_field_${this.id}_${k}`
);
if (!element) {
result[k] = v;
continue;
}
const type =
element.nodeName === "SELECT" ? "select" : element.type;
switch (type) {
case "number":
result[k] = parseFloat(element.value);
break;
case "checkbox":
result[k] = element.checked;
break;
case "select":
case "text":
result[k] = element.value;
break;
}
if (result[k] === undefined) {
result[k] = this.default[k];
}
}
return result;
},
reset: function () {
// reset state and then generate a new field group
this.value = this.default;
const newNode = this.toNode();
const oldNode = this.wrapper;
oldNode.parentNode.replaceChild(newNode, oldNode);
this.wrapper = newNode;
},
},
custom_tag: {
toNode: function () {
const configId = this.configId;
const field = this.settings;
const val = this.value || this.default;
const id = this.id;
const create = this.create;
const count = val.length;
const section = create("div", { className: "config_var tag_match" });
section.appendChild(
create("span", { innerHTML: field.label, className: "field_title" })
);
section.appendChild(
create("span", { innerHTML: field.hint, className: "field_hint" })
);
const tagList = create("div", { className: "config_tag_fields" });
tagList.dataset.prop = "taglist";
const makeTag = (tag, index) => {
const tagField = create("div", { className: "config_tag_field" });
const tagTitle = create("label", {
innerHTML: "Title",
for: `${configId}_field_${id}_${index}_title`,
});
tagField.appendChild(tagTitle);
const tagTitleInput = create("input", {
id: `${configId}_field_${id}_${index}_title`,
type: "text",
value: tag.title,
className: "number",
});
tagTitleInput.dataset.prop = "title";
tagField.appendChild(tagTitleInput);
const tagTags = create("label", {
innerHTML: "Tags",
for: `${configId}_field_${id}_${index}_tags`,
});
tagField.appendChild(tagTags);
const tagTagsInput = create("input", {
id: `${configId}_field_${id}_${index}_tags`,
type: "text",
value: tag.tags.join(","),
className: "number",
});
tagTagsInput.dataset.prop = "tags";
tagField.appendChild(tagTagsInput);
const tagScore = create("label", {
innerHTML: "Score",
for: `${configId}_field_${id}_${index}_scoreMod`,
});
tagField.appendChild(tagScore);
const tagScoreInput = create("input", {
id: `${configId}_field_${id}_${index}_scoreMod`,
type: "number",
value: tag.scoreMod,
className: "number",
});
tagScoreInput.dataset.prop = "score";
tagField.appendChild(tagScoreInput);
const deleteButton = create("button", { innerHTML: "Delete" });
tagField.appendChild(deleteButton);
deleteButton.addEventListener("click", () => {
tagList.removeChild(tagField);
});
return tagField;
};
for (let i = 0; i < count; i++) {
tagList.appendChild(makeTag(val[i], i));
}
section.appendChild(tagList);
const addButton = create("button", { innerHTML: "Add rule" });
addButton.addEventListener("click", () => {
const emptyTag = { title: "", scoreMod: 1, tags: [] };
tagList.appendChild(makeTag(emptyTag, tagList.childElementCount));
});
section.appendChild(addButton);
return section;
},
toValue: function () {
const result = [];
const elements = this.wrapper.querySelectorAll(
`div[data-prop='taglist'] > div`
);
for (let i = 0; i < elements.length; i++) {
const tag = elements[i];
const data = {
title: tag.querySelector("*[data-prop='title']").value,
tags: tag
.querySelector("*[data-prop='tags']")
.value.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
scoreMod: parseFloat(
tag.querySelector('*[data-prop="score"]').value
),
};
if (data.tags.length === 0) continue;
result.push(data);
}
return result.sort((a, b) => a.title.localeCompare(b.title));
},
reset: function () {
this.value = this.default.slice();
const newRender = this.toNode();
const oldWrapper = this.wrapper;
oldWrapper.parentNode.replaceChild(newRender, oldWrapper);
this.wrapper = newRender;
},
},
},
events: {
init: function () {
for (const [key, field] of Object.entries(this.fields)) {
if (field.settings.type !== "custom") continue;
// Initialize subfields
for (const [subfield, val] of Object.entries(field.default)) {
if (field.value[subfield] === undefined) {
console.info("Initializing setting", key, subfield, val);
field.value[subfield] = val;
}
}
// Remove unrecognized subfields
for (const subkey of Object.keys(field.value)) {
if (field.default[subkey] === undefined) {
console.info("Removing unrecognized setting", key, subkey);
delete field.value[subkey];
}
}
}
},
open: function () {
document.querySelector("#chubfix-config").style = "";
document.querySelector("#chubfix-config").scrollTo(0, 0);
},
save: function () {
GM_config.close();
charaCache.clear();
alert(
"Changes saved. You might need to reload the page to apply them."
);
},
},
});
GM_registerMenuCommand("Open config", function () {
GM_config.open();
});
/** Cache of evaluated characters, keyed by slug */
const charaCache = new Map();
/** Cache of followed users from localStorage, converted to a set */
let followsCache = null;
/**
* Functions which accept character data from the api to determine if they
* match a particular slop heuristic.
*/
const heuristics = {
badUsername: (data) =>
data.fullPath
?.split("/")
.shift()
.match(/^[a-z]+_[a-z]+_\d{3,4}$/i) !== null,
emptyDescription: (data) => data.tagline?.trim() === "",
noTags: (data) => (data.topics || []).length === 0,
insufficientTokens: (data, params) => {
// don't trigger when a lorebook is present
if (data.related_lorebooks?.length) return false;
// nTokens includes mes_example which user may want to ignore when
// determining whether tokens are too low
const excludeExamples = params.usePermanent;
// don't bother parsing if nTokens is already too low, or if not using
// permanent tokens
const tooLow = data.nTokens < params.threshold;
if (tooLow && !excludeExamples) return tooLow;
let permTokens = data.nTokens;
permTokens -= tryParseTokenCounts(data)?.mes_example || 0;
return permTokens < params.threshold;
},
tooManyTokens: (data, params) => data.nTokens > params.threshold,
nsfwImage: (data) => data.nsfw_image,
ratingsDisabled: (data) => data.ratings_disabled,
anonymous: (data) => data.fullPath?.split("/").shift() === "Anonymous",
forked: (data) => data.labels?.some((label) => label.title === "Forked"),
fewTags: (data, params) =>
data.topics?.filter((tag) => tag.toLowerCase() !== "nsfw").length <=
params.threshold,
longName: (data, params) => data.name.length > params.threshold,
shortDescription: (data, params) => {
const str = (data.tagline || "").trim() + (data.description || "").trim();
// don't trigger on empty descriptions since there is another heuristic
// for that
if (str.length === 0) return false;
return str.length < params.threshold;
},
nonEnglish: (data) => {
const str = (
(data.tagline || "") + (data.description || "")
).toLowerCase();
let match;
let words = 0;
let common = 0;
while ((match = WORD_REGEX.exec(str)) !== null) {
if (COMMON_ENGLISH_WORDS.has(match[0])) common++;
words++;
}
// very short descriptions may give false positives and are already
// penalized by another check
if (words <= 5) return false;
// non-english latin/germanic languages hit around 5%
return common / words < 0.075;
},
nonAscii: (data, params) => {
const str = [data.name, data.tagline].map((s) => s || "").join();
const percent = str.match(/[^\x00-\x7F]/g)?.length / str.length;
return percent > Math.max(params.thresholdPercent, 1) / 100;
},
recommended: (data) => data.recommended,
verified: (data) => data.verified,
hasSystemPrompt: (data) => {
const counts = tryParseTokenCounts(data);
if (!counts) return false;
return counts.system_prompt;
},
hasJailbreak: (data) => {
const counts = tryParseTokenCounts(data);
if (!counts) return false;
return counts.post_history_instructions;
},
hasLorebook: (data) => data.related_lorebooks?.length > 0,
hasGallery: (data) => data.hasGallery,
favorites: (data, params) => data.n_favorites >= params.threshold,
followed: (data) =>
!!tryGetFollows()?.follows.has(data.fullPath?.split("/").shift()),
};
const isLegacy = window.location.hostname.includes("characterhub.org");
let showHidden = false;
let lastLocation = "";
let observer = null;
let elements = [];
function debounce(callback, delay) {
let timeoutId = null;
return (...args) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
timeoutId = null;
}, delay);
};
}
/**
* Unpacks the TOKEN_COUNTS Chub API label into a JSON object.
*/
function tryParseTokenCounts(data) {
if (data.__chubfix_tokenCounts) return data.__chubfix_tokenCounts;
try {
const counts = data.labels?.find((x) => x.title === "TOKEN_COUNTS");
if (!counts) return null;
const parsed = JSON.parse(counts.description);
return (data.__chubfix_tokenCounts = parsed);
} catch (err) {
console.error("Error parsing token counts", data.fullPath, err);
return null;
}
}
/**
* Marks all cards which have been marked as slop by the heuristics. Requires
* cached evaluations to be present for each card on the page.
*/
function markSlopCards() {
const charaLists = CHUB_PAGE_API.getCharaLists();
for (const charaList of charaLists) {
const cards = CHUB_PAGE_API.getCards(charaList);
for (const card of cards) {
let slug;
if (isLegacy) {
const link = CHUB_PAGE_API.getCardLink(card);
slug = link.href.split("/").slice(-2).join("/");
} else {
slug = getCharaSlugFromCard(card);
fixCardLink(card, slug);
}
const result = charaCache.get(slug);
card.dataset.slug = slug;
if (result) {
card.dataset.slop = result.slop;
card.dataset.score = result.score;
card.dataset.reasons = result.reasons.join(",");
}
}
}
render();
}
function getCharaSlugFromCard(card) {
const name = CHUB_PAGE_API.getCardName(card).innerText?.replace(/\s+/g, "");
const author = CHUB_PAGE_API.getCardAuthorLink(card)
.href.split("/")
.pop()
.replace(/\s+/g, "");
return `chara-${name}-@${author.toLowerCase()}`;
}
/**
* After chub's highly questionable decision to replace standard <a> tags for
* linking to card pages with Javascript-only click handlers, it's no longer
* possible to right-click > open in new tab on cards, because they are not
* actually links.
*
* This function just tries to undo that dumb change by wrapping the given
* card element in an anchor tag.
*/
function fixCardLink(card, slug) {
const shouldFix = GM_config.get("fixCardLinks")?.value ?? true
if (!card.querySelector("a[data-chubfixed-link]") && shouldFix) {
if (!slug) {
slug = getCharaSlugFromCard(card);
}
const chara = charaCache.get(slug);
if (!chara) {
console.warn("chubfix fixCardLink: Chara missing from cache", { slug });
return;
}
const wrap = (contents) => {
const wrapper = document.createElement("a");
wrapper.className = "chubfix-fixed-link";
wrapper.dataset.chubfixedLink = true;
wrapper.href = "https://chub.ai/" + chara.fullPath;
contents.parentNode.insertBefore(wrapper, contents);
wrapper.appendChild(contents);
};
const nameLink = CHUB_PAGE_API.getCardName(card);
const image = CHUB_PAGE_API.getCardAvatar(card);
wrap(nameLink.childNodes[0]);
wrap(image);
}
}
/**
* Applies the appropriate styles to the page to hide or show cards marked as
* slop.
*/
function applyStyles() {
const hiddenCards = CHUB_PAGE_API.getHiddenCards();
for (const hiddenCard of hiddenCards) {
const container = CHUB_PAGE_API.getHiddenCardContainer(hiddenCard);
if (showHidden) {
container.classList.remove("chubfix-hidden");
container.classList.add("chubfix-deemphasized");
} else {
container.classList.remove("chubfix-deemphasized");
container.classList.add("chubfix-hidden");
}
}
}
/**
* Shows a banner at the top of the page with the number of hidden cards.
*/
function showSlopBanner() {
const hiddenCount = CHUB_PAGE_API.getHiddenCards().length;
const hideBanner = GM_config.get("toggleSlopLocation")?.value === "hidden";
if (hiddenCount === 0 || hideBanner) return;
const banner = document.createElement("div");
banner.className = "chubfix-unhide-banner";
if (showHidden) {
banner.textContent = `Hide ${hiddenCount} slop cards`;
banner.addEventListener("click", () => {
showHidden = false;
render();
});
} else {
banner.textContent = `Show ${hiddenCount} slop cards`;
banner.addEventListener("click", () => {
showHidden = true;
render();
});
}
const lastList = CHUB_PAGE_API.getCharaLists().pop();
const target = isLegacy
? lastList.parentElement.parentElement
: lastList.parentElement;
if (target === null) {
console.warn("No target element found for unhide banner");
return;
}
const fn =
GM_config.get("toggleSlopLocation")?.value === "top"
? "prepend"
: "append";
target[fn](banner);
elements.push(banner);
}
/**
* Inserts config button into the user menu.
*/
function injectConfigButton() {
if (GM_config.get("hideConfigButton")?.value ?? false) return;
const menus = CHUB_PAGE_API.getUserMenu();
for (const menu of menus) {
const nodeName = menu.nodeName.toLowerCase();
if (nodeName === "li") {
const configButton = document.createElement("li");
configButton.className =
"ant-menu-overflow-item ant-menu-item ant-menu-item-only-child chubfix-config-button";
configButton.innerHTML = `Desloppifier`;
configButton.addEventListener("click", () => {
GM_config.open();
});
menu.parentElement.insertBefore(configButton, menu);
// menu.appendChild(configButton);
elements.push(configButton);
} else if (nodeName === "div") {
const configButton = document.createElement("div");
configButton.className = "chubfix-config-button relative ml-3";
configButton.innerHTML = `Desloppifier`;
configButton.addEventListener("click", () => {
GM_config.open();
});
menu.parentElement.insertBefore(configButton, menu);
elements.push(configButton);
}
}
}
/**
* Adds tags showing the reasons for the card being marked as slop.
*/
function addSlopTags() {
if (!showHidden && !GM_config.get("alwaysShowSlopLabels")?.value) return;
const cards = CHUB_PAGE_API.getCharaLists().flatMap(CHUB_PAGE_API.getCards);
for (const card of cards) {
const tags = CHUB_PAGE_API.getCardTagList(card);
if (tags === null) {
console.warn("chubfix: No tag list element found for card", card);
continue;
}
const chara = charaCache.get(card.dataset.slug);
if (chara === undefined) {
console.warn("chubfix: No entry for card found in cache", { card });
continue;
}
const { reasons, slop } = chara;
if (reasons.length === 0) continue;
const consolidate = GM_config.get("consolidateTagLabels")?.value ?? true;
const allTags = [];
for (const reason of reasons) {
const config = tryGetHeuristicConfig(reason);
if (!config) continue;
const { title, score } = config;
if (consolidate && reason.includes("__tag")) {
allTags.push({ title, score });
} else {
tags.prepend(makeSlopTag(title, score));
}
}
if (consolidate && allTags.length > 0) {
const total = allTags.reduce((acc, tag) => acc + tag.score, 0);
const lines = allTags.map((tag) => `• ${tag.title} (${tag.score})`);
const label = `Tags (${formatScoreMod(total)})`;
tags.prepend(makeSlopTag(label, total, lines.join("\n")));
}
if (slop) {
tags.prepend(makeSlopTag("SLOP (" + card.dataset.score + ")"));
}
}
}
function formatScoreMod(score) {
return score > 0 ? "+" + score : score;
}
/**
* Creates a tag element with the given reason and score.
*/
function makeSlopTag(reason, score = 0, extra = "") {
const tag = document.createElement("span");
const tagName = CHUB_PAGE_API.getNativeTagClass();
tag.className = "chubfix-slop-tag " + tagName;
if (score < 0) {
tag.classList.add(...CHUB_PAGE_API.getGoodTagClasses());
} else {
tag.classList.add(...CHUB_PAGE_API.getBadTagClasses());
}
tag.textContent = reason;
let titleText = `Score: ${formatScoreMod(score)}\n${extra}`;
tag.title = titleText;
elements.push(tag);
return tag;
}
function tryGetHeuristicConfig(reason) {
try {
const heuristic = reason.includes("__tag")
? GM_config.get("tagMatch")[Number(reason.replace("__tag", ""))]
: GM_config.get(reason);
const title = heuristic.title;
const score = heuristic.scoreMod || 0;
return { title, score };
} catch (err) {
console.error("Error getting heuristic config", {
slug,
heuristic: key,
err,
});
return null;
}
}
/**
* Removes all injected elements and then re-renders the page with the
* current state.
*/
function render() {
console.time("chubfix render");
for (const element of elements) {
element.remove();
}
elements = [];
applyStyles();
showSlopBanner();
addSlopTags();
injectConfigButton();
if (!document.body.contains(modal)) {
document.body.appendChild(modal);
}
console.timeEnd("chubfix render");
}
/**
* Chub for some reason removed the anchor tag around all cards and made each
* card a clickable div where the routing is handled entirely within JS, so
* it's not possible to detect which div links to which card just by searching
* the DOM for the card's slug as it was before. Now we generate a fake slug
* using the card name and author name and hope that is unique enought to
* identify it on the page.
*/
function getCharaSlug(data) {
return isLegacy
? data.fullPath
: `chara-${data.name?.replace(/\s+/g, "")}-@${data.fullPath
?.split("/")
.shift()
.toLowerCase()
.trim()}`;
}
/**
* Given a single character's data from a search API response, evaluates it
* against the slop heuristics and caches the result.
*/
function evaluateChara(data) {
const slug = getCharaSlug(data);
const isChara = data.projectSpace === "characters";
if (charaCache.has(slug) || !isChara) return;
const result = { slop: false, score: 0, reasons: [], fullPath: "" };
// apply heuristics
for (const [key, func] of Object.entries(heuristics)) {
try {
const { scoreMod, ...params } = GM_config.get(key);
if (scoreMod === 0) continue;
if (func(data, params)) {
result.reasons.push(key);
result.score += scoreMod;
}
} catch (err) {
console.error("chubfix: Eval error", { slug, heuristic: key, err });
}
}
// apply tag rules to score
const charaTags = data.topics.map((tag) => tag.toLowerCase());
const tagRules = GM_config.get("tagMatch");
for (let i = 0; i < tagRules.length; i++) {
const rule = tagRules[i];
try {
if (rule.tags.every((tag) => charaTags.includes(tag))) {
result.reasons.push("__tag" + i);
result.score += rule.scoreMod;
}
} catch (err) {
console.error("Error evaluating tag match", { slug, rule, err });
}
}
result.slop = result.score >= GM_config.get("slopThreshold")?.value;
result.fullPath = data.fullPath;
console.info("chubfix: Chara evaluation result", { slug, result });
charaCache.set(slug, result);
}
/**
* Parses a character search response and evaluates each character.
*/
function parseSearchResponse(response) {
console.groupCollapsed("chubfix: Evaluating charas");
console.time("chubfix parseSearchResponse");
try {
const parsed =
typeof response === "string" ? JSON.parse(response) : response;
const nodes =
parsed.data?.nodes || parsed.nodes || parsed.projects?.nodes;
for (const chara of nodes) {
evaluateChara(chara);
}
} catch (e) {
console.error("chubfix: Error parsing search response", e, response);
}
console.groupEnd();
console.timeEnd("chubfix parseSearchResponse");
setTimeout(() => {
// occasionally when navigating back to the previous page, the mutation
// observer may fire and trigger the debounced render before this function
// finishes parsing the cached fetch. to account for this, we will trigger
// a re-render here if there are no marked cards.
if (document.querySelectorAll("div[data-slop]").length === 0) {
console.warn("Missed render after navigation, triggering re-render");
markSlopCards();
}
}, 500);
}
/**
* Parses a login response to load followed creators.
*/
async function parseSelfResponse(response) {
console.time("chubfix parseSelfResponse");
try {
const cachedFollows = tryGetFollows();
const cachedTimestamp = cachedFollows?.timestamp || 0;
if (
!cachedFollows ||
Date.now() - cachedTimestamp > 60 * 60 * 1000 * 24
) {
console.log("chubfix: Need to refresh follows");
await refreshFollows();
}
} catch (e) {
console.error("chubfix: Error parsing self response", e, response);
}
console.timeEnd("chubfix parseSelfResponse");
}
/**
* Triggers follower refresh when user follows/unfollows a user.
*/
async function parseFollowResponse() {
console.log("chubfix: Refreshing follows due to change");
GM_setValue("follows-" + localStorage.getItem("USERNAME"), {
follows: [],
timestamp: 0,
});
await refreshFollows();
}
/**
* Fetches user's followed users and caches them.
*/
async function refreshFollows() {
const username = localStorage.getItem("USERNAME");
if (!username) {
console.error("chubfix: No username found for refresh");
return;
}
const follows = await getAllFollowers(username);
saveFollows(username, follows);
console.log("chubfix: Cached follows for", username, follows);
}
/**
* Returns cached follows for current user, or null if they don't exist.
*/
function tryGetFollows() {
if (followsCache) return followsCache;
try {
const value = GM_getValue("follows-" + localStorage.getItem("USERNAME"), {
follows: [],
timestamp: 0,
});
return (followsCache = {
follows: new Set(value.follows),
timestamp: value.timestamp,
});
} catch (e) {
console.error("Error loading cached follows", e);
return null;
}
}
function saveFollows(username, follows) {
followsCache = { follows: new Set(follows), timestamp: Date.now() };
GM_setValue("follows-" + username, { follows, timestamp: Date.now() });
}
/**
* Fetches all followers of a given user.
*/
async function getAllFollowers(username) {
let completed = false;
let page = 1;
const followers = [];
while (!completed) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const token = localStorage.getItem("URQL_TOKEN");
if (!token) {
console.error("No api token found for fetching followers");
return followers;
}
const response = await fetch(
`https://api.chub.ai/api/follows/${username}?page=${page}`,
{ headers: { "CH-API-KEY": token } }
);
if (!response.ok) {
console.error("Error fetching followers", response);
return followers;
}
const data = await response.json();
console.debug("Got followers page", page, data);
if (!Array.isArray(data?.follows)) {
console.error("Error parsing followers", data);
return followers;
}
followers.push(...data.follows.map((f) => f.username));
completed = page > 5 || data.count <= followers.length;
page++;
}
console.log("Fetched all followers", followers);
return followers;
}
/**
* Sets up mutation observers to mark cards as slop when they are added to
* the DOM.
*/
function startObservers() {
const debouncedMarkSlopCards = debounce(markSlopCards, 10);
observer = new MutationObserver((mutations) => {
const currentLocation = window.location.href;
if (currentLocation !== lastLocation) {
console.log("Navigation detected", currentLocation);
debouncedMarkSlopCards();
}
lastLocation = currentLocation;
for (const mutation of mutations) {
if (!CHUB_PAGE_API.charaListMutated(mutation)) continue;
if (
mutation.addedNodes?.length &&
mutation.addedNodes[0].classList.toString().includes("chubfix")
) {
console.log("Skipping self-triggered mutation", mutation);
continue;
}
debouncedMarkSlopCards();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/**
* Tries to provide an interface for DOM manipulation for both legacy
* characterhub.org and new chub.ai UIs.
*/
const CHUB_PAGE_API = {
charaListMutated: (mutation) =>
isLegacy
? [mutation.target, ...mutation.addedNodes].some((node) =>
node.classList.contains("listings-container")
)
: mutation.target.id === "chara-list",
getCharaLists: () =>
isLegacy
? Array.from(document.querySelectorAll("div.listings-container"))
: [document.querySelector("#chara-list")].filter(Boolean),
getCards: (charaList) =>
isLegacy
? Array.from(
charaList.querySelectorAll(":scope div:has(>.chub-card-info)")
)
: Array.from(charaList.querySelectorAll("div.ant-card")),
/**
* Note: getCardLink no longer works as of Feb 2025 because chub removed
* the links for some reason and makes each card a clickable div.
*/
getCardLink: (card) =>
isLegacy
? card.querySelector(".chub-card-info > div a")
: card.parentElement,
getCardName: (card) =>
isLegacy
? null
: card.querySelector(
"div.ant-card-head-title > div.ant-row > span > span"
),
getCardAvatar: (card) =>
isLegacy ? null : card.querySelector("img[src$='avatar.webp']"),
getCardAuthorLink: (card) =>
isLegacy
? null
: card.querySelector("p:last-of-type > a[href*='/users/']"),
getCardTagList: (card) =>
isLegacy
? card.querySelector(".chub-card-info div.my-3.flex.flex-wrap")
: card.querySelector("div.ant-row.custom-scroll > div"),
getNativeTagClass: () =>
isLegacy
? "flex flex-wrap cursor-hover chub-tag justify-between items-center text-xs hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 rounded px-3 py-1 font-bold leading-loose"
: "ant-tag css-x8t00a",
getBadTagClasses: () =>
isLegacy ? ["chubfix-legacy-bad-tag"] : ["ant-tag-warning"],
getGoodTagClasses: () =>
isLegacy ? ["chubfix-legacy-good-tag"] : ["ant-tag-success"],
getHiddenCards: () => document.querySelectorAll("div[data-slop='true']"),
getHiddenCardContainer: (card) => (isLegacy ? card : card.parentElement),
getUserMenu: () =>
isLegacy
? Array.from(
document.querySelectorAll(
"nav div.relative.ml-3:has(img.rounded-full)"
)
)
: Array.from(
document.querySelectorAll(
"header > ul.ant-menu > li:has(span.ant-avatar)"
)
),
};
function injectStyles() {
const style = document.createElement("style");
style.innerHTML = `
.chubfix-deemphasized {
filter: saturate(0);
opacity: 0.33;
}
.chubfix-fixed-link {
color: inherit;
}
.chubfix-deemphasized:hover {
filter: saturate(0.75);
opacity: 0.66;
}
.chubfix-hidden {
display: none !important;
}
.chubfix-unhide-banner {
display: flex;
justify-content: center;
align-items: center;
padding: 2px;
width: 25%;
margin: 0 auto;
background-color: #2d2d2d;
color: #d3d3d3;
font-size: 14px;
border: 1px solid #ffffff;
cursor: pointer;
user-select: none;
}
.chubfix-unhide-banner:hover {
background-color: #3a3a3a;
color: #ffffff;
}
.chubfix-slop-tag {
max-width: 100%;
margin-inline-end: 2px;
line-height: 1.5;
}
.chubfix-legacy-bad-tag {
color: #ab6100 !important;
}
.chubfix-legacy-good-tag {
color: #108548 !important;
}
li.chubfix-config-button {
order: 4;
}
div.chubfix-config-button {
cursor: pointer;
}
#chubfix-config-modal {
width: 80%;
max-width: 800px;
margin: 0 auto;
z-index: 1000;
position: fixed;
display: none;
}
/*
fix legacy chub card rail sizing issue when slop cards are hidden
*/
.listings-container>div.flex-inline>*[data-slop] {
flex-basis: min-content;
}
`;
document.head.append(style);
}
const interceptors = {
"/api/self": parseSelfResponse,
"/api/projects/similar": parseSearchResponse,
"/search": parseSearchResponse,
"/api/timeline": parseSearchResponse,
"/api/follow/": parseFollowResponse,
"/api/users/": parseSearchResponse,
};
const getInterceptor = (url) => {
const urlObj = new URL(url);
const isApiRequest = ["gateway.chub.ai", "api.chub.ai"].some((x) =>
urlObj.hostname.includes(x)
);
if (!isApiRequest) return null;
for (const [endpoint, handler] of Object.entries(interceptors)) {
if (urlObj.pathname.startsWith(endpoint)) {
return handler;
}
}
return null;
};
/**
* Monkey-patches fetch to intercept API requests so characters can be
* evaluated as they are loaded.
*/
function patchFetch() {
const originalFetch = window.unsafeWindow.fetch;
window.unsafeWindow.fetch = function () {
return originalFetch.apply(this, arguments).then(function (response) {
try {
const ok = response.status >= 200 && response.status < 300;
const isJson = response.headers.get("Content-Type")?.includes("json");
const handler = getInterceptor(response.url);
if (ok && isJson && handler) {
response
.clone()
.json()
.then(handler)
.catch((err) => {
console.error("[fetch] Error parsing API data", { url, err });
});
}
} catch (err) {
console.error("[fetch] Error checking response", { url, err });
}
return response;
});
};
}
/**
* Monkey-patches XHR to intercept API requests so characters can be
* evaluated as they are loaded. /search is the only endpoint which seems to
* use XmlHttpRequest.
*/
function patchXhr() {
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async) {
this.addEventListener("load", function () {
try {
const ok = this.status >= 200 && this.status < 300;
const isJson =
this.getResponseHeader("Content-Type")?.includes("json");
const handler = getInterceptor(this.responseURL);
if (!ok || !isJson || !handler) return;
handler(this.response);
} catch (err) {
console.error("[xhr] Error parsing API response", { url, err });
console.error(this.response);
}
});
originalXhrOpen.apply(this, arguments);
};
}
/* prettier-ignore */
const COMMON_ENGLISH_WORDS = new Set([ "the", "of", "to", "and", "a", "in",
"is", "it", "you", "that", "he", "was", "for", "on", "are", "with", "as",
"I", "his", "they", "be", "at", "one", "have", "this", "from", "or", "had",
"by", "not", "word", "but", "what", "some", "we", "can", "out", "other",
"were", "all", "there", "when", "up", "use", "your", "how", "said", "an",
"each", "she", "which", "do", "their", "time", "if", "will", "way",
"about", "many", "then", "them", "write", "would", "like", "so", "these",
"her", "long", "make", "thing", "see", "him", "two", "has", "look",
"more", "day", "could", "go", "come", "did", "number", "sound", "no",
"most", "people", "my", "over", "know", "water", "than", "call", "first",
"who", "may", "down", "side", "been", "now", "find", "any", "new",
"work", "part", "take", "get", "place", "made", "live", "where", "after",
"back", "little", "only", "round", "man", "year", "came", "show",
"every", "good", "me", "give", "our", "under", "name", "very", "through",
"just", "form", "sentence", "great", "think", "say", "help", "low",
"line", "differ", "turn", "cause", "much", "mean", "before", "move",
"right", "boy", "old", "too", "same", "tell", "does", "set", "three",
"want", "air", "well", "also", "play", "small", "end", "put", "home",
"read", "hand", "port", "large", "spell", "add", "even", "land", "here",
"must", "big", "high", "such", "follow", "act", "why", "ask", "men",
"change", "went", "light", "kind", "off", "need", "house", "picture",
"try", "us", "again", "animal", "point", "mother", "world", "near",
"build", "self", "earth", "father", "head", "stand", "own", "page",
"should", "country", "found", "answer", "school", "grow", "study",
"still", "learn", "plant", "cover", "food", "sun", "four", "between",
"state", "keep", "eye", "never", "last", "let", "thought", "city",
"tree", "cross", "farm", "hard", "start", "might", "story", "saw", "far",
"sea", "draw", "left", "late", "run", "don't", "while", "press", "close",
"night", "real", "life", "few", "north", "open", "seem", "together",
"next", "white", "children", "begin", "got", "walk", "example", "ease",
"paper", "group", "always", "music", "those", "both", "mark", "often",
"letter", "until", "mile", "river", "car", "feet", "care", "second",
"book", "carry", "took", "science", "eat", "room", "friend", "began",
"idea", "fish", "mountain", "stop", "once", "base", "hear", "horse",
"cut", "sure", "watch", "color", "face", "wood", "main", "enough",
"plain", "girl", "usual", "young", "ready", "above", "ever", "red",
"list", "though", "feel", "talk", "bird", "soon", "body", "dog",
"family", "direct", "pose", "leave", "song", "measure", "door",
"product", "black", "short", "numeral", "class", "wind", "question",
"happen", "complete", "ship", "area", "half", "rock", "order", "fire",
"south", "problem", "piece", "told", "knew", "pass", "since", "top",
"whole", "king", "space", "heard", "best", "hour", "better", "true",
"during", "hundred", "five", "remember", "step", "early", "hold", "west",
"ground", "interest", "reach", "fast", "verb", "sing", "listen", "six",
"table", "travel", "less", "morning", "ten", "simple", "several",
"vowel", "toward", "war", "lay", "against", "pattern", "slow", "center",
"love", "person", "money", "serve", "appear", "road", "map", "rain",
"rule", "govern", "pull", "cold", "notice", "voice", "unit", "power",
"town", "fine", "certain", "fly", "fall", "lead", "cry", "dark",
"machine", "note", "wait", "plan", "figure", "star", "box", "noun",
"field", "rest", "correct", "able", "pound", "done", "beauty", "drive",
"stood", "contain", "front", "teach", "week", "final", "gave", "green",
"oh", "quick", "develop", "ocean", "warm", "free", "minute", "strong",
"special", "mind", "behind", "clear", "tail", "produce", "fact",
"street", "inch", "multiply", "nothing", "course", "stay", "wheel",
"full", "force", "blue", "object", "decide", "surface", "deep", "moon",
"island", "foot", "system", "busy", "test", "record", "boat", "common",
"gold", "possible", "plane", "stead", "dry", "wonder", "laugh",
"thousand", "ago", "ran", "check", "game", "shape", "equate", "hot",
"miss", "brought", "heat", "snow", "tire", "bring", "yes", "distant",
"fill", "east", "paint", "language", "among", ]);
const WORD_REGEX = /[\p{L}-]+/gu;
patchFetch();
patchXhr();
injectStyles();
startObservers();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment