YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2024-07-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Chat Filter
  3. // @version 1.16
  4. // @description Filters messages in YouTube stream chat.
  5. // @author Callum Latham
  6. // @namespace https://greasyfork.org/users/696211-ctl2
  7. // @license MIT
  8. // @match *://www.youtube.com/*
  9. // @match *://youtube.com/*
  10. // @exclude *://www.youtube.com/embed/*
  11. // @exclude *://youtube.com/embed/*
  12. // @require https://update.greasyfork.org/scripts/446506/1407437/%24Config.js
  13. // @require https://greasyfork.org/scripts/449472-boolean/code/$Boolean.js?version=1081058
  14. // @grant GM.setValue
  15. // @grant GM.getValue
  16. // @grant GM.deleteValue
  17. // ==/UserScript==
  18.  
  19. /* global $Config */
  20. /* global $Boolean */
  21.  
  22. // Don't run outside the chat frame
  23. if (!window.frameElement || window.frameElement.id !== 'chatframe') {
  24. // noinspection JSAnnotator
  25. return;
  26. }
  27.  
  28. window.addEventListener('load', async () => {
  29. // STATIC CONSTS
  30. const LONG_PRESS_TIME = 400;
  31. const ACTIVE_COLOUR = 'var(--yt-spec-call-to-action)';
  32. const CHAT_LIST_SELECTOR = '#items.yt-live-chat-item-list-renderer';
  33. const FILTER_CLASS = 'cf';
  34. const TAGS_FILTERABLE = [
  35. 'YT-LIVE-CHAT-TEXT-MESSAGE-RENDERER',
  36. 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER',
  37. 'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER',
  38. 'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-PURCHASE-ANNOUNCEMENT-RENDERER',
  39. 'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-REDEMPTION-ANNOUNCEMENT-RENDERER',
  40. 'YT-LIVE-CHAT-PAID-STICKER-RENDERER',
  41. ];
  42. const PRIORITIES = {
  43. VERIFIED: 'Verification Badge',
  44. MODERATOR: 'Moderator Badge',
  45. MEMBER: 'Membership Badge',
  46. LONG: 'Long',
  47. RECENT: 'Recent',
  48. SUPERCHAT: 'Superchat',
  49. STICKER: 'Sticker',
  50. MEMBERSHIP_RENEWAL: 'Membership Purchase',
  51. MEMBERSHIP_GIFT_OUT: 'Membership Gift (Given)',
  52. MEMBERSHIP_GIFT_IN: 'Membership Gift (Received)',
  53. EMOJI: 'Emojis',
  54. };
  55. // ELEMENT CONSTS
  56. const STREAMER = window.parent.document.querySelector('#upload-info > #channel-name').innerText;
  57. const ROOT_ELEMENT = document.body.querySelector('#chat');
  58. const [BUTTON, SVG, COUNTER] = await (async () => {
  59. const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
  60. const [button, svgContainer, svg] = await new Promise((resolve) => {
  61. const template = document.body.querySelector('#live-chat-header-context-menu');
  62. const button = template.querySelector('button').cloneNode(true);
  63. const svgContainer = button.querySelector('yt-icon');
  64. button.style.visibility = 'hidden';
  65. button.querySelector('yt-touch-feedback-shape').remove();
  66. template.parentElement.insertBefore(button, template);
  67. window.setTimeout(() => {
  68. const path = document.createElementNS(SVG_NAMESPACE, 'path');
  69. path.setAttribute('d', 'M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z');
  70. const rectangle = document.createElementNS(SVG_NAMESPACE, 'rect');
  71. rectangle.setAttribute('x', '13.95');
  72. rectangle.setAttribute('y', '0');
  73. rectangle.setAttribute('width', '294');
  74. rectangle.setAttribute('height', '45');
  75. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  76. svg.setAttribute('viewBox', '-50 -50 400 400');
  77. svg.setAttribute('x', '0');
  78. svg.setAttribute('y', '0');
  79. svg.setAttribute('focusable', 'false');
  80. svg.append(path, rectangle);
  81. svgContainer.innerHTML = '';
  82. svgContainer.append(svg);
  83. button.style.removeProperty('visibility');
  84. button.style.setProperty('display', 'contents');
  85. resolve([button, svgContainer, svg]);
  86. }, 0);
  87. });
  88. const counter = (() => {
  89. const container = document.createElement('div');
  90. container.style.position = 'absolute';
  91. container.style.left = '9px';
  92. container.style.bottom = '9px';
  93. container.style.fontSize = '1.1em';
  94. container.style.lineHeight = 'normal';
  95. container.style.width = '1.6em';
  96. container.style.display = 'flex';
  97. container.style.alignItems = 'center';
  98. const svg = (() => {
  99. const circle = document.createElementNS(SVG_NAMESPACE, 'circle');
  100. circle.setAttribute('r', '50');
  101. circle.style.color = 'var(--yt-live-chat-header-background-color)';
  102. circle.style.opacity = '0.65';
  103. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  104. svg.setAttribute('viewBox', '-70 -70 140 140');
  105. svg.append(circle);
  106. return svg;
  107. })();
  108. const text = document.createElement('span');
  109. text.style.position = 'absolute';
  110. text.style.width = '100%';
  111. text.innerText = '?';
  112. container.append(text, svg);
  113. svgContainer.append(container);
  114. return text;
  115. })();
  116. return [button, svg, counter];
  117. })();
  118. // STATE INTERFACES
  119. const $active = new $Boolean('YTCF_IS_ACTIVE');
  120. const $config = new $Config(
  121. 'YTCF_TREE',
  122. (() => {
  123. const regexPredicate = (value) => {
  124. try {
  125. RegExp(value);
  126. } catch (_) {
  127. return 'Value must be a valid regular expression.';
  128. }
  129. return true;
  130. };
  131. return {
  132. children: [
  133. {
  134. label: 'Filters',
  135. children: [],
  136. seed: {
  137. label: 'Description',
  138. value: '',
  139. children: [
  140. {
  141. label: 'Streamer Regex',
  142. children: [],
  143. seed: {
  144. value: '^',
  145. predicate: regexPredicate,
  146. },
  147. },
  148. {
  149. label: 'Author Regex',
  150. children: [],
  151. seed: {
  152. value: '^',
  153. predicate: regexPredicate,
  154. },
  155. },
  156. {
  157. label: 'Message Regex',
  158. children: [],
  159. seed: {
  160. value: '^',
  161. predicate: regexPredicate,
  162. },
  163. },
  164. ],
  165. },
  166. },
  167. {
  168. label: 'Options',
  169. children: [
  170. {
  171. label: 'Case-Sensitive Regex?',
  172. value: false,
  173. },
  174. {
  175. label: 'Pause on Mouse Over?',
  176. value: false,
  177. },
  178. {
  179. label: 'Queue Time (ms)',
  180. value: 0,
  181. predicate: (value) => value >= 0 ? true : 'Queue time must be positive',
  182. },
  183. ],
  184. },
  185. {
  186. label: 'Preferences',
  187. children: [
  188. {
  189. label: 'Requirements',
  190. children: [
  191. {
  192. label: 'OR',
  193. children: [],
  194. poolId: 0,
  195. },
  196. {
  197. label: 'AND',
  198. children: [],
  199. poolId: 0,
  200. },
  201. ],
  202. },
  203. {
  204. label: 'Priorities (High to Low)',
  205. poolId: 0,
  206. children: Object.values(PRIORITIES).map((label) => ({
  207. label,
  208. value: label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN,
  209. })),
  210. },
  211. ],
  212. },
  213. ],
  214. };
  215. })(),
  216. (() => {
  217. const EVALUATORS = (() => {
  218. const getEvaluator = (evaluator, isDesired) => isDesired ? evaluator : (_) => 1 - evaluator(_);
  219. return {
  220. // Special tests
  221. [PRIORITIES.RECENT]: getEvaluator.bind(null, () => 1),
  222. [PRIORITIES.LONG]: getEvaluator.bind(null, (_) => _.querySelector('#message').textContent.length),
  223. // Tests for message type
  224. [PRIORITIES.SUPERCHAT]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-paid-message-renderer')),
  225. [PRIORITIES.STICKER]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-paid-sticker-renderer')),
  226. [PRIORITIES.MEMBERSHIP_RENEWAL]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-membership-item-renderer')),
  227. [PRIORITIES.MEMBERSHIP_GIFT_OUT]: getEvaluator.bind(null, (_) => _.matches('ytd-sponsorships-live-chat-gift-purchase-announcement-renderer')),
  228. [PRIORITIES.MEMBERSHIP_GIFT_IN]: getEvaluator.bind(null, (_) => _.matches('ytd-sponsorships-live-chat-gift-redemption-announcement-renderer')),
  229. // Tests for descendant element presence
  230. [PRIORITIES.EMOJI]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('.emoji'))),
  231. [PRIORITIES.MEMBER]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chat-badges > [type=member]'))),
  232. [PRIORITIES.MODERATOR]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chip-badges > [type=verified]'))),
  233. [PRIORITIES.VERIFIED]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chat-badges > [type=moderator]'))),
  234. };
  235. })();
  236. return ([rawFilters, options, {'children': [{'children': [softRequirements, hardRequirements]}, priorities]}]) => ({
  237. filters: (() => {
  238. const filters = [];
  239. const getRegex = options.children[0].value ?
  240. ({value}) => new RegExp(value) :
  241. ({value}) => new RegExp(value, 'i');
  242. const matchesStreamer = (node) => getRegex(node).test(STREAMER);
  243. for (const filter of rawFilters.children) {
  244. const [{'children': streamers}, {'children': authors}, {'children': messages}] = filter.children;
  245. if (streamers.length === 0 || streamers.some(matchesStreamer)) {
  246. filters.push({
  247. authors: authors.map(getRegex),
  248. messages: messages.map(getRegex),
  249. });
  250. }
  251. }
  252. return filters;
  253. })(),
  254. pauseOnHover: options.children[1].value,
  255. queueTime: options.children[2].value,
  256. requirements: {
  257. soft: softRequirements.children.map(({label, 'value': isDesired}) => EVALUATORS[label](isDesired)),
  258. hard: hardRequirements.children.map(({label, 'value': isDesired}) => EVALUATORS[label](isDesired)),
  259. },
  260. comparitors: (() => {
  261. const getComparitor = (getValue, low, high) => {
  262. low = getValue(low);
  263. high = getValue(high);
  264. return low < high ? -1 : low === high ? 0 : 1;
  265. };
  266. return priorities.children.map(({label, 'value': isDesired}) => getComparitor.bind(null, EVALUATORS[label](isDesired)));
  267. })(),
  268. });
  269. })(),
  270. 'YouTube Chat Filter',
  271. {
  272. headBase: '#ff0000',
  273. headButtonExit: '#000000',
  274. borderHead: '#ffffff',
  275. nodeBase: ['#222222', '#111111'],
  276. borderTooltip: '#570000',
  277. },
  278. {
  279. zIndex: 10000,
  280. scrollbarColor: 'initial',
  281. },
  282. );
  283. // CSS
  284. (function style() {
  285. function addStyle(sheet, selector, rules) {
  286. const ruleString = rules.map(
  287. ([selector, rule]) => `${selector}:${typeof rule === 'function' ? rule() : rule} !important;`,
  288. );
  289. sheet.insertRule(`${selector}{${ruleString.join('')}}`);
  290. }
  291. const styleElement = document.createElement('style');
  292. const {sheet} = document.head.appendChild(styleElement);
  293. const styles = [
  294. [`${CHAT_LIST_SELECTOR}`, [['bottom', 'inherit']]],
  295. [`${CHAT_LIST_SELECTOR} > :not(.${FILTER_CLASS})`, [['display', 'none']]],
  296. ];
  297. for (const style of styles) {
  298. addStyle(sheet, style[0], style[1]);
  299. }
  300. })();
  301. // STATE
  302. let queuedPost;
  303. // FILTERING
  304. function doFilter(isInitial = true) {
  305. const chatListElement = ROOT_ELEMENT.querySelector(CHAT_LIST_SELECTOR);
  306. let doQueue = false;
  307. let paused = false;
  308. function showPost(post, queueNext) {
  309. const config = $config.get();
  310. post.classList.add(FILTER_CLASS);
  311. queuedPost = undefined;
  312. if (queueNext && config && config.queueTime > 0) {
  313. // Start queueing
  314. doQueue = true;
  315. window.setTimeout(() => {
  316. doQueue = false;
  317. // Unqueue
  318. if (!paused) {
  319. acceptPost();
  320. }
  321. }, config.queueTime);
  322. }
  323. }
  324. function acceptPost(post = queuedPost, allowQueue = true) {
  325. if (!post) {
  326. return;
  327. }
  328. if (allowQueue && (doQueue || paused)) {
  329. queuedPost = post;
  330. } else {
  331. showPost(post, allowQueue);
  332. }
  333. }
  334. window.document.body.addEventListener('mouseenter', () => {
  335. const config = $config.get();
  336. if (config && config.pauseOnHover) {
  337. paused = true;
  338. }
  339. });
  340. window.document.body.addEventListener('mouseleave', () => {
  341. const config = $config.get();
  342. paused = false;
  343. if (config && config.pauseOnHover) {
  344. acceptPost();
  345. }
  346. });
  347. function processPost(post, allowQueue = true) {
  348. const config = $config.get();
  349. const isFilterable = config && $active.get() && TAGS_FILTERABLE.includes(post.tagName);
  350. if (isFilterable) {
  351. if (
  352. config.filters.some((filter) =>
  353. // Test author filter
  354. (filter.authors.length > 0 && filter.authors.some((_) => _.test(post.querySelector('#author-name')?.textContent)))
  355. // Test message filter
  356. || (filter.messages.length > 0 && filter.messages.some((_) => _.test(post.querySelector('#message')?.textContent))),
  357. )
  358. // Test requirements
  359. || (config.requirements.soft.length > 0 && !config.requirements.soft.some((passes) => passes(post)))
  360. || config.requirements.hard.some((passes) => !passes(post))
  361. ) {
  362. return;
  363. }
  364. // Test inferior to queued post
  365. if (queuedPost) {
  366. for (const comparitor of config.comparitors) {
  367. const rating = comparitor(post, queuedPost);
  368. if (rating < 0) {
  369. return;
  370. }
  371. if (rating > 0) {
  372. break;
  373. }
  374. }
  375. }
  376. }
  377. acceptPost(post, isFilterable && allowQueue);
  378. }
  379. if (isInitial) {
  380. // Process initial messages
  381. for (const post of chatListElement.children) {
  382. processPost(post, false);
  383. }
  384. // Re-sizes the chat after removing initial messages
  385. chatListElement.parentElement.style.height = `${chatListElement.clientHeight}px`;
  386. // Restart if the chat element gets replaced
  387. // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
  388. new MutationObserver((mutations) => {
  389. for (const {addedNodes} of mutations) {
  390. for (const node of addedNodes) {
  391. if (node.matches('yt-live-chat-item-list-renderer')) {
  392. doFilter(false);
  393. }
  394. }
  395. }
  396. }).observe(
  397. ROOT_ELEMENT.querySelector('#item-list'),
  398. {childList: true},
  399. );
  400. }
  401. // Handle new posts
  402. new MutationObserver((mutations) => {
  403. for (const {addedNodes} of mutations) {
  404. for (const addedNode of addedNodes) {
  405. processPost(addedNode);
  406. }
  407. }
  408. }).observe(
  409. chatListElement,
  410. {childList: true},
  411. );
  412. }
  413. // MAIN
  414. (() => {
  415. let timeout;
  416. const updateSvg = () => {
  417. SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  418. };
  419. const updateCounter = () => {
  420. const config = $config.get();
  421. const count = config ? config.filters.length : 0;
  422. queuedPost = undefined;
  423. COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  424. COUNTER.innerText = `${count}`;
  425. };
  426. const onShortClick = (event) => {
  427. if (timeout && event.button === 0) {
  428. timeout = window.clearTimeout(timeout);
  429. $active.toggle();
  430. updateSvg();
  431. }
  432. };
  433. const onLongClick = () => {
  434. timeout = undefined;
  435. $config.edit()
  436. .then(updateCounter)
  437. .catch(({message}) => {
  438. if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
  439. $config.reset();
  440. updateCounter();
  441. }
  442. });
  443. };
  444. Promise.all([
  445. $active.init()
  446. .then(updateSvg),
  447. $config.ready()
  448. .catch(async (e) => {
  449. const tree = await GM.getValue('YTCF_TREE');
  450. const {children} = tree.children[2].children[1];
  451. if (children.some(({label}) => label === PRIORITIES.STICKER)) {
  452. throw e;
  453. }
  454. // Copy superchat info onto new sticker entry
  455. const refIndex = children.findIndex(({label}) => label === PRIORITIES.SUPERCHAT);
  456. // Try fixing error by adding the new 'Sticker' entry to the 'priorities' subtree
  457. children.splice(refIndex, 0, {
  458. label: PRIORITIES.STICKER,
  459. value: children[refIndex].value,
  460. });
  461. await GM.setValue('YTCF_TREE', tree);
  462. await $config.ready();
  463. })
  464. .finally(updateCounter),
  465. ])
  466. .then(() => {
  467. // Start filtering
  468. doFilter();
  469. // Add short click listener
  470. BUTTON.addEventListener('mouseup', onShortClick);
  471. // Add long click listener
  472. BUTTON.addEventListener('mousedown', (event) => {
  473. if (event.button === 0) {
  474. timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
  475. }
  476. });
  477. });
  478. })();
  479. });