Crunchyroll Watchlist Userscript

UI tweaks for CrunchyRoll Beta. New watchlist order and an autoplay tweak.

  1. // ==UserScript==
  2. // @name Crunchyroll Watchlist Userscript
  3. // @namespace Itsnotlupus Scripts
  4. // @description UI tweaks for CrunchyRoll Beta. New watchlist order and an autoplay tweak.
  5. // @description:de UI-Optimierungen für Crunchyroll Beta. Neue Watchlist-Reihenfolge und Autoplay-Optimierung.
  6. // @description:fr Tweaks pour Crunchyroll Beta. Nouvel ordre pour la watchlist et un ajustement de l'autoplay.
  7. // @match https://beta.crunchyroll.com/*
  8. // @version 0.9
  9. // @require https://unpkg.com/moduleraid@5.1.2/dist/moduleraid.umd.js
  10. // @require https://unpkg.com/devtools-detect@3.0.1/index.js
  11. // @run-at document-start
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. /**
  16. * Techniques used to monkeypatch Crunchyroll:
  17. *
  18. * - Webpack modules contain a lot of useful functionality. moduleraid is essential in reaching those.
  19. * The builtin find*() methods are constrained by minimization, and you may need to inspect its .modules
  20. * array to find what you need. see sortInit() for an example.
  21. * - The site uses Axios to perform network requests. Some of its modules expose utilities to define
  22. * interceptors on its axios instances, which allows to modify and decorate data fetched by the site.
  23. * - This is a React app, so modifying the rendered markup on the site is tricky. There are 2 approaches:
  24. * 1. Observe for DOM changes or React renders and re-apply your modifications each time.
  25. * This is how the watchlist card text changes and dimming are implemented.
  26. * 2. Find the source data used by the React components to render the app, and change that data.
  27. * This is how custom watchlist sorting is implemented.
  28. * - Useful data can found on React component props, including access to the Redux store. This is done
  29. * by using the React dev hooks and traversing the React Fiber tree to find matches. It is also possible
  30. * to change a React component behavior by changing its props, but as with markup, this will not stick
  31. * unless it is reapplied on every render. See tweakPlayer() for an example.
  32. *
  33. * The module devtools-detect is used to know when to make a few useful object available to the console,
  34. * which makes poking around and understanding what's there somewhat easier.
  35. */
  36.  
  37. /*jshint ignore:start */
  38.  
  39. const DAY = 24*3600*1000;
  40. const WEEK = 7 * DAY;
  41.  
  42. const NOT_WATCHED = 0;
  43. const WATCHED_WAIT = 1;
  44. const WATCHED_DONE = 2;
  45.  
  46. // Localization
  47. const i18n = {
  48. // reference strings
  49. 'en': {
  50. 'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
  51. 'Starting.': 'Starting.',
  52. 'DevTools open': 'DevTools open',
  53. 'Autoplay blocked at end of season.': 'Autoplay blocked at end of season.',
  54. 'Natural Sort': 'Natural Order',
  55. 'Airs on {DATE}': 'Airs on {DATE}',
  56. 'No Recent Episode': 'No Recent Episode',
  57. 'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
  58. 'Untitled': 'Untitled',
  59. 'Better Cards': 'Enhanced Cards',
  60. 'Show/Hide Comments': 'Show/Hide Comments'
  61. },
  62. // hasty translations
  63. 'de': {
  64. 'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
  65. 'Starting.': 'Anfang.',
  66. 'DevTools open': 'DevTools geöffnet',
  67. 'Autoplay blocked at end of season.': 'Autoplay am Saisonende gesperrt.',
  68. 'Natural Sort': 'Natürliche Reihenfolge',
  69. 'Airs on {DATE}': 'Am {DATE} ausgestrahlt',
  70. 'No Recent Episode': 'Keine aktuelle Folge',
  71. 'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
  72. 'Untitled': 'Ohne Titel',
  73. 'Better Cards': 'Erweiterte Karten',
  74. 'Show/Hide Comments': 'Kommentare ein-/ausblenden'
  75. },
  76. 'fr': {
  77. 'Crunchyroll Watchlist Userscript: {MSG}': 'Crunchyroll Watchlist Userscript: {MSG}',
  78. 'Starting.': 'Démarrage.',
  79. 'DevTools open': 'DevTools ouverts',
  80. 'Autoplay blocked at end of season.': 'Autoplay bloqué en fin de saison.',
  81. 'Natural Sort': 'Ordre Naturel',
  82. 'Airs on {DATE}': 'Diffusion le {DATE}',
  83. 'No Recent Episode': 'Pas d\'épisode récent',
  84. 'S{SEASON}E{EPISODE} - {TITLE}': 'S{SEASON}E{EPISODE} - {TITLE}',
  85. 'Untitled': 'Sans titre',
  86. 'Better Cards': 'Cartes Améliorées',
  87. 'Show/Hide Comments': 'Afficher/Masquer les commentaires'
  88. }
  89. // ...
  90. }
  91. const getLocale = (_ = location.pathname.split('/')[1]) => _ in i18n ? _ : navigator.language;
  92. const loc = (s, locale=getLocale(), lang=locale.split('-')[0]) => i18n[locale]?.[s] ?? i18n[lang]?.[s] ?? i18n.en[s] ?? s;
  93. const t = (s,o) => o?loc(s).replace(/\{([^{]*)\}/g,(a,b)=>o[b]??loc(a)):loc(s);
  94. const dateFormatter = Intl.DateTimeFormat(getLocale(), { weekday: "long", hour: "numeric", minute: "numeric"});
  95.  
  96. // other utilities
  97. const crel = (name, attrs, ...children) => ((e = Object.assign(document.createElement(name), attrs)) => (e.append(...children), e))();
  98. const svg = (name, attrs, ...children) => {
  99. const e = document.createElementNS('http://www.w3.org/2000/svg', name);
  100. Object.entries(attrs).forEach(([key,val]) => e.setAttribute(key, val));
  101. e.append(...children);
  102. return e;
  103. }
  104. const log = (msg, ...args) => console.log(`%c${t('Crunchyroll Watchlist Userscript: {MSG}', { MSG: t(msg) })}`, 'font-weight:600;color:green', ...args);
  105.  
  106. /** calls a function whenever react renders */
  107. const observeReact = (fn) => {
  108. reactObservers.add(fn);
  109. return () => reactObservers.delete(fn);
  110. };
  111. /** calls a function whenever the DOM changes */
  112. const observeDOM = (fn, e = document.documentElement, config = { attributes: 1, childList: 1, subtree: 1 }) => {
  113. const observer = new MutationObserver(fn);
  114. observer.observe(e, config);
  115. return () => observer.disconnect();
  116. };
  117. /** check a condition on every DOM change until true */
  118. const untilDOM = f => new Promise((r,_,d = observeDOM(() => f() && d() | r() )) => 0);
  119.  
  120. function debounce(fn) {
  121. let latestArgs, scheduled = false;
  122. return (...args) => {
  123. latestArgs = args;
  124. if (!scheduled) {
  125. scheduled = true;
  126. Promise.resolve().then(() => {
  127. scheduled = false;
  128. fn(...latestArgs);
  129. });
  130. }
  131. };
  132. }
  133.  
  134. // React monkey-patching. the stuff belows allows us to:
  135. // - observe every react renders
  136. // - inspect nodes for props (which exposes redux and other useful state)
  137. // - modify props value (which may not stick unless reapplied on every render)
  138. const reactObservers = new Set;
  139. const notifyReactObservers = debounce(() => reactObservers.forEach(fn=>fn()));
  140. let reactRoot; // We assume we'll only ever see one react root. Seems to hold here.
  141. const h = '__REACT_DEVTOOLS_GLOBAL_HOOK__';
  142. if (window[h]) {
  143. const ocfr = window[h].onCommitFiberRoot.bind(window[h]);
  144. window[h].onCommitFiberRoot = (_, root) => {
  145. notifyReactObservers();
  146. reactRoot = root;
  147. return ocfr(_, root);
  148. };
  149. } else {
  150. const listeners={};
  151. window[h] = {
  152. onCommitFiberRoot: (_, root) => {
  153. notifyReactObservers();
  154. reactRoot = root
  155. },
  156. onCommitFiberUnmount: ()=>0,
  157. inject: ()=>0,
  158. checkDCE: ()=>0,
  159. supportsFiber: true,
  160. on: ()=>0,
  161. sub: ()=>0,
  162. renderers: [],
  163. emit: ()=>0
  164. };
  165. }
  166.  
  167. /** Traversal of React's tree to find nodes that match a props name */
  168. function findNodesWithProp(name, firstOnly = false) {
  169. const acc = new Set;
  170. const visited = new Set;
  171. const getPropFromNode = node => {
  172. if (!node || visited.has(node)) return;
  173. visited.add(node);
  174. const props = node.memoizedProps;
  175. if (props && typeof props === 'object' && name in props) {
  176. acc.add(node);
  177. if (firstOnly) throw 0; // goto end
  178. }
  179. getPropFromNode(node.sibling);
  180. getPropFromNode(node.child);
  181. }
  182. try { getPropFromNode(reactRoot?.current) } catch {}
  183. return Array.from(acc);
  184. }
  185.  
  186. /** Magically obtain a prop value from the most top-level React component we can find */
  187. function getProp(name) {
  188. return findNodesWithProp(name, true)[0]?.memoizedProps?.[name];
  189. }
  190.  
  191. /** Forcefully mutate props on a component node in the react tree. */
  192. function updateNodeProps(node, props) {
  193. Object.assign(node.memoizedProps, props);
  194. Object.assign(node.pendingProps, props);
  195. Object.assign(node.stateNode?.props??{}, props);
  196. }
  197.  
  198. // Actual script logic starts here
  199.  
  200. function sortInit(mR) {
  201. // add sort elements we need. this needs to run before first render.
  202. // find sort data module and its members by shape, since it's all minimized
  203. const sortData = Object.values(mR.modules).find(m=>Object.values(m).includes("watchlist.sort"));
  204. const sortTypes = Object.entries(sortData).find(pair=>pair[1].alphabetical?.trigger)[0];
  205. const sortItems = Object.entries(sortData).find(pair=>pair[1][0]?.trigger)[0];
  206. const sortFilters = Object.entries(sortData).find(pair=>pair[1].alphabetical?.desc)[0];
  207.  
  208. if ("natural" in sortData[sortTypes]) return;
  209. sortData[sortTypes].natural = { name: t("Natural Sort"), value: "natural", trigger: t("Natural Sort")}
  210. sortData[sortItems].unshift(sortData[sortTypes].natural);
  211. sortData[sortFilters]["natural"] = {}; // we don't want sort filters available for natural sort XXX this isn't enough.
  212. return true;
  213. }
  214.  
  215. function axiosInit(Content, store) {
  216. Content.addRequestInterceptor(function (config) {
  217. if (config?.params?.sort_by === 'natural') {
  218. config.params.sort_by = '';
  219. config.__this_is_a_natural_sort = true;
  220. }
  221. return config;
  222. });
  223. Content.addResponseInterceptor(function (response) {
  224. if (response.config.url.endsWith('/watchlist')) {
  225. // save the watchlist items so we don't need to double fetch it.
  226. store.watchlistItems = response.data.items;
  227. // decorate watchlist items with 'watched' and 'lastAirDate' (for sorting and render)
  228. store.watchlistItems.forEach(item => {
  229. const { completion_status, panel: { episode_metadata: ep }} = item;
  230. const lastAirDate = new Date(ep.episode_air_date);
  231. item.lastAirDate = lastAirDate.getTime();
  232. if (completion_status) {
  233. // Cut off at 2 weeks after the original air date since VRV doesn't provide a date of next availability.
  234. // This works well enough for weekly shows, accounting for the occasional skipped week.
  235. item.watched = Date.now() - lastAirDate < 2 * WEEK ? WATCHED_WAIT : WATCHED_DONE;
  236. } else {
  237. item.watched = NOT_WATCHED;
  238. }
  239. });
  240. if (response.config.__this_is_a_natural_sort) {
  241. // the "Natural Sort" sorts items that haven't been watched above items that have.
  242. // it also sorts watched items likely to have new episode above items that aren't likely to have any.
  243. // it sorts items available to watch with more recent release first,
  244. // and items likely to have new episodes with closest next release first.
  245. // (If we had a sense of which shows are most eagerly watched, we could use that and have a plausible "Scientific Sort"..)
  246. store.watchlistItems.sort((a,b) => {
  247. // 1. sortByWatched
  248. const sortByWatched = a.watched - b.watched;
  249. if (sortByWatched) return sortByWatched;
  250. // 2. sortByAirDate
  251. const sortByAirDate = b.lastAirDate - a.lastAirDate;
  252. return a.watched === 1 ? -sortByAirDate : sortByAirDate;
  253. });
  254. }
  255. }
  256. return response;
  257. });
  258. }
  259.  
  260. /** As long as Crunchyroll has nonsensical seasons, it's better
  261. * to prevent autoplay across seasons
  262. */
  263. function tweakPlayer() {
  264. const [_,page] = location.pathname.split('/');
  265. if (page !== 'watch') return;
  266. // find player React component
  267. const node = findNodesWithProp('upNextLink', true)[0];
  268. const props = node?.memoizedProps;
  269. if (props && !props.injected) {
  270. // wrap the changeLocation props and check if it's being asked to
  271. // navigate to the "upnext" address.
  272. const { videoEnded, changeLocation, upNextLink } = props;
  273. let videoJustEnded = false;
  274. updateNodeProps(node, {
  275. videoEnded() {
  276. // track this to only block autoplay but still allow user to use the "next video" button in the player.
  277. videoJustEnded = true;
  278. return videoEnded;
  279. },
  280. changeLocation(go) {
  281. if (videoJustEnded && go.to === upNextLink) {
  282. videoJustEnded = false;
  283. // check if the next episode would be an "episode 1", indicative of another season
  284. const { content, watch } = getProp('store').getState(); // grab some state from redux
  285. const upNextId = content.upNext.byId[watch.id].contentId;
  286. const { episodeNumber } = content.media.byId[upNextId];
  287. if (episodeNumber === 1) {
  288. log('Autoplay blocked at end of season.');
  289. return;
  290. }
  291. }
  292. return changeLocation(go);
  293. },
  294. injected: true
  295. });
  296. }
  297. }
  298.  
  299. /**
  300. * Dim items that have been watched, gray out shows with no recent episodes.
  301. * Show title of next episode to play, or expected air date for one to show up.
  302. */
  303. function decorateWatchlist(store) {
  304. const { watchlistItems, classNames } = store;
  305. if (!classNames.watchlistCard) {
  306. // get a fresh mR, because not all CSS classnames are available at startup
  307. const mR = new moduleraid({ entrypoint: "__LOADABLE_LOADED_CHUNKS__", debug: false });
  308. const canari = Object.values(mR.modules).filter(o=>o?.Z?.watchlistCard)[0]?.Z;
  309. if (!canari) return; // too soon. retry on next mutation.
  310. // laboriously extract various classnames from whatever is exported
  311. // This mostly involves calling spurious react component renders and grabbing data from the trees generated.
  312. // This became necessary because classnames are now assigned random ids at build time.
  313.  
  314. // checkbox
  315. const X = Object.values(mR.modules).filter(o=>o?.Z?.CheckboxOption)[0].Z;
  316. const { className: dropdownCheckboxOption, labelClassName: dropdownCheckboxOptionLabel } = X.CheckboxOption().props.children({onOptionClick:0, onBlur:0}).props;
  317. const C = Object.values(mR.modules).filter(o=>o?.Z?.defaultProps?.labelClassName==="")[0].Z.prototype.render.call({props:{ isChecked: true }}).props;
  318. const checkbox = C.className;
  319. const CL = C.children.props;
  320. const [checkboxLabel, checkboxLabelIsChecked] = CL.className.split(' ');
  321. const I = CL.children[0].props;
  322. const checkboxInput = I.className;
  323. const M = CL.children[1].props;
  324. const checkboxCheckmark = M.className;
  325. const S = M.children.props;
  326. const checkboxSvg = S.className;
  327. const P = S.children.props;
  328. const checkboxPath = P.className;
  329. const T = CL.children[2].props;
  330. const checkboxText = Object.values(mR.modules).filter(o=>o?.Z?.displayName === 'Text')[0].Z.render(T).props.className;
  331. // card elements
  332. const { watchlistCard, watchlistCard__contentLink } = canari;
  333. const watchListCardSubtitle = Object.values(mR.modules).filter(o=>o?.Z?.Subtitle)[0].Z.Subtitle({className: ''}).props.className;
  334. Object.assign(classNames, {
  335. dropdownCheckboxOption,
  336. dropdownCheckboxOptionLabel,
  337. checkbox,
  338. checkboxLabel,
  339. checkboxLabelIsChecked,
  340. checkboxInput,
  341. checkboxCheckmark,
  342. checkboxSvg,
  343. checkboxPath,
  344. checkboxText,
  345. watchlistCard,
  346. watchlistCard__contentLink,
  347. watchListCardSubtitle
  348. })
  349. }
  350. const c = classNames;
  351. let useBetterCards = localStorage.BETTER_CARDS !== "false"; // be optimistic
  352. const controls = document.querySelector(".erc-watchlist-controls");
  353. if (controls && !document.querySelector(".better-cards")) {
  354. const checkbox = crel('li', { className: "controls-item" },
  355. crel('div', { className: `${c.checkbox} ${c.dropdownCheckboxOption}` },
  356. crel('label', { className: `${c.checkboxLabel} ${c.dropdownCheckboxOptionLabel} better-cards`+(useBetterCards ? ` ${c.checkboxLabelIsChecked}`: ""), tabIndex: "0" },
  357. crel('input', { className: c.checkboxInput, type: "checkbox", value: "better_cards" }),
  358. crel('span', { className: c.checkboxCheckmark },
  359. svg('svg', { class: c.checkboxSvg, viewBox: "2 2 16 16" },
  360. svg('path', { class: c.checkboxPath, d: "M6,10 C7.93333333,12 8.93333333,13 9,13 C9.06666667,13 10.7333333,11 14,7", "stroke-width": "2"}))),
  361. crel('span', { className: c.checkboxText }, t("Better Cards")))));
  362. const label = checkbox.querySelector(".better-cards");
  363. label.addEventListener("click", (e) => {
  364. if (e.target !== label.querySelector('input')) return;
  365. label.classList.toggle(c.checkboxLabelIsChecked, localStorage.BETTER_CARDS = useBetterCards = !useBetterCards);
  366. // remove all 'decorated' classes. triggers a mutation so this function runs again, and the loop below adjusts each card's appearance
  367. document.querySelectorAll(`.erc-my-lists-collection .${c.watchlistCard}.decorated`).forEach(e=>e.classList.remove('decorated'));
  368. });
  369. controls.insertBefore(checkbox, controls.firstChild);
  370. }
  371. for (const card of document.querySelectorAll(`.erc-my-lists-collection .${c.watchlistCard}:not(.decorated)`)) {
  372. const metadata = card.querySelector(`.${c.watchListCardSubtitle}`);
  373. if (useBetterCards) {
  374. const [item_id] = card.querySelector(`.${c.watchlistCard__contentLink}`).getAttribute('href').split('/').slice(-2); // XXX .at(-2)
  375. const item = watchlistItems.find(item => item.panel.id === item_id);
  376. if (!item) return;
  377. const { watched, panel : { episode_metadata: ep, title }} = item;
  378. metadata.dataset.originalHTML = metadata.innerHTML;
  379. metadata.innerHTML = `<span style="font-size: 0.875rem; margin-top: 1.5rem"></span>`;
  380. const label = metadata.firstChild;
  381. switch (watched) {
  382. default:
  383. case NOT_WATCHED:
  384. // use title & number to decorate watchlist item. iffy CSS.
  385. label.textContent = t('S{SEASON}E{EPISODE} - {TITLE}', {SEASON:ep.season_number || 1, EPISODE:ep.episode_number || 1, TITLE: title || t('Untitled')});
  386. metadata.style = 'height: 2.7em; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; white-space: normal';
  387. break;
  388. case WATCHED_WAIT:
  389. // half-dullify and show original air date.
  390. label.textContent = t('Airs on {DATE}', { DATE: dateFormatter.format(new Date(ep.episode_air_date)) });
  391. card.style = 'filter: grayscale(90%);opacity:.9';
  392. break;
  393. case WATCHED_DONE:
  394. // old shows, fully watched.
  395. // dullify thoroughly.
  396. label.textContent = t('No Recent Episode');
  397. card.style = 'filter: grayscale(90%);opacity:0.5';
  398. break;
  399. }
  400. } else {
  401. // restore original markup if we can.
  402. if (metadata.dataset.originalHTML) metadata.innerHTML=metadata.dataset.originalHTML;
  403. metadata.style='';
  404. card.style='';
  405. }
  406. card.classList.add('decorated');
  407. }
  408. }
  409.  
  410. function hideComments() {
  411. const comments = document.querySelector(".commenting-wrapper");
  412. if (!comments) return;
  413. const comments_toggle = comments.querySelector('.comments-toggle');
  414. if (comments_toggle) return;
  415. const button = crel('div', { role: "button", className: "comments-toggle c-button c-button--type-two-weak", tabindex: "0"},
  416. crel('span', { className: "c-call-to-action c-call-to-action--m c-button__cta", style: "cursor:pointer" }, t("Show/Hide Comments")));
  417. button.addEventListener('click', () => document.body.classList.toggle('show-comments'));
  418. comments.insertBefore(button, comments.firstChild);
  419. }
  420.  
  421. function hideCookieBanner() {
  422. document.head.append(crel('style', {
  423. type: 'text/css',
  424. textContent: `body:not(.show-evidon) .evidon-banner {
  425. display: none !important;
  426. }`
  427. }));
  428. // not an unconditional hiding. let the user click a banner button once to trigger future hiding.
  429. if (!localStorage.evidon_clicked) {
  430. document.body.classList.add('show-evidon');
  431. document.body.addEventListener('click', e => {
  432. if (Array.from(document.querySelectorAll(`button[class*="evidon"]`)).includes(e.target)) {
  433. localStorage.evidon_clicked = true;
  434. }
  435. }, true);
  436. }
  437. }
  438.  
  439. function main() {
  440. log('Starting.');
  441.  
  442. // grab webpack modules first
  443. const mR = new moduleraid({ entrypoint: "__LOADABLE_LOADED_CHUNKS__", debug: false });
  444. const [ { Content } ] = mR.findModule('CMS'); // all the backend APIs are here.
  445.  
  446. // state kept by this script
  447. const store = {
  448. // watchlist items last fetched
  449. watchlistItems : [],
  450. // classnames extracted from webpack exports, then cached here
  451. classNames: {}
  452. };
  453. // debugging help
  454. const devToolsDone = observeDOM(() => {
  455. if (devtools.isOpen) {
  456. const exposed = { Content, getProp, updateNodeProps, findNodesWithProp, mR, store, moduleraid };
  457. log('DevTools open', exposed);
  458. Object.assign(window, exposed);
  459. devToolsDone();
  460. }
  461. });
  462. // initial setup
  463. // wait for React so we can peek at Redux' state and get the accountId
  464. const setupDone = observeReact(() => {
  465. const accountId = getProp("store")?.getState()?.userManagement?.account?.accountId;
  466. if (!accountId) return;
  467. if (!localStorage.watchlist_userscript_setup) {
  468. console.log("accountId",accountId);
  469. // override watchlist_sort with our new order once after install.
  470. localStorage.WATCHLIST_SORT = JSON.stringify({ [accountId]: { type: "natural", order: "desc" }});
  471. localStorage.watchlist_userscript_setup = true;
  472. }
  473. setupDone();
  474. });
  475.  
  476. // add our sort data early enough to be used by first render
  477. sortInit(mR);
  478.  
  479. // intercept and augment watchlist requests
  480. axiosInit(Content, store);
  481.  
  482. // player fixes - code triggers on React tree changes
  483. observeReact(tweakPlayer);
  484. // watchlist fixes - code triggers on DOM tree changes
  485. observeDOM(() => decorateWatchlist(store));
  486. // hide video comments by default
  487. document.head.append(crel('style', {
  488. type: 'text/css',
  489. textContent: `body:not(.show-comments) .commenting-wrapper>div:last-child {
  490. display: none;
  491. }`
  492. }));
  493. observeDOM(hideComments);
  494. // the cookie banner keeps on popping up even after interacting with it. make it not do that.
  495. hideCookieBanner();
  496. }
  497.  
  498. untilDOM(() => window.__LOADABLE_LOADED_CHUNKS__ ).then(main);
  499.  

QingJ © 2025

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