YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2023-01-27 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Chat Filter
  3. // @version 1.3
  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('#upload-info > #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. // Unqueue
  375. if (!paused) {
  376. acceptPost();
  377. }
  378. }, config.queueTime);
  379. }
  380. }
  381.  
  382. function acceptPost(post = queuedPost, allowQueue = true) {
  383. if (!post) {
  384. return;
  385. }
  386.  
  387. if (allowQueue && (doQueue || paused)) {
  388. queuedPost = post;
  389. } else {
  390. showPost(post, allowQueue);
  391. }
  392. }
  393.  
  394. window.document.body.addEventListener('mouseenter', () => {
  395. const config = $config.get();
  396.  
  397. if (config && config.pauseOnHover) {
  398. paused = true;
  399. }
  400. });
  401.  
  402. window.document.body.addEventListener('mouseleave', () => {
  403. const config = $config.get();
  404.  
  405. paused = false;
  406.  
  407. if (config && config.pauseOnHover) {
  408. acceptPost();
  409. }
  410. });
  411.  
  412. function processPost(post, allowQueue = true) {
  413. const config = $config.get();
  414. const isFilterable = config && $active.get() && TAGS_FILTERABLE.includes(post.tagName);
  415.  
  416. if (isFilterable) {
  417. if (
  418. config.filters.some(filter =>
  419. // Test author filter
  420. (filter.authors.length > 0 && filter.authors.some(_ => _.test(post.querySelector('#author-name')?.textContent))) ||
  421. // Test message filter
  422. (filter.messages.length > 0 && filter.messages.some(_ => _.test(post.querySelector('#message')?.textContent)))
  423. ) ||
  424. // Test requirements
  425. (config.requirements.soft.length > 0 && !config.requirements.soft.some(passes => passes(post))) ||
  426. config.requirements.hard.some(passes => !passes(post))
  427. ) {
  428. return;
  429. }
  430.  
  431. // Test inferior to queued post
  432. if (queuedPost) {
  433. for (const comparitor of config.comparitors) {
  434. const rating = comparitor(post, queuedPost);
  435.  
  436. if (rating < 0) {
  437. return;
  438. }
  439.  
  440. if (rating > 0) {
  441. break;
  442. }
  443. }
  444. }
  445. }
  446.  
  447. acceptPost(post, isFilterable && allowQueue);
  448. }
  449.  
  450. if (isInitial) {
  451. // Process initial messages
  452. for (const post of chatListElement.children) {
  453. processPost(post, false);
  454. }
  455.  
  456. // Re-sizes the chat after removing initial messages
  457. chatListElement.parentElement.style.height = `${chatListElement.clientHeight}px`;
  458.  
  459. // Restart if the chat element gets replaced
  460. // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
  461. new MutationObserver((mutations) => {
  462. for (const {addedNodes} of mutations) {
  463. for (const node of addedNodes) {
  464. if (node.matches('yt-live-chat-item-list-renderer')) {
  465. doFilter(false);
  466. }
  467. }
  468. }
  469. }).observe(
  470. ROOT_ELEMENT.querySelector('#item-list'),
  471. {childList: true}
  472. );
  473. }
  474.  
  475. // Handle new posts
  476. new MutationObserver((mutations) => {
  477. for (const {addedNodes} of mutations) {
  478. for (const addedNode of addedNodes) {
  479. processPost(addedNode);
  480. }
  481. }
  482. }).observe(
  483. chatListElement,
  484. {childList: true}
  485. );
  486. }
  487.  
  488. // MAIN
  489.  
  490. (() => {
  491. let timeout;
  492.  
  493. const updateSvg = () => {
  494. SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  495. };
  496.  
  497. const updateCounter = () => {
  498. const config = $config.get();
  499. const count = config ? config.filters.length : 0;
  500.  
  501. queuedPost = undefined;
  502.  
  503. COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  504.  
  505. COUNTER.innerText = `${count}`;
  506. };
  507.  
  508. const onShortClick = (event) => {
  509. if (timeout && event.button === 0) {
  510. timeout = window.clearTimeout(timeout);
  511.  
  512. $active.toggle();
  513.  
  514. updateSvg();
  515. }
  516. };
  517.  
  518. const onLongClick = () => {
  519. timeout = undefined;
  520.  
  521. $config.edit()
  522. .then(updateCounter)
  523. .catch(({message}) => {
  524. if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
  525. $config.reset();
  526.  
  527. updateCounter();
  528. }
  529. });
  530. };
  531.  
  532. Promise.allSettled([
  533. $active.init()
  534. .then(updateSvg),
  535. $config.init()
  536. .then(updateCounter)
  537. ])
  538. .then((responses) => {
  539. // Start filtering
  540. doFilter();
  541.  
  542. // Inform users of issues
  543. for (const response of responses) {
  544. if ('reason' in response) {
  545. window.alert(response.reason.message);
  546. }
  547. }
  548.  
  549. // Add short click listener
  550. BUTTON.addEventListener('mouseup', onShortClick);
  551.  
  552. // Add long click listener
  553. BUTTON.addEventListener('mousedown', (event) => {
  554. if (event.button === 0) {
  555. timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
  556. }
  557. });
  558. });
  559. })();
  560. });