Anilist: Activity-Feed Filter

Control the content displayed in your activity feeds

当前为 2023-11-19 提交的版本,查看 最新版本

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