YouTube Chat Filter

Filters messages in YouTube stream chat.

目前為 2024-06-28 提交的版本,檢視 最新版本

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

QingJ © 2025

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