YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2023-07-18 提交的版本,查看 最新版本

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

QingJ © 2025

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