YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2022-08-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Chat Filter
  3. // @version 1.0
  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. // @require https://greasyfork.org/scripts/446506-config/code/$Config.js?version=1081062
  11. // @require https://greasyfork.org/scripts/449472-boolean/code/$Boolean.js?version=1081058
  12. // @grant GM.setValue
  13. // @grant GM.getValue
  14. // @grant GM.deleteValue
  15. // ==/UserScript==
  16.  
  17. // Don't run outside the chat frame
  18. if (window.frameElement.id !== 'chatframe') {
  19. // noinspection JSAnnotator
  20. return;
  21. }
  22.  
  23. window.addEventListener('load', async () => {
  24. // STATIC CONSTS
  25.  
  26. const LONG_PRESS_TIME = 400;
  27. const ACTIVE_COLOUR = 'var(--yt-spec-call-to-action)';
  28. const CHAT_LIST_SELECTOR = '#items.yt-live-chat-item-list-renderer';
  29. const FILTER_CLASS = 'cf';
  30. const TITLE = 'YouTube Chat Filter';
  31. const PRIORITIES = {
  32. 'MODE_CHANGE': 'Chat Mode Change',
  33. 'VERIFIED': 'Verification Badge',
  34. 'MODERATOR': 'Moderator Badge',
  35. 'MEMBER': 'Membership Badge',
  36. 'LONG': 'Long',
  37. 'RECENT': 'Recent',
  38. 'SUPERCHAT': 'Superchat',
  39. 'MEMBERSHIP_RENEWAL': 'Membership Purchase',
  40. 'MEMBERSHIP_GIFT_OUT': 'Membership Gift (Given)',
  41. 'MEMBERSHIP_GIFT_IN': 'Membership Gift (Received)',
  42. 'EMOJI': 'Emojis'
  43. };
  44.  
  45. // ELEMENT CONSTS
  46.  
  47. const STREAMER = window.parent.document.querySelector('#channel-name').innerText;
  48. const ROOT_ELEMENT = document.body.querySelector('#chat');
  49. const [BUTTON, SVG, COUNTER] = await (async () => {
  50. const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
  51.  
  52. const [button, svgContainer, svg] = await new Promise((resolve) => {
  53. const template = document.body.querySelector('#overflow');
  54. const button = template.cloneNode(true);
  55. const svgContainer = button.querySelector('yt-icon');
  56.  
  57. button.style.visibility = 'hidden';
  58.  
  59. template.parentElement.insertBefore(button, template);
  60.  
  61. window.setTimeout(() => {
  62. const path = document.createElementNS(SVG_NAMESPACE, 'path');
  63.  
  64. 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');
  65.  
  66. const rectangle = document.createElementNS(SVG_NAMESPACE, 'rect');
  67.  
  68. rectangle.setAttribute('x', '13.95');
  69. rectangle.setAttribute('y', '0');
  70. rectangle.setAttribute('width', '294');
  71. rectangle.setAttribute('height', '45');
  72.  
  73. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  74.  
  75. svg.setAttribute('viewBox', '-50 -50 400 400');
  76. svg.setAttribute('x', '0');
  77. svg.setAttribute('y', '0');
  78. svg.setAttribute('focusable', 'false');
  79.  
  80. svg.append(path, rectangle);
  81.  
  82. svgContainer.innerHTML = '';
  83. svgContainer.append(svg);
  84.  
  85. button.style.removeProperty('visibility');
  86.  
  87. resolve([button, svgContainer, svg]);
  88. }, 0);
  89. });
  90.  
  91. const counter = (() => {
  92. const container = document.createElement('div');
  93.  
  94. container.style.position = 'absolute';
  95. container.style.left = '9px';
  96. container.style.bottom = '9px';
  97. container.style.fontSize = '1.1em';
  98. container.style.lineHeight = 'normal';
  99. container.style.width = '1.6em';
  100. container.style.display = 'flex';
  101. container.style.alignItems = 'center';
  102.  
  103. const svg = (() => {
  104. const circle = document.createElementNS(SVG_NAMESPACE, 'circle');
  105.  
  106. circle.setAttribute('r', '50');
  107. circle.style.color = 'var(--yt-live-chat-header-background-color)';
  108. circle.style.opacity = '0.65';
  109.  
  110. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  111.  
  112. svg.setAttribute('viewBox', '-70 -70 140 140');
  113.  
  114. svg.append(circle);
  115.  
  116. return svg;
  117. })();
  118.  
  119. const text = document.createElement('span');
  120.  
  121. text.style.position = 'absolute';
  122. text.style.width = '100%';
  123. text.innerText = '?';
  124.  
  125. container.append(text, svg);
  126.  
  127. svgContainer.append(container);
  128.  
  129. return text;
  130. })();
  131.  
  132. return [button, svg, counter];
  133. })();
  134.  
  135. // STATE INTERFACES
  136.  
  137. const $active = new $Boolean('YTCF_IS_ACTIVE');
  138.  
  139. const $config = new $Config(
  140. 'YTCF_TREE',
  141. (() => {
  142. const regexPredicate = (value) => {
  143. try {
  144. RegExp(value);
  145. } catch (_) {
  146. return 'Value must be a valid regular expression.';
  147. }
  148.  
  149. return true;
  150. };
  151.  
  152. return {
  153. 'children': [
  154. {
  155. 'label': 'Filters',
  156. 'children': [],
  157. 'seed': {
  158. 'label': 'Description',
  159. 'value': '',
  160. 'children': [
  161. {
  162. 'label': 'Streamer Regex',
  163. 'children': [],
  164. 'seed': {
  165. 'value': '^',
  166. 'predicate': regexPredicate
  167. }
  168. },
  169. {
  170. 'label': 'Author Regex',
  171. 'children': [],
  172. 'seed': {
  173. 'value': '^',
  174. 'predicate': regexPredicate
  175. }
  176. },
  177. {
  178. 'label': 'Message Regex',
  179. 'children': [],
  180. 'seed': {
  181. 'value': '^',
  182. 'predicate': regexPredicate
  183. }
  184. }
  185. ]
  186. }
  187. },
  188. {
  189. 'label': 'Options',
  190. 'children': [
  191. {
  192. 'label': 'Case-Sensitive Regex?',
  193. 'value': false
  194. },
  195. {
  196. 'label': 'Show Intro Message?',
  197. 'value': false
  198. },
  199. {
  200. 'label': 'Pause on Mouse Over?',
  201. 'value': false
  202. },
  203. {
  204. 'label': 'Queue Time (ms)',
  205. 'value': 1000,
  206. 'predicate': (value) => value >= 0 ? true : 'Queue time must be positive'
  207. }
  208. ]
  209. },
  210. {
  211. 'label': 'Preferences',
  212. 'children': [
  213. {
  214. 'label': 'Requirements',
  215. 'children': [
  216. {
  217. 'label': 'OR',
  218. 'children': [],
  219. 'poolId': 0
  220. },
  221. {
  222. 'label': 'AND',
  223. 'children': [],
  224. 'poolId': 0
  225. }
  226. ]
  227. },
  228. {
  229. 'label': 'Priorities (High to Low)',
  230. 'poolId': 0,
  231. 'children': Object.values(PRIORITIES).map(label => ({
  232. label,
  233. 'value': label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN
  234. }))
  235. }
  236. ]
  237. }
  238. ]
  239. };
  240. })(),
  241. (() => {
  242. const EVALUATORS = (() => {
  243. const getEvaluator = (evaluator, isDesired) => isDesired ? evaluator : (_) => 1 - evaluator(_);
  244.  
  245. return {
  246. // Special tests
  247. [PRIORITIES.RECENT]: getEvaluator.bind(null, () => 1),
  248. [PRIORITIES.LONG]: getEvaluator.bind(null, _ => _.querySelector('#message').textContent.length),
  249. // Tests for message type
  250. [PRIORITIES.SUPERCHAT]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-paid-message-renderer')),
  251. [PRIORITIES.MEMBERSHIP_RENEWAL]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-membership-item-renderer')),
  252. [PRIORITIES.MEMBERSHIP_GIFT_OUT]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-purchase-announcement-renderer')),
  253. [PRIORITIES.MEMBERSHIP_GIFT_IN]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-redemption-announcement-renderer')),
  254. [PRIORITIES.MODE_CHANGE]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-mode-change-message-renderer')),
  255. // Tests for descendant element presence
  256. [PRIORITIES.EMOJI]: getEvaluator.bind(null, _ => Boolean(_.querySelector('.emoji'))),
  257. [PRIORITIES.MEMBER]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=member]'))),
  258. [PRIORITIES.MODERATOR]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chip-badges > [type=verified]'))),
  259. [PRIORITIES.VERIFIED]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=moderator]')))
  260. };
  261. })();
  262.  
  263. return ([rawFilters, options, {'children': [{'children': [softRequirements, hardRequirements]}, priorities]}]) => ({
  264. 'filters': (() => {
  265. const filters = [];
  266.  
  267. const getRegex = options.children[0].value ?
  268. ({value}) => new RegExp(value) :
  269. ({value}) => new RegExp(value, 'i');
  270. const matchesStreamer = (node) => getRegex(node).test(STREAMER);
  271.  
  272. for (const filter of rawFilters.children) {
  273. const [{'children': streamers}, {'children': authors}, {'children': messages}] = filter.children;
  274.  
  275. if (streamers.length === 0 || streamers.some(matchesStreamer)) {
  276. filters.push({
  277. 'authors': authors.map(getRegex),
  278. 'messages': messages.map(getRegex)
  279. });
  280. }
  281. }
  282.  
  283. return filters;
  284. })(),
  285. 'showIntroMessage': options.children[1].value,
  286. 'pauseOnHover': options.children[2].value,
  287. 'queueTime': options.children[3].value,
  288. 'requirements': {
  289. 'soft': softRequirements.children.map(({
  290. label, 'value': isDesired
  291. }) => EVALUATORS[label](isDesired)),
  292. 'hard': hardRequirements.children.map(({
  293. label, 'value': isDesired
  294. }) => EVALUATORS[label](isDesired))
  295. },
  296. 'comparitors': (() => {
  297. const getComparitor = (getValue, low, high) => {
  298. low = getValue(low);
  299. high = getValue(high);
  300.  
  301. return low < high ? -1 : low === high ? 0 : 1;
  302. };
  303.  
  304. return priorities.children.map(({
  305. label, 'value': isDesired
  306. }) => getComparitor.bind(null, EVALUATORS[label](isDesired)));
  307. })()
  308. });
  309. })(),
  310. TITLE,
  311. {
  312. 'headBase': '#ff0000',
  313. 'headButtonExit': '#000000',
  314. 'borderHead': '#ffffff',
  315. 'nodeBase': ['#222222', '#111111'],
  316. 'borderTooltip': '#570000'
  317. },
  318. {'zIndex': 10000}
  319. );
  320.  
  321. // CSS
  322.  
  323. (function style() {
  324. function addStyle(sheet, selector, rules) {
  325. const ruleString = rules.map(
  326. ([selector, rule]) => `${selector}:${typeof rule === 'function' ? rule() : rule} !important;`
  327. );
  328.  
  329. sheet.insertRule(`${selector}{${ruleString.join('')}}`);
  330. }
  331.  
  332. const styleElement = document.createElement('style');
  333. const {sheet} = document.head.appendChild(styleElement);
  334.  
  335. const styles = [
  336. [`${CHAT_LIST_SELECTOR}`, [
  337. ['bottom', 'inherit']
  338. ]],
  339. [`${CHAT_LIST_SELECTOR} > :not(.${FILTER_CLASS})`, [
  340. ['display', 'none']
  341. ]]
  342. ];
  343.  
  344. for (const style of styles) {
  345. addStyle(sheet, style[0], style[1]);
  346. }
  347. })();
  348.  
  349. // STATE
  350.  
  351. let queuedPost;
  352.  
  353. // FILTERING
  354.  
  355. function doFilter() {
  356. const chatListElement = ROOT_ELEMENT.querySelector(CHAT_LIST_SELECTOR);
  357.  
  358. let doQueue = false;
  359. let paused = false;
  360.  
  361. function showPost(post) {
  362. const config = $config.get();
  363.  
  364. post.classList.add(FILTER_CLASS);
  365.  
  366. queuedPost = undefined;
  367.  
  368. if (config && config.queueTime > 0) {
  369. // Start queueing
  370. doQueue = true;
  371.  
  372. window.setTimeout(() => {
  373. doQueue = false;
  374.  
  375. // Unqueue
  376. if (!paused) {
  377. acceptPost();
  378. }
  379. }, config.queueTime);
  380. }
  381. }
  382.  
  383. function acceptPost(post = queuedPost) {
  384. /** Unqueue iff:
  385. * - Nothing was queued at the most recent unqueue
  386. * - No posts have been shown since the last unqueue
  387. * - A post is queued
  388. */
  389. if (!post) {
  390. return;
  391. }
  392.  
  393. if (doQueue || paused) {
  394. queuedPost = post;
  395. } else {
  396. showPost(post);
  397. }
  398. }
  399.  
  400. window.document.body.addEventListener('mouseenter', () => {
  401. const config = $config.get();
  402.  
  403. if (config && config.pauseOnHover) {
  404. paused = true;
  405. }
  406. });
  407.  
  408. window.document.body.addEventListener('mouseleave', () => {
  409. const config = $config.get();
  410.  
  411. paused = false;
  412.  
  413. if (config && config.pauseOnHover) {
  414. acceptPost();
  415. }
  416. });
  417.  
  418. function processPost(post) {
  419. const config = $config.get();
  420.  
  421. // Test insta-accept
  422. if (
  423. (!config || !$active.get()) ||
  424. (config.showIntroMessage && post.matches('yt-live-chat-viewer-engagement-message-renderer')) ||
  425. post.matches('yt-live-chat-placeholder-item-renderer')
  426. ) {
  427. showPost(post);
  428.  
  429. return;
  430. }
  431.  
  432. // Test reject
  433. if (
  434. config.filters.some(filter =>
  435. // Test author filter
  436. (filter.authors.length > 0 && filter.authors.some(_ => _.test(post.querySelector('#author-name')?.textContent))) ||
  437. // Test message filter
  438. (filter.messages.length > 0 && filter.messages.some(_ => _.test(post.querySelector('#message')?.textContent)))
  439. ) ||
  440. // Test requirements
  441. (config.requirements.soft.length > 0 && !config.requirements.soft.some(passes => passes(post))) ||
  442. config.requirements.hard.some(passes => !passes(post))
  443. ) {
  444. return;
  445. }
  446.  
  447. // Test inferior to queued post
  448. if (queuedPost) {
  449. for (const comparitor of config.comparitors) {
  450. const rating = comparitor(post, queuedPost);
  451.  
  452. if (rating < 0) {
  453. return;
  454. }
  455.  
  456. if (rating > 0) {
  457. break;
  458. }
  459. }
  460. }
  461.  
  462. acceptPost(post);
  463. }
  464.  
  465. // Handle new posts
  466. new MutationObserver((mutations) => {
  467. for (const {addedNodes} of mutations) {
  468. addedNodes.forEach(processPost);
  469. }
  470. }).observe(
  471. chatListElement,
  472. {childList: true}
  473. );
  474. }
  475.  
  476. // BUTTON LISTENERS
  477.  
  478. (() => {
  479. let timeout;
  480.  
  481. const updateSvg = () => {
  482. SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  483. };
  484.  
  485. const updateCounter = () => {
  486. const config = $config.get();
  487. const count = config ? config.filters.length : 0;
  488.  
  489. queuedPost = undefined;
  490.  
  491. COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  492.  
  493. COUNTER.innerText = `${count}`;
  494. };
  495.  
  496. const onShortClick = (event) => {
  497. if (timeout && event.button === 0) {
  498. timeout = window.clearTimeout(timeout);
  499.  
  500. $active.toggle();
  501.  
  502. updateSvg();
  503. }
  504. };
  505.  
  506. const onLongClick = () => {
  507. timeout = undefined;
  508.  
  509. $config.edit()
  510. .then(updateCounter)
  511. .catch(({message}) => {
  512. if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
  513. $config.reset();
  514.  
  515. updateCounter();
  516. }
  517. });
  518. };
  519.  
  520. Promise.allSettled([
  521. $active.init()
  522. .then(updateSvg),
  523. $config.init()
  524. .then(updateCounter)
  525. ])
  526. .then((responses) => {
  527. for (const response of responses) {
  528. if ('reason' in response) {
  529. window.alert(response.reason.message);
  530. }
  531. }
  532.  
  533. BUTTON.addEventListener('mouseup', onShortClick);
  534.  
  535. BUTTON.addEventListener('mousedown', (event) => {
  536. if (event.button === 0) {
  537. timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
  538. }
  539. });
  540. });
  541. })();
  542.  
  543. doFilter();
  544.  
  545. // Restart if the chat element gets replaced
  546. // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
  547. new MutationObserver((mutations) => {
  548. for (const {addedNodes} of mutations) {
  549. for (const node of addedNodes) {
  550. if (node.matches('yt-live-chat-item-list-renderer')) {
  551. doFilter();
  552. }
  553. }
  554. }
  555. }).observe(
  556. ROOT_ELEMENT.querySelector('#item-list'),
  557. {childList: true}
  558. );
  559. });