YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2024-06-27 提交的版本,查看 最新版本

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