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