Anilist: Activity-Feed Filter

Control the content displayed in your activity feeds

  1. // ==UserScript==
  2. // @name Anilist: Activity-Feed Filter
  3. // @namespace https://github.com/SeyTi01/
  4. // @version 1.8.4
  5. // @description Control the content displayed in your activity feeds
  6. // @author SeyTi01
  7. // @match https://anilist.co/*
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11.  
  12. const config = {
  13. remove: {
  14. images: false, // Remove activities with images
  15. gifs: false, // Remove activities with gifs
  16. videos: false, // Remove activities with videos
  17. text: false, // Remove activities with only text
  18. uncommented: false, // Remove activities without comments
  19. unliked: false, // Remove activities without likes
  20. containsStrings: [], // Remove activities containing user-defined strings
  21. },
  22. options: {
  23. targetLoadCount: 2, // Minimum number of activities to display per "Load More" button click
  24. caseSensitive: false, // Use case-sensitive matching for string-based removal
  25. reverseConditions: false, // Display only posts that meet the specified removal conditions
  26. linkedConditions: [], // Groups of conditions to be evaluated together
  27. },
  28. runOn: {
  29. home: true, // Run the script on the home feed
  30. social: true, // Run the script on the 'Recent Activity' of anime/manga entries
  31. profile: false, // Run the script on user profile feeds
  32. guestHome: false, // Run the script on the home feed for non-user visitors
  33. },
  34. };
  35.  
  36. class MainApp {
  37. constructor(activityHandler, uiHandler, config) {
  38. this.ac = activityHandler;
  39. this.ui = uiHandler;
  40. this.config = config;
  41. }
  42.  
  43. observeMutations = (mutations) => {
  44. if (this.isAllowedUrl()) {
  45. mutations.forEach(mutation => mutation.addedNodes.forEach(node => this.handleAddedNode(node)));
  46. this.loadMoreOrReset();
  47. }
  48. }
  49.  
  50. handleAddedNode = (node) => {
  51. if (node instanceof HTMLElement) {
  52. if (node.matches(selectors.DIV.ACTIVITY)) {
  53. this.ac.processNode(node);
  54. } else if (node.matches(selectors.DIV.BUTTON)) {
  55. this.ui.assignLoadMore(node);
  56. }
  57. }
  58. }
  59.  
  60. loadMoreOrReset = () => {
  61. if (this.ac.currentLoadCount < this.config.options.targetLoadCount && this.ui.userPressed) {
  62. this.ui.clickLoadMore();
  63. } else {
  64. this.ac.resetLoadCount();
  65. this.ui.resetState();
  66. }
  67. }
  68.  
  69. isAllowedUrl = () => {
  70. const allowedPatterns = Object.keys(this.URLS).filter(pattern => this.config.runOn[pattern]);
  71.  
  72. return allowedPatterns.some(pattern => {
  73. const regex = new RegExp(this.URLS[pattern].replace('*', '.*'));
  74. return regex.test(window.location.href);
  75. });
  76. }
  77.  
  78. initializeObserver = () => {
  79. this.observer = new MutationObserver(this.observeMutations);
  80. this.observer.observe(document.body, { childList: true, subtree: true });
  81. }
  82.  
  83. URLS = {
  84. home: 'https://anilist.co/home',
  85. social: 'https://anilist.co/*/social',
  86. profile: 'https://anilist.co/user/*/',
  87. guestHome: 'https://anilist.co/social',
  88. };
  89. }
  90.  
  91. class ActivityHandler {
  92. constructor(config) {
  93. this.currentLoadCount = 0;
  94. this.config = config;
  95. this.linked = {
  96. TRUE: 1,
  97. FALSE: 0,
  98. NONE: -1,
  99. };
  100. }
  101.  
  102. CONDITIONS_MAP = new Map([
  103. ['uncommented', (node, reverse) => reverse ? !this.evaluateUncommentedRemoval(node) : this.evaluateUncommentedRemoval(node)],
  104. ['unliked', (node, reverse) => reverse ? !this.evaluateUnlikedRemoval(node) : this.evaluateUnlikedRemoval(node)],
  105. ['text', (node, reverse) => reverse ? !this.evaluateTextRemoval(node) : this.evaluateTextRemoval(node)],
  106. ['images', (node, reverse) => reverse ? !this.evaluateImageRemoval(node) : this.evaluateImageRemoval(node)],
  107. ['gifs', (node, reverse) => reverse ? !this.evaluateGifRemoval(node) : this.evaluateGifRemoval(node)],
  108. ['videos', (node, reverse) => reverse ? !this.evaluateVideoRemoval(node) : this.evaluateVideoRemoval(node)],
  109. ['containsStrings', (node, reverse) => this.evaluateStringRemoval(node, reverse)],
  110. ]);
  111.  
  112. processNode(node) {
  113. const { options: { reverseConditions } } = this.config;
  114. const linkedResult = this.evaluateLinkedConditions(node);
  115.  
  116. const shouldRemoveNode = reverseConditions
  117. ? this.evaluateReverseConditions(node, linkedResult)
  118. : this.evaluateNormalConditions(node, linkedResult);
  119.  
  120. shouldRemoveNode ? node.remove() : this.currentLoadCount++;
  121. }
  122.  
  123. evaluateLinkedConditions(node) {
  124. const { options: { linkedConditions } } = this.config;
  125. this.linkedConditionsFlat = linkedConditions.flat();
  126.  
  127. if (this.linkedConditionsFlat.length === 0) {
  128. return this.linked.NONE;
  129. }
  130.  
  131. const conditions = this.extractLinkedConditions(linkedConditions);
  132. const checkResult = conditions.map(c => this.evaluateConditionList(node, c));
  133.  
  134. return (checkResult.includes(true) && (!this.config.options.reverseConditions || !checkResult.includes(false)))
  135. ? this.linked.TRUE
  136. : this.linked.FALSE;
  137. }
  138.  
  139. evaluateReverseConditions(node, linkedResult) {
  140. const { remove, options: { reverseConditions } } = this.config;
  141.  
  142. const checkedConditions = Array.from(this.CONDITIONS_MAP)
  143. .filter(([name]) => !this.isConditionInLinked(name) && (remove[name] === true || remove[name].length > 0))
  144. .map(([, condition]) => condition(node, reverseConditions));
  145.  
  146. return linkedResult !== this.linked.FALSE && !checkedConditions.includes(false)
  147. && (linkedResult === this.linked.TRUE || checkedConditions.includes(true));
  148. }
  149.  
  150. evaluateNormalConditions(node, linkedResult) {
  151. const { remove, options: { reverseConditions } } = this.config;
  152.  
  153. return linkedResult === this.linked.TRUE || [...this.CONDITIONS_MAP].some(([name, condition]) =>
  154. !this.isConditionInLinked(name) && remove[name] && condition(node, reverseConditions),
  155. );
  156. }
  157.  
  158. evaluateConditionList(node, conditionList) {
  159. const { options: { reverseConditions } } = this.config;
  160.  
  161. return reverseConditions
  162. ? conditionList.some(condition => this.CONDITIONS_MAP.get(condition)(node, reverseConditions))
  163. : conditionList.every(condition => this.CONDITIONS_MAP.get(condition)(node, reverseConditions));
  164. }
  165.  
  166. extractLinkedConditions(linkedConditions) {
  167. return linkedConditions.every(condition => typeof condition === 'string')
  168. && !linkedConditions.some(condition => Array.isArray(condition))
  169. ? [linkedConditions]
  170. : linkedConditions.map(condition => Array.isArray(condition)
  171. ? condition
  172. : [condition]);
  173. }
  174.  
  175. isConditionInLinked(condition) {
  176. return this.linkedConditionsFlat.includes(condition);
  177. }
  178.  
  179. evaluateStringRemoval = (node, reversed) => {
  180. const { remove: { containsStrings }, options: { caseSensitive } } = this.config;
  181.  
  182. if (containsStrings.flat().length === 0) {
  183. return false;
  184. }
  185.  
  186. const containsString = (nodeText, strings) => !caseSensitive
  187. ? nodeText.toLowerCase().includes(strings.toLowerCase())
  188. : nodeText.includes(strings);
  189.  
  190. const checkStrings = (strings) => Array.isArray(strings)
  191. ? strings.every(str => containsString(node.textContent, str))
  192. : containsString(node.textContent, strings);
  193.  
  194. return reversed
  195. ? !containsStrings.some(checkStrings)
  196. : containsStrings.some(checkStrings);
  197. };
  198.  
  199. evaluateTextRemoval = (node) =>
  200. (node.classList.contains(selectors.ACTIVITY.TEXT) || node.classList.contains(selectors.ACTIVITY.MESSAGE))
  201. && !(this.evaluateImageRemoval(node) || this.evaluateGifRemoval(node) || this.evaluateVideoRemoval(node));
  202.  
  203. evaluateVideoRemoval = (node) => node?.querySelector(selectors.CLASS.VIDEO) || node?.querySelector(selectors.SPAN.YOUTUBE);
  204.  
  205. evaluateImageRemoval = (node) => node?.querySelector(selectors.CLASS.IMAGE) && !node.querySelector(selectors.CLASS.IMAGE).src.includes('.gif');
  206.  
  207. evaluateGifRemoval = (node) => node?.querySelector(selectors.CLASS.IMAGE)?.src.includes('.gif');
  208.  
  209. evaluateUncommentedRemoval = (node) => !node.querySelector(selectors.DIV.REPLIES)?.querySelector(selectors.SPAN.COUNT);
  210.  
  211. evaluateUnlikedRemoval = (node) => !node.querySelector(selectors.DIV.LIKES)?.querySelector(selectors.SPAN.COUNT);
  212.  
  213. resetLoadCount = () => this.currentLoadCount = 0;
  214. }
  215.  
  216. class UIHandler {
  217. constructor() {
  218. this.userPressed = true;
  219. this.cancel = null;
  220. this.loadMore = null;
  221. }
  222.  
  223. assignLoadMore = (button) => {
  224. this.loadMore = button;
  225. this.loadMore.addEventListener('click', () => {
  226. this.userPressed = true;
  227. this.triggerScrollEvents();
  228. this.displayCancel();
  229. });
  230. };
  231.  
  232. clickLoadMore = () => this.loadMore?.click() ?? null;
  233.  
  234. resetState = () => {
  235. this.userPressed = false;
  236. this.hideCancel();
  237. };
  238.  
  239. displayCancel = () => {
  240. this.cancel ? this.cancel.style.display = 'block' : this.createCancel();
  241. }
  242.  
  243. hideCancel = () => {
  244. if (this.cancel) {
  245. this.cancel.style.display = 'none';
  246. }
  247. };
  248.  
  249. triggerScrollEvents = () => {
  250. const domEvent = new Event('scroll', { bubbles: true });
  251. const intervalId = setInterval(() => this.userPressed
  252. ? window.dispatchEvent(domEvent)
  253. : clearInterval(intervalId), 100);
  254. };
  255.  
  256. createCancel = () => {
  257. const BUTTON_STYLE = `
  258. position: fixed;
  259. bottom: 10px;
  260. right: 10px;
  261. z-index: 9999;
  262. line-height: 1.3;
  263. background-color: rgb(var(--color-background-blue-dark));
  264. color: rgb(var(--color-text-bright));
  265. font: 1.6rem 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
  266. -webkit-font-smoothing: antialiased;
  267. box-sizing: border-box;
  268. --button-color: rgb(var(--color-blue));
  269. `;
  270.  
  271. this.cancel = Object.assign(document.createElement('button'), {
  272. textContent: 'Cancel',
  273. className: 'cancel-button',
  274. style: BUTTON_STYLE,
  275. onclick: () => {
  276. this.userPressed = false;
  277. this.cancel.style.display = 'none';
  278. },
  279. });
  280.  
  281. document.body.appendChild(this.cancel);
  282. };
  283. }
  284.  
  285. class ConfigValidator {
  286. constructor(config) {
  287. this.config = config;
  288. this.errors = [];
  289. }
  290.  
  291. validate() {
  292. this.validatePositiveNonZeroInteger('options.targetLoadCount', 'options.targetLoadCount');
  293. this.validateLinkedConditions('options.linkedConditions');
  294. this.validateStringArrays(['remove.containsStrings', 'options.linkedConditions']);
  295. this.validateBooleans(['remove.uncommented', 'remove.unliked', 'remove.text', 'remove.images', 'remove.gifs', 'remove.videos', 'options.caseSensitive', 'options.reverseConditions', 'runOn.home', 'runOn.social', 'runOn.profile', 'runOn.guestHome']);
  296.  
  297. if (this.errors.length > 0) {
  298. throw new Error(`Script disabled due to configuration errors: ${this.errors.join(', ')}`);
  299. }
  300. }
  301.  
  302. validateBooleans(keys) {
  303. keys.forEach(key => {
  304. const value = this.getConfigValue(key);
  305. typeof value !== 'boolean' ? this.errors.push(`${key} should be a boolean`) : null;
  306. });
  307. }
  308.  
  309. validatePositiveNonZeroInteger(key, configKey) {
  310. const value = this.getConfigValue(configKey);
  311. if (!(value > 0 && Number.isInteger(value))) {
  312. this.errors.push(`${key} should be a positive non-zero integer`);
  313. }
  314. }
  315.  
  316. validateStringArrays(keys) {
  317. for (const key of keys) {
  318. const value = this.getConfigValue(key);
  319. if (!Array.isArray(value)) {
  320. this.errors.push(`${key} should be an array`);
  321. } else if (!this.validateArrayContents(value)) {
  322. this.errors.push(`${key} should only contain strings`);
  323. }
  324. }
  325. }
  326.  
  327. validateArrayContents(arr) {
  328. return arr.every(element => {
  329. if (Array.isArray(element)) {
  330. return this.validateArrayContents(element);
  331. }
  332. return typeof element === 'string';
  333. });
  334. }
  335.  
  336. validateLinkedConditions(configKey) {
  337. const linkedConditions = this.getConfigValue(configKey).flat();
  338. const allowedConditions = ['uncommented', 'unliked', 'text', 'images', 'gifs', 'videos', 'containsStrings'];
  339.  
  340. if (linkedConditions.some(condition => !allowedConditions.includes(condition))) {
  341. this.errors.push(`${configKey} should only contain the following strings: ${allowedConditions.join(', ')}`);
  342. }
  343. }
  344.  
  345. getConfigValue(key) {
  346. return key.split('.').reduce((value, k) => value[k], this.config);
  347. }
  348. }
  349.  
  350. const selectors = {
  351. DIV: {
  352. BUTTON: 'div.load-more',
  353. ACTIVITY: 'div.activity-entry',
  354. REPLIES: 'div.action.replies',
  355. LIKES: 'div.action.likes',
  356. },
  357. SPAN: {
  358. COUNT: 'span.count',
  359. YOUTUBE: 'span.youtube',
  360. },
  361. ACTIVITY: {
  362. TEXT: 'activity-text',
  363. MESSAGE: 'activity-message',
  364. },
  365. CLASS: {
  366. IMAGE: 'img',
  367. VIDEO: 'video',
  368. },
  369. };
  370.  
  371. function main() {
  372. try {
  373. new ConfigValidator(config).validate();
  374. } catch (error) {
  375. console.error(error.message);
  376. return;
  377. }
  378.  
  379. const uiHandler = new UIHandler();
  380. const activityHandler = new ActivityHandler(config);
  381. const mainApp = new MainApp(activityHandler, uiHandler, config);
  382.  
  383. mainApp.initializeObserver();
  384. }
  385.  
  386. main();