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