YouTube Chat Filter

Filters messages in YouTube stream/premier chat.

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

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