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.5
  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. this.URLS = {
  42. home: 'https://anilist.co/home',
  43. social: 'https://anilist.co/*/social',
  44. profile: 'https://anilist.co/user/*/',
  45. guestHome: 'https://anilist.co/social',
  46. };
  47. }
  48.  
  49. initializeObserver() {
  50. this.observer = new MutationObserver(this._observeMutations.bind(this));
  51. this.observer.observe(document.body, { childList: true, subtree: true });
  52. }
  53.  
  54. _observeMutations(mutations) {
  55. if (this._isUrlAllowed()) {
  56. mutations.forEach(mutation => mutation.addedNodes.forEach(node => this._handleAddedNode(node)));
  57. this._processLoadOrReset();
  58. }
  59. }
  60.  
  61. _handleAddedNode(node) {
  62. if (!(node instanceof HTMLElement)) {
  63. return;
  64. }
  65.  
  66. if (node.matches(SELECTORS.DIV.ACTIVITY)) {
  67. this.ac.processActivityNode(node);
  68. } else if (node.matches(SELECTORS.DIV.BUTTON)) {
  69. this.ui.bindLoadMoreButton(node);
  70. } else if (node.matches(SELECTORS.DIV.MARKDOWN)) {
  71. const entry = node.closest(SELECTORS.DIV.ACTIVITY);
  72. if (entry) this.ac.processActivityNode(entry);
  73. }
  74. }
  75.  
  76. _processLoadOrReset() {
  77. if (this.ac.currentLoadCount < this.config.options.targetLoadCount && this.ui.userPressed) {
  78. this.ui.triggerLoadMore();
  79. } else {
  80. this.ac._resetLoadCount();
  81. this.ui.resetUIState();
  82. }
  83. }
  84.  
  85. _isUrlAllowed() {
  86. const allowedPatterns = Object.keys(this.URLS).filter(pattern => this.config.runOn[pattern]);
  87.  
  88. return allowedPatterns.some(pattern => {
  89. const regex = new RegExp(this.URLS[pattern].replace('*', '.*'));
  90. return regex.test(window.location.href);
  91. });
  92. }
  93. }
  94.  
  95. class ActivityHandler {
  96. constructor(config) {
  97. this.currentLoadCount = 0;
  98. this.config = config;
  99. this.LINKED = { TRUE: 1, FALSE: 0, NONE: -1 };
  100.  
  101. const wrap = method => (node, reverse) => {
  102. const res = method.call(this, node);
  103. return reverse ? !res : res;
  104. };
  105.  
  106. const handlers = {
  107. uncommented: this._evaluateUncommentedRemoval,
  108. unliked: this._evaluateUnlikedRemoval,
  109. text: this._evaluateTextRemoval,
  110. images: this._evaluateImageRemoval,
  111. gifs: this._evaluateGifRemoval,
  112. videos: this._evaluateVideoRemoval,
  113. containsStrings: this._evaluateStringRemoval
  114. };
  115.  
  116. this.CONDITIONS_MAP = new Map(
  117. Object.entries(handlers).map(
  118. ([name, method]) => [name, wrap(method)]
  119. )
  120. );
  121. }
  122.  
  123. processActivityNode(node) {
  124. const { options: { reverseConditions, linkedConditions } } = this.config;
  125. this.linkedConditionsFlat = linkedConditions.flat();
  126.  
  127. const linkedResult = this._evaluateLinkedConditions(node);
  128. const shouldRemove = reverseConditions
  129. ? this._evaluateReverseConditions(node, linkedResult)
  130. : this._evaluateNormalConditions(node, linkedResult);
  131.  
  132. shouldRemove ? node.remove() : this.currentLoadCount++;
  133. }
  134.  
  135. _evaluateLinkedConditions(node) {
  136. const { options: { linkedConditions } } = this.config;
  137.  
  138. if (this.linkedConditionsFlat.length === 0) {
  139. return this.LINKED.NONE;
  140. }
  141.  
  142. const lists = this._extractLinkedConditions(linkedConditions);
  143. const results = lists.map(list => this._evaluateConditionList(node, list));
  144. const hasTrue = results.some(Boolean);
  145. const hasFalse = results.some(r => !r);
  146.  
  147. return hasTrue && (!this.config.options.reverseConditions || !hasFalse)
  148. ? this.LINKED.TRUE
  149. : this.LINKED.FALSE;
  150. }
  151.  
  152. _evaluateReverseConditions(node, linkedResult) {
  153. const { options: { reverseConditions } } = this.config;
  154.  
  155. const results = this._getActiveConditionFunctions().map(fn => fn(node, reverseConditions));
  156.  
  157. return linkedResult !== this.LINKED.FALSE
  158. && !results.includes(false)
  159. && (linkedResult === this.LINKED.TRUE || results.includes(true));
  160. }
  161.  
  162. _evaluateNormalConditions(node, linkedResult) {
  163. const { options: { reverseConditions } } = this.config;
  164.  
  165. const anyMatch = this._getActiveConditionFunctions().some(fn => fn(node, reverseConditions));
  166.  
  167. return linkedResult === this.LINKED.TRUE || anyMatch;
  168. }
  169.  
  170. _getActiveConditionFunctions() {
  171. const { remove } = this.config;
  172.  
  173. return [...this.CONDITIONS_MAP]
  174. .filter(([name]) => {
  175. if (this.linkedConditionsFlat.includes(name)) return false;
  176. const cfg = remove[name];
  177. return cfg === true || (Array.isArray(cfg) && cfg.flat().length > 0);
  178. })
  179. .map(([, fn]) => fn);
  180. }
  181.  
  182. _evaluateConditionList(node, list) {
  183. const { options: { reverseConditions } } = this.config;
  184.  
  185. return reverseConditions
  186. ? list.some(cond => this.CONDITIONS_MAP.get(cond)(node, reverseConditions))
  187. : list.every(cond => this.CONDITIONS_MAP.get(cond)(node, reverseConditions));
  188. }
  189.  
  190. _extractLinkedConditions(linkedConditions) {
  191. const isNested = linkedConditions.some(Array.isArray);
  192.  
  193. return isNested
  194. ? linkedConditions.map(c => Array.isArray(c) ? c : [c])
  195. : [linkedConditions];
  196. }
  197.  
  198. _evaluateStringRemoval(node) {
  199. const { remove: { containsStrings }, options: { caseSensitive } } = this.config;
  200.  
  201. const matches = substr => {
  202. const text = node.textContent;
  203.  
  204. return caseSensitive
  205. ? text.includes(substr)
  206. : text.toLowerCase().includes(substr.toLowerCase());
  207. };
  208.  
  209. return containsStrings.some(group =>
  210. Array.isArray(group)
  211. ? group.every(matches)
  212. : matches(group)
  213. );
  214. }
  215.  
  216. _evaluateTextRemoval(node) {
  217. const hasTextClass =
  218. node.classList.contains(SELECTORS.ACTIVITY.TEXT) || node.classList.contains(SELECTORS.ACTIVITY.MESSAGE);
  219.  
  220. return hasTextClass && !(
  221. this._evaluateImageRemoval(node) ||
  222. this._evaluateGifRemoval(node) ||
  223. this._evaluateVideoRemoval(node)
  224. );
  225. }
  226.  
  227. _evaluateVideoRemoval(node) {
  228. return node.querySelector(SELECTORS.CLASS.VIDEO) || node.querySelector(SELECTORS.SPAN.YOUTUBE);
  229. }
  230.  
  231. _evaluateImageRemoval(node) {
  232. const img = node.querySelector(SELECTORS.CLASS.IMAGE);
  233.  
  234. return img && !img.src.includes('.gif');
  235. }
  236.  
  237. _evaluateGifRemoval(node) {
  238. const img = node.querySelector(SELECTORS.CLASS.IMAGE);
  239.  
  240. return img && img.src.includes('.gif');
  241. }
  242.  
  243. _evaluateUncommentedRemoval(node) {
  244. const replies = node.querySelector(SELECTORS.DIV.REPLIES);
  245.  
  246. return !replies || !replies.querySelector(SELECTORS.SPAN.COUNT);
  247. }
  248.  
  249. _evaluateUnlikedRemoval(node) {
  250. const likes = node.querySelector(SELECTORS.DIV.LIKES);
  251.  
  252. return !likes || !likes.querySelector(SELECTORS.SPAN.COUNT);
  253. }
  254.  
  255. _resetLoadCount() {
  256. this.currentLoadCount = 0;
  257. }
  258. }
  259.  
  260. class UIHandler {
  261. constructor() {
  262. this.userPressed = true;
  263. this.loadMoreButton = null;
  264. this.cancelButton = null;
  265. }
  266.  
  267. bindLoadMoreButton(button) {
  268. this.loadMoreButton = button;
  269. button.addEventListener('click', () => {
  270. this.userPressed = true;
  271. this._startScrollTrigger();
  272. this._showCancelButton();
  273. });
  274. }
  275.  
  276. triggerLoadMore() {
  277. this.loadMoreButton?.click();
  278. }
  279.  
  280. resetUIState() {
  281. this.userPressed = false;
  282. this._hideCancelButton();
  283. }
  284.  
  285. displayErrorMessage(message) {
  286. if (!this.errorContainer) {
  287. const style =
  288. `position: fixed;` +
  289. `bottom: 10px;` +
  290. `right: 10px;` +
  291. `z-index: 10000;` +
  292. `background-color: rgba(255,0,0,0.85);` +
  293. `color: #fff;` +
  294. `padding: 12px 20px;` +
  295. `border-radius: 4px;` +
  296. `font: 1.4rem Roboto, sans-serif;` +
  297. `box-shadow: 0 2px 6px rgba(0,0,0,0.3);`;
  298.  
  299. this.errorContainer = Object.assign(
  300. document.createElement('div'),
  301. {
  302. textContent: message,
  303. className: 'config-error-message',
  304. }
  305. );
  306.  
  307. this.errorContainer.setAttribute('style', style);
  308. document.body.appendChild(this.errorContainer);
  309. } else {
  310. this.errorContainer.textContent = message;
  311. this.errorContainer.style.display = 'block';
  312. }
  313.  
  314. setTimeout(() => {
  315. if (this.errorContainer) {
  316. this.errorContainer.style.display = 'none';
  317. }
  318. }, 5000);
  319. }
  320.  
  321. _createCancelButton() {
  322. if (this.cancelButton) {
  323. this.cancelButton.style.display = 'block';
  324. return;
  325. }
  326.  
  327. const style =
  328. `position: fixed;` +
  329. `bottom: 10px;` +
  330. `right: 10px;` +
  331. `z-index: 9999;` +
  332. `line-height: 1.3;` +
  333. `background-color: rgb(var(--color-background-blue-dark));` +
  334. `color: rgb(var(--color-text-bright));` +
  335. `font: 1.6rem Roboto, sans-serif;` +
  336. `box-sizing: border-box;`;
  337.  
  338. this.cancelButton = document.createElement('button');
  339. this.cancelButton.textContent = 'Cancel';
  340. this.cancelButton.className = 'cancel-button';
  341. this.cancelButton.setAttribute('style', style);
  342. this.cancelButton.addEventListener('click', () => {
  343. this.userPressed = false;
  344. this.cancelButton.style.display = 'none';
  345. });
  346.  
  347. document.body.appendChild(this.cancelButton);
  348. }
  349.  
  350. _showCancelButton() {
  351. if (this.cancelButton) {
  352. this.cancelButton.style.display = 'block';
  353. } else {
  354. this._createCancelButton();
  355. }
  356. }
  357.  
  358. _hideCancelButton() {
  359. if (this.cancelButton) {
  360. this.cancelButton.style.display = 'none';
  361. }
  362. }
  363.  
  364. _startScrollTrigger() {
  365. const event = new Event('scroll', { bubbles: true });
  366. const interval = setInterval(() => {
  367. this.userPressed
  368. ? window.dispatchEvent(event)
  369. : clearInterval(interval);
  370. }, 100);
  371. }
  372. }
  373.  
  374. class ConfigValidator {
  375. constructor(config) {
  376. this.config = config;
  377. this.errors = [];
  378. }
  379.  
  380. validateConfig() {
  381. this._validatePositiveInteger('options.targetLoadCount');
  382. this._validateStringArray('remove.containsStrings');
  383. this._validateStringArray('options.linkedConditions');
  384. this._validateLinkedConditions();
  385. this._validateBooleanSettings([
  386. 'remove.uncommented',
  387. 'remove.unliked',
  388. 'remove.text',
  389. 'remove.images',
  390. 'remove.gifs',
  391. 'remove.videos',
  392. 'options.caseSensitive',
  393. 'options.reverseConditions',
  394. 'runOn.home',
  395. 'runOn.social',
  396. 'runOn.profile',
  397. 'runOn.guestHome'
  398. ]);
  399.  
  400. if (this.errors.length) {
  401. throw new Error(`Anilist Activity Feed Filter: Script disabled due to configuration errors: ${this.errors.join(', ')}`);
  402. }
  403. }
  404.  
  405. _validateLinkedConditions() {
  406. const linked = this._flattenArray(this._getConfigValue('options.linkedConditions'));
  407. const allowed = ['uncommented', 'unliked', 'text', 'images', 'gifs', 'videos', 'containsStrings'];
  408. if (linked.some(cond => !allowed.includes(cond))) {
  409. this.errors.push(`options.linkedConditions should only contain: ${allowed.join(', ')}`);
  410. }
  411. }
  412.  
  413. _validateBooleanSettings(paths) {
  414. paths.forEach(path => {
  415. if (typeof this._getConfigValue(path) !== 'boolean') {
  416. this.errors.push(`${path} should be a boolean`);
  417. }
  418. });
  419. }
  420.  
  421. _validateStringArray(path) {
  422. const value = this._getConfigValue(path);
  423. if (!Array.isArray(value)) {
  424. this.errors.push(`${path} should be an array`);
  425. } else if (!this._flattenArray(value).every(item => typeof item === 'string')) {
  426. this.errors.push(`${path} should only contain strings`);
  427. }
  428. }
  429.  
  430. _validatePositiveInteger(path) {
  431. const value = this._getConfigValue(path);
  432. if (!Number.isInteger(value) || value <= 0) {
  433. this.errors.push(`${path} should be a positive non-zero integer`);
  434. }
  435. }
  436.  
  437. _getConfigValue(path) {
  438. return path.split('.').reduce((obj, key) => obj[key], this.config);
  439. }
  440.  
  441. _flattenArray(arr) {
  442. return arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? this._flattenArray(val) : val), []);
  443. }
  444. }
  445.  
  446. const SELECTORS = {
  447. DIV: {
  448. BUTTON: 'div.load-more',
  449. ACTIVITY: 'div.activity-entry',
  450. REPLIES: 'div.action.replies',
  451. LIKES: 'div.action.likes',
  452. MARKDOWN: 'div.markdown'
  453. },
  454. SPAN: {
  455. COUNT: 'span.count',
  456. YOUTUBE: 'span.youtube',
  457. },
  458. ACTIVITY: {
  459. TEXT: 'activity-text',
  460. MESSAGE: 'activity-message',
  461. },
  462. CLASS: {
  463. IMAGE: 'img',
  464. VIDEO: 'video',
  465. },
  466. };
  467.  
  468. function initializeApp() {
  469. const uiHandler = new UIHandler();
  470. try {
  471. new ConfigValidator(config).validateConfig();
  472. } catch (error) {
  473. uiHandler.displayErrorMessage(error.message);
  474. return;
  475. }
  476.  
  477. const activityHandler = new ActivityHandler(config);
  478. const mainApp = new MainApp(activityHandler, uiHandler, config);
  479.  
  480. mainApp.initializeObserver();
  481. }
  482.  
  483. initializeApp();