YouTube Chat Filter

Set up filters for stream chats

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址