YouTube Chat Filter

Filters messages in YouTube stream chat.

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

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