Kitsu MALonnaised

Shows MyAnimeList.net data on kitsu.app

  1. // ==UserScript==
  2. // @name Kitsu MALonnaised
  3. // @description Shows MyAnimeList.net data on kitsu.app
  4. // @version 1.1.0
  5. // @icon data:image/png;base64,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
  6. //
  7. // @author tophf
  8. // @namespace https://github.com/tophf
  9. // @inspired-by https://greasyfork.org/scripts/5890
  10. //
  11. // @match https://kitsu.app/*
  12. //
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_addStyle
  17. // @grant GM_openInTab
  18. // @grant GM_getResourceText
  19. // @grant unsafeWindow
  20. //
  21. // @resource LZString https://cdn.jsdelivr.net/gh/openstyles/lz-string-unsafe@22af192175b5e1707f49c57de7ce942d4d4ad480/lz-string-unsafe.min.js
  22. // @run-at document-start
  23. //
  24. // @connect myanimelist.net
  25. // @connect self
  26. // ==/UserScript==
  27.  
  28. /* global LZStringUnsafe */
  29. 'use strict';
  30.  
  31. const API_URL = 'https://kitsu.app/api/edge/';
  32. const MAL_URL = 'https://myanimelist.net/';
  33. const MAL_CDN_URL = 'https://cdn.myanimelist.net/';
  34. let MAL_IMG_EXT = '.jpg';
  35. // maximum number present in a MAL page initially
  36. const MAL_RECS_LIMIT = 24;
  37. const MAL_CAST_LIMIT = 10;
  38. const MAL_STAFF_LIMIT = 4;
  39. const MAL_CSS_CHAR_IMG = 'a[href*="/character/"] img[data-src]';
  40. const MAL_CSS_VA_IMG = 'a[href*="/people/"] img[data-src]';
  41. const KITSU_RECS_PER_ROW = 4;
  42. const KITSU_RECS_HOVER_DELAY = 250;
  43. const KITSU_RECS_HOVER_DURATION = 500;
  44. const KITSU_GRAY_LINK_CLASS = 'import-title';
  45. // IntersectionObserver margin
  46. const LAZY_MARGIN = 200;
  47. const LAZY_ATTR = 'malsrc';
  48. const $LAZY_ATTR = '$' + LAZY_ATTR;
  49.  
  50. const DB_NAME = 'MALonnaise';
  51. const DB_STORE_NAME = 'data';
  52. const DB_FIELDS = 'path TID time score users favs chars recs'.split(' ');
  53.  
  54. const HOUR = 3600e3;
  55. const DAY = 24 * HOUR;
  56. const AIR_DATE_MAX_DIFF = 30 * DAY;
  57. const CACHE_DURATION = DAY;
  58. const GZIP = window.CompressionStream;
  59.  
  60. const ID = (name => Object.defineProperties({
  61. SCORE: `${name}-SCORE`,
  62. USERS: `${name}-USERS`,
  63. FAVS: `${name}-FAVS`,
  64. CHARS: `${name}-CHARS`,
  65. RECS: `${name}-RECS`,
  66. }, {
  67. me: {
  68. value: name,
  69. },
  70. selectAll: {
  71. value: (suffix = '') =>
  72. Object.keys(ID)
  73. .map(id => `#${ID.me}-${id} ${suffix}`)
  74. .join(','),
  75. },
  76. }))(GM_info.script.name.replace(/\W/g, ''));
  77.  
  78. const EXT_LINK = {
  79. tag: 'SVG:svg',
  80. viewBox: '0 0 22 22',
  81. children: [{
  82. tag: 'SVG:path',
  83. d: 'M13,0v2h5.6L6.3,14.3l1.4,1.4L20,3.4V9h2V0H13z M0,4v18h18V9l-2,2v9H2V6h9l2-2H0z',
  84. }],
  85. };
  86.  
  87. const agent = (() => {
  88. const data = new Proxy({}, {
  89. get: (self, name) =>
  90. self[name] ||
  91. (self[name] = new Map()),
  92. });
  93. return {
  94. on(name, fn, thisArg) {
  95. data[name].set(fn, [thisArg]);
  96. },
  97. resolveOn(name, thisArg) {
  98. return new Promise(resolve =>
  99. data[name].set(resolve, [thisArg, true]));
  100. },
  101. fire(name, ...args) {
  102. const listeners = data[name];
  103. for (const [fn, [thisArg, once]] of listeners) {
  104. fn.apply(thisArg, args);
  105. if (once)
  106. listeners.delete(fn);
  107. }
  108. },
  109. };
  110. })();
  111.  
  112.  
  113. const API = (() => {
  114. const API_OPTIONS = {
  115. headers: {
  116. Accept: 'application/vnd.api+json',
  117. },
  118. };
  119. const handler = {
  120. get({path}, endpoint) {
  121. const fn = () => {};
  122. fn.path = path + (path ? '/' : '') + endpoint;
  123. return new Proxy(fn, handler);
  124. },
  125. async apply(target, thisArg, [options]) {
  126. for (const [k, v] of Object.entries(options)) {
  127. if (typeof v === 'object') {
  128. delete options[k];
  129. for (const [kk, vv] of Object.entries(v))
  130. options[`${k}[${kk}]`] = vv;
  131. }
  132. }
  133. const url = `${API_URL}${target.path}?${new URLSearchParams(options)}`;
  134. return (await fetch(url, API_OPTIONS)).json();
  135. },
  136. };
  137. return new Proxy({path: ''}, handler);
  138. })();
  139.  
  140. /**
  141. * @property {Object} data
  142. * @property {String} renderedPath
  143. */
  144. class App {
  145.  
  146. static async init() {
  147. App.data = {};
  148. agent.on(InterceptXHR.register(), App.processMappings);
  149. agent.on(InterceptHistory.register(), App.onUrlChange);
  150. window.addEventListener('popstate', () => App.onUrlChange());
  151.  
  152. await Cache.init();
  153. App.onUrlChange();
  154. App.initStyles();
  155.  
  156. // detect WebP support
  157. $create({
  158. tag: 'img',
  159. src: 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
  160. onload() {
  161. MAL_IMG_EXT = '.webp';
  162. },
  163. });
  164. }
  165.  
  166. static async onUrlChange(path = location.pathname) {
  167. const [, type, slug] = path.match(/\/(anime|manga)\/([^/?#]+)(?:[?#].*)?$|$/);
  168. App.hide();
  169. if (!slug)
  170. App.data = {path};
  171. if (App.data.path === path)
  172. return;
  173. let data = await Cache.read(type, slug) || {};
  174. App.data = data;
  175. if (!data.path) {
  176. App.findMalEquivalent(type, slug);
  177. return;
  178. }
  179. if (data.expired)
  180. App.plant(data);
  181. if (data.expired || !data.score)
  182. data = await App.processMal({type, slug, TID: data.TID});
  183. App.plant(data);
  184. }
  185.  
  186. static async findMalEquivalent(type, slug) {
  187. const kitsuData = await API[type]({
  188. filter: {slug},
  189. include: 'mappings',
  190. fields: {
  191. mappings: 'externalSite,externalId',
  192. [type]: 'id,slug,status,subtype,startDate',
  193. },
  194. });
  195. if (await App.processMappings(kitsuData))
  196. return;
  197. const {categories: malData} = await Util.fetchJson(`${MAL_URL}search/prefix.json?${
  198. new URLSearchParams({type, keyword: encodeURIComponent(slug), v: 1})
  199. }`);
  200. try {
  201. const gist = Util.str2gist(slug);
  202. const ka = kitsuData.data[0].attributes;
  203. const kDate = +Date.parse(ka.startDate + ' GMT');
  204. const kSubType = ka.subtype.toLowerCase();
  205. for (const c of malData) {
  206. if (type !== c.type.toLowerCase())
  207. continue;
  208. for (const {url, name, payload: p} of c.items) {
  209. const mDate = Date.parse(p.aired.split(' to ')[0] + ' GMT');
  210. const dateDiff = kDate ? Math.abs(kDate - mDate) : Date.now() - mDate;
  211. if (dateDiff < AIR_DATE_MAX_DIFF && (
  212. dateDiff <= DAY && kSubType !== p.media_type.toLowerCase() ||
  213. Util.str2gist(name) === gist
  214. )) {
  215. const TID = MalTypeId.fromUrl(url);
  216. App.plant({
  217. TID,
  218. expired: true,
  219. score: [Number(p.score) || 0],
  220. path: type + '/' + slug,
  221. });
  222. App.plant(await App.processMal({type, slug, url, TID}));
  223. return;
  224. }
  225. }
  226. }
  227. } catch (e) {}
  228. console.warn('No match on MAL for %s/%s', type, slug, malData, kitsuData);
  229. }
  230.  
  231. static async processMappings(payload) {
  232. const url = Mal.findUrl(payload);
  233. if (!url)
  234. return;
  235. const {type, attributes: {slug}} = payload.data[0];
  236. let data = await Cache.read(type, slug);
  237. if (!data || data.expired || !data.score)
  238. data = await App.processMal({type, slug, url});
  239. App.plant(data);
  240. return true;
  241. }
  242.  
  243. static async processMal({type, slug, url, TID}) {
  244. App.shouldFadeOut = true;
  245. App.hide();
  246. const data = await Mal.scavenge(url || MalTypeId.toUrl(TID));
  247. data.TID = TID || MalTypeId.urlToTID(url);
  248. data.path = type + '/' + slug;
  249. if (App.data.recs)
  250. data.recs.push(...MalRecs.subtract(App.data.recs, data.recs));
  251. setTimeout(Cache.write, 0, type, slug, data);
  252. return data;
  253. }
  254.  
  255. static async plant(data) {
  256. if (!data || data.path === App.renderedPath)
  257. return;
  258. App.data = data;
  259. const [type, slug] = data.path.split('/');
  260. Object.defineProperties(data, {
  261. type: {value: type, configurable: true},
  262. slug: {value: slug, configurable: true},
  263. url: {value: MalTypeId.toUrl(data.TID), configurable: true},
  264. });
  265.  
  266. await Mutant.gotPath(data);
  267.  
  268. Render.all(data);
  269.  
  270. App.renderedPath = data.expired ? '' : data.path;
  271. App.shouldFadeOut = !data.score;
  272. }
  273.  
  274. static async hide() {
  275. App.renderedPath = '';
  276. await Util.nextTick();
  277. if (!App.shouldFadeOut)
  278. return;
  279. for (const el of $$(ID.selectAll()))
  280. el.style.opacity = 0;
  281. }
  282.  
  283. static initStyles() {
  284. Mutant.gotTheme().then(() => {
  285. if (!document.body)
  286. return;
  287. const bgColor = getComputedStyle(document.body).backgroundColor;
  288. document.head.append(
  289. $create({
  290. tag: 'style',
  291. textContent: `
  292. #${ID.RECS} {
  293. --${ID.me}-bg-color: ${bgColor};
  294. }`,
  295. }));
  296. });
  297.  
  298. const MAIN_TRANSITION = 'opacity .25s';
  299.  
  300. const RECS_MIN_HEIGHT = 220;
  301. const RECS_MAX_HEIGHT = 20e3;
  302. const RECS_IMG_MARGIN = '.5rem';
  303. const RECS_TRANSITION_TIMING = `${KITSU_RECS_HOVER_DURATION}ms ${KITSU_RECS_HOVER_DELAY}ms`;
  304.  
  305. const EXT_LINK_SIZE_EM = .8;
  306.  
  307. // language=CSS
  308. GM_addStyle(`
  309. a[mal] svg {
  310. fill: currentColor;
  311. margin-left: ${EXT_LINK_SIZE_EM / 2}em;
  312. width: ${EXT_LINK_SIZE_EM}em;
  313. height: ${EXT_LINK_SIZE_EM}em;
  314. display: inline-block;
  315. opacity: .5;
  316. -webkit-user-select: none;
  317. -moz-user-select: none;
  318. -ms-user-select: none;
  319. user-select: none;
  320. }
  321. a[mal="title"] svg {
  322. vertical-align: middle;
  323. }
  324. a[mal]:hover svg {
  325. opacity: 1;
  326. }
  327. .media--sidebar .is-sticky {
  328. position: static !important;
  329. }
  330. #SCORE:hover,
  331. ${ID.selectAll('a:hover')} {
  332. text-decoration: underline;
  333. }
  334. ${ID.selectAll()} {
  335. transition: ${MAIN_TRANSITION};
  336. }
  337. ${ID.selectAll('ins')} {
  338. display: block;
  339. width: 100%;
  340. }
  341. #SCORE:not(:first-child),
  342. #USERS,
  343. #FAVS {
  344. margin-left: 1em;
  345. }
  346. #USERS::before {
  347. content: '\\1F464';
  348. margin-right: .25em;
  349. }
  350. #FAVS::before {
  351. content: '\\2764';
  352. margin-right: .25em;
  353. }
  354. /*******************************************************/
  355. #CHARS h5 {
  356. display: inline-block;
  357. }
  358. #CHARS h5 a {
  359. font: inherit;
  360. }
  361. #CHARS summary {
  362. cursor: zoom-in;
  363. }
  364. #CHARS details[open] summary {
  365. cursor: zoom-out;
  366. }
  367. #CHARS summary:hover {
  368. color: #fff;
  369. }
  370. #CHARS[mal="anime"] div[mal] {
  371. width: 50%;
  372. display: inline-block;
  373. }
  374. #CHARS[mal="manga"] li {
  375. width: calc(50% - 4px);
  376. display: inline-block;
  377. }
  378. #CHARS[mal="manga"] ul[hovered] li:nth-child(odd) {
  379. margin-right: 8px;
  380. }
  381. #CHARS div[mal="people"] {
  382. opacity: .5;
  383. will-change: opacity;
  384. transition: opacity .25s .1s;
  385. }
  386. #CHARS div[mal="people"] img {
  387. opacity: .3;
  388. will-change: opacity;
  389. transition: opacity .25s .1s;
  390. }
  391. #CHARS div[mal="people"]:only-child {
  392. width: 100%;
  393. opacity: 1;
  394. }
  395. #CHARS div[mal="people"]:only-child img {
  396. opacity: .15;
  397. }
  398. #CHARS:hover div[mal="people"]:only-child img {
  399. opacity: .45;
  400. }
  401. #CHARS:hover div[mal="people"] img {
  402. opacity: .6;
  403. }
  404. #CHARS div[mal="people"]:only-child:hover img,
  405. #CHARS div[mal="people"]:hover,
  406. #CHARS div[mal="people"] img:hover {
  407. opacity: 1;
  408. }
  409. #CHARS div[mal]:first-child a {
  410. font-weight: bold;
  411. }
  412. #CHARS li a svg {
  413. vertical-align: middle;
  414. line-height: 1.0;
  415. }
  416. #CHARS span {
  417. display: inline-block;
  418. white-space: pre-line;
  419. overflow: hidden;
  420. text-overflow: ellipsis;
  421. max-width: calc(100% - 2 * ${EXT_LINK_SIZE_EM}em); /* room for the ext link icon */
  422. vertical-align: middle;
  423. line-height: 1.0;
  424. }
  425. #CHARS a div {
  426. overflow: hidden;
  427. width: 100%;
  428. }
  429. #CHARS div[mal="people"]:only-child {
  430. width: 100%;
  431. }
  432. #CHARS img {
  433. width: calc(100% + 2px);
  434. max-width: none;
  435. margin: -1px;
  436. }
  437. #CHARS img[${LAZY_ATTR}]:not([src]) {
  438. padding: 0 100% ${Util.num2pct(350 / 225)} 0;
  439. }
  440. #CHARS div[mal]:not(:only-child) a > :first-child:not(div) {
  441. margin-top: 60%;
  442. }
  443. #CHARS small {
  444. display: block;
  445. margin: 0 0 8px 0;
  446. line-height: 1.0;
  447. }
  448. /* replace the site's chars */
  449. #CHARS ul:not([hovered]) {
  450. display: flex;
  451. flex-wrap: wrap;
  452. }
  453. #CHARS ul[mal~="one-row"]:not([hovered]) li:nth-child(n + 5),
  454. #CHARS ul:not([hovered]) li:nth-child(n + 9) {
  455. display: none;
  456. }
  457. #CHARS ul:not([hovered]) li {
  458. width: calc(25% - 6px);
  459. margin: 0 3px 6px;
  460. position: relative;
  461. }
  462. #CHARS ul:not([hovered]) div[mal] {
  463. width: 100%;
  464. }
  465. #CHARS ul:not([hovered]) a div {
  466. border-radius: 3px;
  467. margin-bottom: .5em;
  468. }
  469. #CHARS[mal="anime"] ul:not([hovered]) div[mal="people"],
  470. #CHARS ul:not([hovered]) small,
  471. #CHARS ul:not([hovered]) li a[mal] svg{
  472. display:none;
  473. }
  474. #CHARS ul:not([hovered]) span {
  475. max-width: 100%;
  476. vertical-align: top;
  477. }
  478. /*******************************************************/
  479. #RECS {
  480. margin-bottom: 1em;
  481. }
  482. #RECS ul {
  483. display: flex;
  484. flex-wrap: wrap;
  485. margin: 0 -${RECS_IMG_MARGIN} 0 0;
  486. padding: 0;
  487. max-height: ${RECS_MIN_HEIGHT}px;
  488. overflow: hidden;
  489. position: relative;
  490. contain: layout;
  491. transition: max-height ${RECS_TRANSITION_TIMING};
  492. }
  493. #RECS ul:hover {
  494. max-height: ${RECS_MAX_HEIGHT}px;
  495. }
  496. #RECS ul:not(.hovered) {
  497. -webkit-mask-image: linear-gradient(#000, transparent);
  498. }
  499. #RECS li {
  500. list-style: none;
  501. position: relative;
  502. margin: 0 .5rem .5rem 0;
  503. width: calc(${Util.num2pct(1 / KITSU_RECS_PER_ROW)} - ${RECS_IMG_MARGIN});
  504. line-height: 1;
  505. display: flex;
  506. flex-direction: column;
  507. }
  508. #RECS li[mal="auto-rec"] {
  509. opacity: .25;
  510. }
  511. #RECS li[mal="auto-rec"]:hover {
  512. opacity: 1;
  513. }
  514. #RECS li[mal="more"] {
  515. width: 100%;
  516. text-align: center;
  517. padding: 0;
  518. }
  519. #RECS li[mal="more"] a {
  520. padding: 1em;
  521. }
  522. #RECS a[mal="title"] {
  523. margin: 0 0 ${Util.num2pct(315 / 225)};
  524. font-size: .8rem;
  525. font-weight: bolder;
  526. }
  527. #RECS div {
  528. overflow: hidden;
  529. position: absolute;
  530. top: 2rem;
  531. left: 0;
  532. right: 0;
  533. bottom: 0;
  534. background-size: calc(100% + 2px);
  535. background-position: -1px -1px;
  536. background-repeat: no-repeat;
  537. transition: opacity .5s, filter .5s;
  538. cursor: pointer;
  539. }
  540. #RECS li[mal="auto-rec"] div {
  541. filter: grayscale(1);
  542. }
  543. #RECS li[mal="auto-rec"]:hover div {
  544. filter: none;
  545. }
  546. #RECS a[mal="title"] div::after {
  547. content: "MAL only";
  548. position: absolute;
  549. top: 0;
  550. left: 0;
  551. right: 0;
  552. bottom: 0;
  553. box-sizing: content-box;
  554. width: 2rem;
  555. height: 2rem;
  556. margin: auto;
  557. padding: .75rem .6rem .5rem;
  558. text-align: center;
  559. line-height: .9;
  560. font-weight: bold;
  561. font-size: 1rem;
  562. letter-spacing: -.05em;
  563. border: 3px solid #fff;
  564. border-radius: 4rem;
  565. background: #2E51A2;
  566. color: #fff;
  567. box-shadow: 2px 3px 10px 2px #000a;
  568. transition: opacity .5s .1s;
  569. opacity: 0;
  570. }
  571. #RECS a[mal="title"] div:hover::after {
  572. opacity: 1;
  573. }
  574. #RECS span {
  575. white-space: nowrap;
  576. overflow: hidden;
  577. text-overflow: ellipsis;
  578. padding: 0;
  579. margin: 0;
  580. display: inline-block;
  581. vertical-align: sub;
  582. max-width: calc(100% - 1.5 * ${EXT_LINK_SIZE_EM}em);
  583. }
  584. #RECS small {
  585. font-size: .75rem;
  586. opacity: .75;
  587. margin-bottom: .25em;
  588. }
  589. `
  590. // language=none
  591. .replace(
  592. new RegExp(`#(?=${Object.keys(ID).join('|')})\\b`, 'g'),
  593. `#${ID.me}-`
  594. ));
  595. }
  596. }
  597.  
  598.  
  599. /**
  600. * @property {IDB} db
  601. */
  602. class Cache { // eslint-disable-line no-redeclare
  603.  
  604. static async init() {
  605. Cache.idb = new IDB(DB_NAME, DB_STORE_NAME);
  606. await Cache.idb.open({
  607. onupgradeneeded(e) {
  608. if (!e.oldVersion) {
  609. const store = e.target.result.createObjectStore(DB_STORE_NAME, {keyPath: 'path'});
  610. store.createIndex('TID', 'TID', {unique: true});
  611. store.createIndex('time', 'time', {unique: false});
  612. }
  613. },
  614. });
  615. if (GZIP) return;
  616. Cache.initLZ();
  617. Cache.zip = Cache.LZ;
  618. Cache.unzip = Cache.unLZ;
  619. }
  620.  
  621. static initLZ() {
  622. const url = URL.createObjectURL(new Blob([
  623. GM_getResourceText('LZString'),
  624. `;(${() => {
  625. self.onmessage = ({data: {id, action, value}}) =>
  626. self.postMessage({
  627. id,
  628. value: LZStringUnsafe[action](value),
  629. });
  630. }})()`,
  631. ]));
  632. const q = [];
  633. const w = new Worker(url);
  634. const invokeWorker = (action, value) => new Promise(resolve => {
  635. const id = performance.now();
  636. const payload = {id, action, value};
  637. q.push({resolve, payload});
  638. if (q.length === 1)
  639. w.postMessage(payload);
  640. });
  641. w.onmessage = ({data: {id, value}}) => {
  642. const i = q.findIndex(_ => _.payload.id === id);
  643. q[i].resolve(value);
  644. q.splice(i, 1);
  645. if (q.length)
  646. w.postMessage(q[0].payload);
  647. };
  648. URL.revokeObjectURL(url);
  649. Cache.LZ = invokeWorker.bind(null, 'compressToUTF16');
  650. Cache.unLZ = invokeWorker.bind(null, 'decompressFromUTF16');
  651. }
  652.  
  653. static async read(type, slug) {
  654. const path = type + '/' + slug;
  655. const data = await Cache.idb.get(path);
  656. if (!data)
  657. return;
  658. if (Date.now() - data.time > CACHE_DURATION)
  659. data.expired = true;
  660. if (data.lz) {
  661. for (const [k, v] of Object.entries(data.lz))
  662. data[k] = Util.parseJson(await Cache.unzip(v));
  663. data.lz = undefined;
  664. }
  665. return data;
  666. }
  667.  
  668. static async write(type, slug, data) {
  669. data.path = type + '/' + slug;
  670. data.time = Date.now();
  671. const toWrite = {};
  672. let lz, lzKeys;
  673. for (const k of DB_FIELDS) {
  674. const v = data[k];
  675. if (v === undefined)
  676. continue;
  677. if (v && typeof v === 'object') {
  678. const str = JSON.stringify(v);
  679. if (str.length > 100) {
  680. (lzKeys || (lzKeys = [])).push(k);
  681. (lz || (lz = [])).push(Cache.zip(str));
  682. continue;
  683. }
  684. }
  685. toWrite[k] = v;
  686. }
  687. if (lz)
  688. toWrite.lz = (await Promise.all(lz)).reduce((res, v, i) => ((res[lzKeys[i]] = v), res), {});
  689. try {
  690. await Cache.idb.put(toWrite);
  691. } catch (e) {
  692. if (e instanceof DOMException &&
  693. e.code === DOMException.QUOTA_EXCEEDED_ERR) {
  694. await Cache.cleanup();
  695. await Cache.idb.put(toWrite);
  696. } else {
  697. console.error(e);
  698. }
  699. }
  700. }
  701.  
  702. static cleanup() {
  703. return new Promise(resolve => {
  704. this.idb.exec({index: 'time', write: true, raw: true})
  705. .openCursor(IDBKeyRange.upperBound(Date.now - CACHE_DURATION))
  706. .onsuccess = e => {
  707. const cursor = /** @type IDBCursorWithValue */ e.target.result;
  708. if (!cursor) {
  709. resolve();
  710. return;
  711. }
  712. const {value} = cursor;
  713. if (value.lz) {
  714. delete value.lz;
  715. cursor.update(value);
  716. }
  717. cursor.continue();
  718. };
  719. });
  720. }
  721.  
  722. static zip(str) {
  723. const zipped = new Response(str).body.pipeThrough(new GZIP('gzip'));
  724. return new Response(zipped).blob();
  725. }
  726.  
  727. /** @param {Blob|string|undefined} v */
  728. static unzip(v) {
  729. if (!v)
  730. return;
  731. if (typeof v === 'string')
  732. return (!Cache.unLZ && Cache.initLZ(), Cache.unLZ(v));
  733. /* global DecompressionStream */
  734. return new Response(v.stream().pipeThrough(new DecompressionStream('gzip'))).text();
  735. }
  736. }
  737.  
  738.  
  739. /**
  740. * @property {IDBDatabase} db
  741. */
  742. class IDB {
  743.  
  744. constructor(name, storeName) {
  745. this.name = name;
  746. this.storeName = storeName;
  747. }
  748.  
  749. open(events) {
  750. return new Promise(resolve => {
  751. Object.assign(indexedDB.open(this.name), events, {
  752. onsuccess: e => {
  753. this.db = e.target.result;
  754. resolve();
  755. },
  756. });
  757. });
  758. }
  759.  
  760. get(key, index) {
  761. return this.exec({index}).get(key);
  762. }
  763.  
  764. put(value) {
  765. return this.exec({write: true}).put(value);
  766. }
  767.  
  768. /**
  769. * @param _
  770. * @param {Boolean} [_.write]
  771. * @param {String} [_.index]
  772. * @param {Boolean} [_.raw]
  773. * @return {Promise<IDBObjectStore|IDBIndex>|IDBObjectStore|IDBIndex}
  774. */
  775. exec({write, index, raw} = {}) {
  776. return new Proxy({}, {
  777. get: (_, method) =>
  778. (...args) => {
  779. let op = this.db
  780. .transaction(this.storeName, write ? 'readwrite' : 'readonly')
  781. .objectStore(this.storeName);
  782. if (index)
  783. op = op.index(index);
  784. op = op[method](...args);
  785. return raw ?
  786. op :
  787. new Promise((resolve, reject) => {
  788. op.onsuccess = e => resolve(e.target.result);
  789. op.onerror = reject;
  790. });
  791. },
  792. });
  793. }
  794. }
  795.  
  796.  
  797. class InterceptHistory {
  798. static register() {
  799. const event = Symbol(this.name);
  800. const pushState = unsafeWindow.History.prototype.pushState;
  801. unsafeWindow.History.prototype.pushState = function (state, title, url) {
  802. pushState.apply(this, arguments);
  803. agent.fire(event, url);
  804. };
  805. return event;
  806. }
  807. }
  808.  
  809.  
  810. class InterceptXHR {
  811. static register() {
  812. const event = Symbol(this.name);
  813. const XHR = unsafeWindow.XMLHttpRequest;
  814. unsafeWindow.XMLHttpRequest = class extends XHR {
  815. open(method, url, ...args) {
  816. if (url.startsWith(API_URL)) {
  817. const newUrl = InterceptXHR.onOpen.call(this, url);
  818. if (newUrl === false)
  819. return;
  820. if (newUrl) {
  821. url = newUrl;
  822. this.addEventListener('load', onLoad, {once: true});
  823. }
  824. }
  825. return super.open(method, url, ...args);
  826. }
  827. };
  828. return event;
  829.  
  830. function onLoad(e) {
  831. agent.fire(event, JSON.parse(e.target.responseText));
  832. }
  833. }
  834.  
  835. static onOpen(url) {
  836. // https://kitsu.app/api/edge/anime?........&include=categories.......
  837. if (
  838. !App.data.TID &&
  839. url.includes('&include=') && (
  840. url.includes('/anime?') ||
  841. url.includes('/manga?'))
  842. ) {
  843. const u = new URL(url);
  844. u.searchParams.set('include', u.searchParams.get('include') + ',mappings');
  845. u.searchParams.set('fields[mappings]', 'externalSite,externalId');
  846. return u.href;
  847. }
  848. // https://kitsu.app/api/edge/castings?.....&page%5Blimit%5D=4&......
  849. if (App.data.chars &&
  850. url.includes('/castings?') &&
  851. url.includes('page%5Blimit%5D=4')) {
  852. this.send = InterceptXHR.sendDummy;
  853. this.setRequestHeader = InterceptXHR.dummy;
  854. return false;
  855. }
  856. }
  857.  
  858. static sendDummy() {
  859. Object.defineProperty(this, 'responseText', {value: '{"data": []}'});
  860. this.onload({type: 'load', target: this});
  861. }
  862.  
  863. static dummy() {
  864. // NOP
  865. }
  866. }
  867.  
  868.  
  869. class Mal {
  870.  
  871. static findUrl(data) {
  872. for (const {type, attributes: a} of data.included || []) {
  873. if (type === 'mappings' &&
  874. a.externalSite.startsWith('myanimelist')) {
  875. const malType = a.externalSite.split('/')[1];
  876. const malId = a.externalId;
  877. return MAL_URL + malType + '/' + malId;
  878. }
  879. }
  880. }
  881.  
  882. static extract(img, stripId) {
  883. if (!img)
  884. return;
  885. const text = Util.decodeHtml(img.alt) || 0;
  886. // https://myanimelist.net/character/101457/Chika_Kudou
  887. // https://myanimelist.net/recommendations/anime/31859-35790
  888. // https://myanimelist.net/anime/19815/No_Game_No_Life?suggestion
  889. const a = img.closest('a');
  890. let aId = a && a.href.match(/\/(\d+(?:-\d+)?)|$/)[1] || 0;
  891. if (stripId && aId && aId.includes('-'))
  892. aId = aId.replace(stripId, '');
  893. // https://cdn.myanimelist.net/r/23x32/images/characters/7/331067.webp?s=xxxxxxxxxx
  894. // https://cdn.myanimelist.net/r/23x32/images/voiceactors/1/47102.jpg?s=xxxxxxxxx
  895. // https://cdn.myanimelist.net/r/90x140/images/anime/13/77976.webp?s=xxxxxxx
  896. const {src} = img.dataset;
  897. const imgId = src && src.match(/\/(\d+\/\d+)\.|$/)[1] || 0;
  898. return [text, aId >> 0, imgId];
  899. }
  900.  
  901. static extractChars(doc) {
  902. const processed = new Set();
  903. const chars = [];
  904. for (const img of $$(`${MAL_CSS_CHAR_IMG}, ${MAL_CSS_VA_IMG}`, doc)) {
  905. const parent = img.closest('table');
  906. if (processed.has(parent))
  907. continue;
  908. // we're assuming a character is a table that contains an actor's table
  909. // and the character's img comes first so we can add the nested actor's table
  910. // thus skipping it on subsequent matches for 'a[href*="/people/"] img'
  911. processed.add($('table', parent));
  912. const char = $(MAL_CSS_CHAR_IMG, parent);
  913. let actor;
  914. if (char) {
  915. for (const el of $$(MAL_CSS_VA_IMG, parent)) {
  916. const lang = $text('small', el.closest('tr'));
  917. if (!lang || lang === 'Japanese') {
  918. actor = el;
  919. break;
  920. }
  921. }
  922. } else {
  923. actor = img;
  924. }
  925. chars.push([
  926. $text('small', parent),
  927. char ? Mal.extract(char) : [],
  928. ...(actor ? [Mal.extract(actor)] : []),
  929. ]);
  930. }
  931. return chars.length && chars;
  932. }
  933.  
  934. static async scavenge(url) {
  935. const doc = await Util.fetchDoc(url);
  936. let el, score, users, favs;
  937.  
  938. el = $('[itemprop="ratingValue"],' +
  939. '[data-id="info1"] > span:not(.dark_text)', doc);
  940. if (!el)
  941. return {};
  942. score = $text(el).trim();
  943. score = score && Number(score.match(/[\d.]+|$/)[0]) || score;
  944. const ratingCount = Util.str2num($text('[itemprop="ratingCount"]', doc));
  945.  
  946. while (el.parentElement && !el.parentElement.textContent.includes('Members:'))
  947. el = el.parentElement;
  948. while ((!users || !favs) && (el = el.nextElementSibling)) {
  949. const txt = el.textContent;
  950. users = users || Util.str2num(txt.match(/Members:\s*([\d,]+)|$/)[1]);
  951. favs = favs || Util.str2num(txt.match(/Favorites:\s*([\d,]+)|$/)[1]);
  952. }
  953.  
  954. const rxStripOwnId = new RegExp('-?\\b' + url.match(/\d+/)[0] + '\\b-?');
  955. const recs = $$('#anime_recommendation .link,' +
  956. '#manga_recommendation .link', doc)
  957. .map(a => [
  958. ...Mal.extract($('img', a), rxStripOwnId),
  959. parseInt($text('.users', a)) || 0,
  960. ]);
  961.  
  962. return {
  963. users,
  964. favs,
  965. score: score ? [score, ratingCount || 0] : undefined,
  966. chars: Mal.extractChars(doc) || undefined,
  967. recs: recs.length ? recs : undefined,
  968. };
  969. }
  970.  
  971. static async scavengeRecs(url) {
  972. const doc = await Util.fetchDoc(url);
  973. const data = App.data;
  974. const oldRecs = data.recs || [];
  975. const rxType = new RegExp(`^${url.split('/')[3]}: `, 'i');
  976. data.recs = $$('a[href*="/recommendations/"]', doc)
  977. .map(a => {
  978. const entry = a.closest('table');
  979. const more = $text('a:not([href^="/"]):not([href^="http"])', entry);
  980. const count = parseInt(more.match(/\s\d+\s|$/)[0]) + 1 || 1;
  981. const info = Mal.extract($('a img', entry));
  982. info[0] = info[0].replace(rxType, '');
  983. info.push(count);
  984. return info;
  985. });
  986. data.recs.sort(MalRecs.sortFn);
  987. setTimeout(Cache.write, 0, data.type, data.slug, data);
  988. return MalRecs.subtract(data.recs, oldRecs);
  989. }
  990. }
  991.  
  992.  
  993. const REC_IDX_NAME = 0;
  994. const REC_IDX_ID = 1;
  995. const REC_IDX_COUNT = 3;
  996.  
  997.  
  998. class MalRecs {
  999.  
  1000. static hasId(recs, id) {
  1001. return recs.some(r => r[REC_IDX_ID] === id);
  1002. }
  1003.  
  1004. static subtract(recsA, recsB) {
  1005. return recsA.filter(([, id]) => !MalRecs.hasId(recsB, id));
  1006. }
  1007.  
  1008. static sortFn(a, b) {
  1009. return b[REC_IDX_COUNT] - a[REC_IDX_COUNT] ||
  1010. a[REC_IDX_NAME] < b[REC_IDX_NAME] && -1 ||
  1011. a[REC_IDX_NAME] > b[REC_IDX_NAME] && 1 ||
  1012. 0;
  1013. }
  1014. }
  1015.  
  1016.  
  1017. class MalTypeId {
  1018.  
  1019. static fromUrl(url) {
  1020. return url.match(/((?:anime|manga)\/\d+)|$/)[1] || '';
  1021. }
  1022.  
  1023. static toUrl(typeId) {
  1024. if (!typeId.includes('/'))
  1025. typeId = MalTypeId.fromTID(typeId).join('/');
  1026. return MAL_URL + typeId;
  1027. }
  1028.  
  1029. static fromTID(short) {
  1030. const t = short.slice(0, 1);
  1031. const fullType = t === 'a' && 'anime' ||
  1032. t === 'm' && 'manga' ||
  1033. '';
  1034. return [fullType, short.slice(1)];
  1035. }
  1036.  
  1037. static toTID(typeId) {
  1038. return typeId.slice(0, 1) + typeId.split('/')[1];
  1039. }
  1040.  
  1041. static urlToTID(url) {
  1042. return MalTypeId.toTID(MalTypeId.fromUrl(url));
  1043. }
  1044. }
  1045.  
  1046.  
  1047. class Mutant {
  1048.  
  1049. static async gotPath({path} = {}) {
  1050. const skipCurrent = !path;
  1051. const selector = 'meta[property="og:url"]' +
  1052. (skipCurrent ? '' : `[content="${location.origin}/${path}"]`);
  1053. if (Mutant.isWaiting(selector, skipCurrent))
  1054. return agent.resolveOn('gotPath');
  1055. const el = await Mutant.waitFor(selector, document.head, {skipCurrent});
  1056. agent.fire('gotPath', path);
  1057. return el;
  1058. }
  1059.  
  1060. static async gotTheme() {
  1061. const head = await Mutant.waitFor('head', document.documentElement);
  1062. const el = await Mutant.waitFor('link[data-theme]', head);
  1063. try {
  1064. el.sheet.cssRules.item(0);
  1065. } catch (e) {
  1066. await new Promise(done => el.addEventListener('load', done, {once: true}));
  1067. }
  1068. }
  1069.  
  1070. static gotMoved(node, timeout = 10e3) {
  1071. return new Promise(resolve => {
  1072. const parent = node.parentNode;
  1073. let timer;
  1074. const ob = new MutationObserver(() => {
  1075. if (node.parentNode !== parent) {
  1076. ob.disconnect();
  1077. clearTimeout(timer);
  1078. resolve(true);
  1079. }
  1080. });
  1081. ob.observe(parent, {childList: true});
  1082. timer = setTimeout(() => {
  1083. ob.disconnect();
  1084. resolve(false);
  1085. }, timeout);
  1086. });
  1087. }
  1088.  
  1089. static async waitFor(selector, base, {skipCurrent} = {}) {
  1090. return !skipCurrent && $(selector, base) ||
  1091. new Promise(resolve => {
  1092. if (!Mutant._waiting)
  1093. Mutant._waiting = new Set();
  1094. Mutant._waiting.add(selector);
  1095. new MutationObserver((mutations, ob) => {
  1096. for (const {addedNodes} of mutations) {
  1097. for (const n of addedNodes) {
  1098. if (n.matches && n.matches(selector)) {
  1099. Mutant._waiting.delete(selector);
  1100. ob.disconnect();
  1101. resolve(n);
  1102. }
  1103. }
  1104. }
  1105. }).observe(base, {childList: true});
  1106. });
  1107. }
  1108.  
  1109. static isWaiting(selector, asPrefix) {
  1110. if (!Mutant._waiting) {
  1111. Mutant._waiting = new Set();
  1112. return false;
  1113. } else if (asPrefix) {
  1114. for (const s of Mutant._waiting) {
  1115. if (s.startsWith(selector))
  1116. return true;
  1117. }
  1118. } else {
  1119. return Mutant._waiting.has(selector);
  1120. }
  1121. }
  1122. }
  1123.  
  1124.  
  1125. class Render {
  1126.  
  1127. static all(data) {
  1128. if (!Render.scrollObserver)
  1129. Render.scrollObserver = new IntersectionObserver(Render._lazyLoad, {
  1130. rootMargin: LAZY_MARGIN + 'px',
  1131. });
  1132. Render.stats(data);
  1133. Render.characters(data);
  1134. Render.recommendations(data);
  1135. Render.observe();
  1136. }
  1137.  
  1138. static observe(container) {
  1139. for (const el of $$(`[${LAZY_ATTR}]`, container))
  1140. Render.scrollObserver.observe(el);
  1141. }
  1142.  
  1143. static stats({score: [r, count] = ['N/A'], users, favs, url} = {}) {
  1144. const quarter = r > 0 && Math.max(1, Math.min(4, 1 + (r - .001) / 2.5 >> 0));
  1145. $create(Util.externalLink({
  1146. $mal: '',
  1147. id: ID.SCORE,
  1148. parent: $('.media-rating'),
  1149. href: url,
  1150. title: count && `Scored by ${Util.num2str(count)} users` || '',
  1151. textContent: (r > 0 ? Util.num2pct(r / 10) : r) + ' on MAL',
  1152. className: 'media-community-rating' + (quarter ? ' percent-quarter-' + quarter : ''),
  1153. $style: null,
  1154. }));
  1155. $create({
  1156. tag: 'span',
  1157. id: ID.USERS,
  1158. after: $id(ID.SCORE),
  1159. textContent: Util.num2str(users),
  1160. $style: users ? null : 'opacity:0; display:none',
  1161. });
  1162. $create({
  1163. tag: 'span',
  1164. id: ID.FAVS,
  1165. after: $id(ID.USERS),
  1166. textContent: Util.num2str(favs),
  1167. $style: favs ? null : 'opacity:0; display:none',
  1168. });
  1169. }
  1170.  
  1171. static characters({chars = [], url, type, slug, path}) {
  1172. $remove('.media--main-characters');
  1173. if (App.renderedPath !== path) {
  1174. // hide the previous pics of chars and voice actors
  1175. // to prevent them from flashing briefly during fade-in/hover
  1176. const el = $id(ID.CHARS);
  1177. if (el) {
  1178. const hidden = el.style.opacity === '0';
  1179. for (const img of el.getElementsByTagName('img')) {
  1180. if (hidden || img.src.includes('voiceactors'))
  1181. img.removeAttribute('src');
  1182. }
  1183. }
  1184. }
  1185. const numChars = chars.length;
  1186. let numCastPics = 0;
  1187. let numCast = 0;
  1188. for (const [/*type*/, [char, /*charId*/, charImg]] of chars) {
  1189. numCast += char ? 1 : 0;
  1190. numCastPics += charImg ? 1 : 0;
  1191. }
  1192. const moreCharsPossible = numCast === MAL_CAST_LIMIT ||
  1193. numChars - numCast === MAL_STAFF_LIMIT;
  1194. // prefer chars with pics, except for main chars who stay in place
  1195. chars = chars
  1196. .map((c, i) => [c, i])
  1197. .sort((
  1198. [[typeA, [, , imgA]], i],
  1199. [[typeB, [, , imgB]], j]
  1200. ) =>
  1201. (typeB === 'Main') - (typeA === 'Main') ||
  1202. !!imgB - !!imgA ||
  1203. i - j)
  1204. .map(([c]) => c);
  1205.  
  1206. $create({
  1207. tag: 'section',
  1208. $mal: type,
  1209. id: ID.CHARS,
  1210. after: $('.media--information'),
  1211. className: 'media--related',
  1212. $style: numChars ? null : 'opacity:0; display:none',
  1213. children: numChars && [{
  1214. tag: 'h5',
  1215. children: [
  1216. 'Characters ',
  1217. Util.externalLink({
  1218. href: `${url}/${slug}/characters`,
  1219. textContent: 'on MAL',
  1220. $mal: 'chars-all',
  1221. }),
  1222. ],
  1223. }, {
  1224. tag: 'ul',
  1225. $mal: numCastPics <= 6 ? 'one-row' : '',
  1226. onmouseover: Render._charsHovered,
  1227. onmouseout: Render._charsHovered,
  1228. children: chars.map(Render.char),
  1229. }, moreCharsPossible && {
  1230. tag: 'a',
  1231. href: `/${App.data.path}/characters`,
  1232. className: 'more-link',
  1233. textContent: 'View all characters',
  1234. }],
  1235. });
  1236. }
  1237.  
  1238. static char([type, [char, charId, charImg], [va, vaId, vaImg] = []]) {
  1239. return {
  1240. tag: 'li',
  1241. children: [
  1242. char && {
  1243. tag: 'div',
  1244. $mal: 'char',
  1245. children: [
  1246. Util.externalLink({
  1247. $mal: 'char',
  1248. href: `${MAL_URL}character/${charId}`,
  1249. children: [
  1250. charImg && {
  1251. tag: 'div',
  1252. children: [{
  1253. tag: 'img',
  1254. [$LAZY_ATTR]: `${MAL_CDN_URL}images/characters/${charImg}${MAL_IMG_EXT}`,
  1255. }],
  1256. }, {
  1257. tag: 'span',
  1258. children: Render.malName(char),
  1259. },
  1260. ],
  1261. }),
  1262. type !== 'Supporting' && {
  1263. tag: 'small',
  1264. textContent: type,
  1265. },
  1266. ],
  1267. },
  1268.  
  1269. va && {
  1270. tag: 'div',
  1271. $mal: 'people',
  1272. children: [
  1273. Util.externalLink({
  1274. $mal: 'people',
  1275. href: `${MAL_URL}people/${vaId}`,
  1276. children: [
  1277. vaImg && {
  1278. tag: 'div',
  1279. children: [{
  1280. tag: 'img',
  1281. [$LAZY_ATTR]: `${MAL_CDN_URL}images/voiceactors/${vaImg}.jpg`,
  1282. }],
  1283. }, {
  1284. tag: 'span',
  1285. children: Render.malName(va),
  1286. },
  1287. ],
  1288. }),
  1289. !char && {
  1290. tag: 'small',
  1291. textContent: type,
  1292. },
  1293. ],
  1294. },
  1295. ],
  1296. };
  1297. }
  1298.  
  1299. static recommendations({recs, url, slug}) {
  1300. $create({
  1301. tag: 'section',
  1302. id: ID.RECS,
  1303. before: $('.media--reactions'),
  1304. $style: recs ? null : 'opacity:0; display:none',
  1305. children: recs && [{
  1306. tag: 'h5',
  1307. children: [
  1308. 'Recommendations ',
  1309. Util.externalLink({
  1310. $mal: 'recs-all',
  1311. href: `${url}/${slug}/userrecs`,
  1312. className: KITSU_GRAY_LINK_CLASS,
  1313. textContent: 'on MAL',
  1314. }),
  1315. ],
  1316. }, {
  1317. tag: 'ul',
  1318. onmouseover: Render.recommendationsHidden,
  1319. onmouseenter: Render.onRecsHovered,
  1320. onmouseleave: Render.onRecsHovered,
  1321. children: recs.slice(0, KITSU_RECS_PER_ROW).map(Render.rec, arguments[0]),
  1322. }],
  1323. });
  1324. }
  1325.  
  1326. static recommendationsHidden() {
  1327. this.onmouseover = null;
  1328. const added = $create({tag: 'div'},
  1329. App.data.recs
  1330. .slice(KITSU_RECS_PER_ROW)
  1331. .map(Render.rec, App.data));
  1332. Render.observe(added);
  1333. if (App.data.recs.length === MAL_RECS_LIMIT) {
  1334. $create({
  1335. tag: 'li',
  1336. $mal: 'more',
  1337. parent: added,
  1338. className: 'media-summary',
  1339. children: {
  1340. tag: 'a',
  1341. href: '#',
  1342. onclick: Render.recommendationsMore,
  1343. className: 'more-link',
  1344. textContent: 'Load more recommendations',
  1345. },
  1346. });
  1347. }
  1348. $(`#${ID.RECS} ul`).append(...added.children);
  1349. }
  1350.  
  1351. static onRecsHovered(e) {
  1352. clearTimeout(Render.recsHoveredTimer);
  1353. const on = e.type === 'mouseenter';
  1354. const delay = KITSU_RECS_HOVER_DELAY + (on ? 0 : KITSU_RECS_HOVER_DURATION * .9);
  1355. Render.recsHoveredTimer = setTimeout(() => this.classList.toggle('hovered', on), delay);
  1356. }
  1357.  
  1358. static async recommendationsMore(e) {
  1359. e.preventDefault();
  1360. Object.assign(this, {
  1361. onclick: null,
  1362. textContent: 'Loading...',
  1363. style: 'pointer-events:none; cursor: wait',
  1364. });
  1365. const block = $id(ID.RECS);
  1366. block.style.cursor = 'progress';
  1367. const newRecs = await Mal.scavengeRecs($('a', block).href);
  1368. const added = $create({tag: 'div'}, newRecs.map(Render.rec, App.data));
  1369. Render.observe(added);
  1370. $('ul', block).append(...added.children);
  1371. block.style.cursor = '';
  1372. setTimeout(() =>
  1373. this.parentNode.remove());
  1374. }
  1375.  
  1376. static rec([name, id, img, count]) {
  1377. const {type, TID} = this;
  1378. return {
  1379. tag: 'li',
  1380. $mal: count ? '' : 'auto-rec',
  1381. onclick: Render._kitsuLinkPreclicked,
  1382. onauxclick: Render._kitsuLinkPreclicked,
  1383. onmousedown: Render._kitsuLinkPreclicked,
  1384. onmouseup: Render._kitsuLinkPreclicked,
  1385. onmouseover: Render.kitsuLink,
  1386. children: [{
  1387. tag: 'small',
  1388. children: [
  1389. !count ?
  1390. 'auto-rec' :
  1391. Util.externalLink({
  1392. $mal: 'rec',
  1393. href: `${MAL_URL}recommendations/${type}/${id}-${TID.slice(1)}`,
  1394. className: KITSU_GRAY_LINK_CLASS,
  1395. textContent: `${count} rec${count > 1 ? 's' : ''}`,
  1396. }),
  1397. ],
  1398. }, Util.externalLink({
  1399. $mal: 'title',
  1400. title: name,
  1401. href: `${MAL_URL}${type}/${id}`,
  1402. className: KITSU_GRAY_LINK_CLASS,
  1403. children: [{
  1404. tag: 'span',
  1405. textContent: name,
  1406. }],
  1407. }), {
  1408. tag: 'div',
  1409. [$LAZY_ATTR]: `${MAL_CDN_URL}images/${type}/${img}${MAL_IMG_EXT}`,
  1410. }],
  1411. };
  1412. }
  1413.  
  1414. static async kitsuLink() {
  1415. this.onmouseover = null;
  1416.  
  1417. const image = $('div', this);
  1418. const malLink = $('a[mal="title"]', this);
  1419. const typeId = MalTypeId.fromUrl(malLink.href);
  1420. const TID = MalTypeId.toTID(typeId);
  1421. const [type, id] = typeId.split('/');
  1422.  
  1423. const {path = ''} = await Cache.idb.get(TID, 'TID') || {};
  1424. let slug = path.split('/')[1];
  1425.  
  1426. if (!slug) {
  1427. const mappings = await API.mappings({
  1428. filter: {
  1429. externalId: id,
  1430. externalSite: 'myanimelist/' + type,
  1431. },
  1432. });
  1433. const entry = mappings.data[0];
  1434. if (entry) {
  1435. const mappingId = entry.id;
  1436. const mapping = await API.mappings[mappingId].item({
  1437. fields: {
  1438. [type]: 'slug',
  1439. },
  1440. });
  1441. slug = mapping.data.attributes.slug;
  1442. Cache.write(type, slug, {TID});
  1443. }
  1444. }
  1445.  
  1446. if (slug) {
  1447. $create({
  1448. tag: 'a',
  1449. href: `/${type}/${slug}`,
  1450. className: KITSU_GRAY_LINK_CLASS,
  1451. children: image,
  1452. parent: this,
  1453. });
  1454. } else {
  1455. malLink.appendChild(image);
  1456. }
  1457.  
  1458. if (!this.onmousedown && this.onmouseup) {
  1459. await new Promise(resolve => addEventListener('mouseup', resolve, {once: true}));
  1460. await Util.nextTick();
  1461. }
  1462. this.onclick = null;
  1463. this.onauxclick = null;
  1464. this.onmousedown = null;
  1465. this.onmouseup = null;
  1466. }
  1467.  
  1468. static malName(str) {
  1469. const i = str.indexOf(', ');
  1470. // <wbr> wraps even with "white-space:nowrap" so it's better than unicode zero-width space
  1471. if (i < 0) {
  1472. const words = str.split(/\s+/);
  1473. return words.length <= 2
  1474. ? words.join(' ')
  1475. : words[0] + ' ' + words.slice(1).join('\xA0');
  1476. } else {
  1477. return [
  1478. str.slice(i + 2).replace(/\s+/, '\xA0') + ' ',
  1479. {tag: 'wbr'},
  1480. str.slice(0, i).replace(/\s+/, '\xA0'),
  1481. ];
  1482. }
  1483. }
  1484.  
  1485. static _charsHovered() {
  1486. const hovering = this.matches(':hover');
  1487. if (hovering !== this.hasAttribute('hovered')) {
  1488. clearTimeout(this[ID.me]);
  1489. this[ID.me] = setTimeout(Render._charsHoveredTimer, hovering ? 250 : 1000, this);
  1490. }
  1491. }
  1492.  
  1493. static _charsHoveredTimer(el) {
  1494. $attributize(el, 'hovered', el.matches(':hover') ? '' : null);
  1495. }
  1496.  
  1497. static async _kitsuLinkPreclicked(e) {
  1498. if (!e.target.style.backgroundImage)
  1499. return;
  1500. if (e.type === 'mousedown') {
  1501. this.onmousedown = null;
  1502. return;
  1503. }
  1504. if (e.type === 'mouseup') {
  1505. this.onmouseup = null;
  1506. await Util.nextTick();
  1507. if (!this.onclick)
  1508. return;
  1509. }
  1510. this.onclick = null;
  1511. this.onauxclick = null;
  1512. if (e.altKey || e.metaKey || e.button > 1)
  1513. return;
  1514.  
  1515. let link = e.target.closest('a');
  1516. if (!link) {
  1517. const winner = await Promise.race([
  1518. Mutant.gotMoved(e.target),
  1519. Mutant.gotPath(),
  1520. ]);
  1521. if (winner !== true)
  1522. return;
  1523. }
  1524.  
  1525. const {button: btn, ctrlKey: c, shiftKey: s} = e;
  1526. link = e.target.closest('a');
  1527. if (!btn && !c) {
  1528. link.dispatchEvent(new MouseEvent('click', e));
  1529. if (!s)
  1530. App.onUrlChange(link.pathname);
  1531. } else {
  1532. GM_openInTab(link.href, {
  1533. active: btn === 0 && c && s,
  1534. insert: true,
  1535. setParent: true,
  1536. });
  1537. }
  1538. }
  1539.  
  1540. static _lazyLoad(entries) {
  1541. for (const e of entries) {
  1542. if (e.isIntersecting) {
  1543. const el = e.target;
  1544. let url = el.getAttribute(LAZY_ATTR);
  1545.  
  1546. if (el instanceof HTMLImageElement) {
  1547. if (el.src !== url)
  1548. el.src = url;
  1549. } else {
  1550. url = `url(${url})`;
  1551. if (el.style.backgroundImage !== url)
  1552. el.style.backgroundImage = url;
  1553. }
  1554.  
  1555. el.removeAttribute(LAZY_ATTR);
  1556. Render.scrollObserver.unobserve(el);
  1557. }
  1558. }
  1559. }
  1560. }
  1561.  
  1562.  
  1563. class Util {
  1564.  
  1565. static str2num(str) {
  1566. return str && Number(str.replace(/,/g, '')) || undefined;
  1567. }
  1568.  
  1569. static str2gist(str) {
  1570. return str.replace(/\W+/g, ' ').trim().toLowerCase();
  1571. }
  1572.  
  1573. static num2str(num) {
  1574. return num && num.toLocaleString() || '';
  1575. }
  1576.  
  1577. static num2pct(n, numDecimals = 2) {
  1578. return (n * 100).toFixed(numDecimals).replace(/\.?0+$/, '') + '%';
  1579. }
  1580.  
  1581. static decodeHtml(str) {
  1582. if (str.includes('&#')) {
  1583. str = str.replace(/&#(x?)([\da-f]);/gi, (_, hex, code) =>
  1584. String.fromCharCode(parseInt(code, hex ? 16 : 10)));
  1585. }
  1586. if (!str.includes('&') ||
  1587. !/&\w+;/.test(str))
  1588. return str;
  1589. if (!Mal.parser)
  1590. Mal.parser = new DOMParser();
  1591. const doc = Mal.parser.parseFromString(str, 'text/html');
  1592. return doc.body.firstChild.textContent;
  1593. }
  1594.  
  1595. static parseJson(str) {
  1596. try {
  1597. return JSON.parse(str);
  1598. } catch (e) {}
  1599. }
  1600.  
  1601. static nextTick() {
  1602. return new Promise(setTimeout);
  1603. }
  1604.  
  1605. static fetchDoc(url) {
  1606. return new Promise(resolve => {
  1607. GM_xmlhttpRequest({
  1608. url,
  1609. method: 'GET',
  1610. onload(r) {
  1611. const doc = new DOMParser().parseFromString(r.response, 'text/html');
  1612. resolve(doc);
  1613. },
  1614. });
  1615. });
  1616. }
  1617.  
  1618. static fetchJson(url) {
  1619. return new Promise(resolve => {
  1620. GM_xmlhttpRequest({
  1621. url,
  1622. method: 'GET',
  1623. responseType: 'json',
  1624. onload(r) {
  1625. resolve(r.response);
  1626. },
  1627. });
  1628. });
  1629. }
  1630.  
  1631. static externalLink(
  1632. props,
  1633. children = props.children || props.textContent || []
  1634. ) {
  1635. props.tag = 'a';
  1636. props.target = '_blank';
  1637. props.rel = 'noopener noreferrer';
  1638. props.children = Array.isArray(children) ? children : [children];
  1639. props.children.push(EXT_LINK);
  1640. delete props.textContent;
  1641. return props;
  1642. }
  1643. }
  1644.  
  1645.  
  1646. /** @return {HTMLElement} */
  1647. function $(selector, node = document) {
  1648. return node.querySelector(selector);
  1649. }
  1650.  
  1651. /** @return {HTMLElement} */
  1652. function $id(id, doc = document) {
  1653. return doc.getElementById(id);
  1654. }
  1655.  
  1656. /** @return {HTMLElement[]} */
  1657. function $$(selector, node = document) {
  1658. return [...node.querySelectorAll(selector)];
  1659. }
  1660.  
  1661. /** @return {String} */
  1662. function $text(selector, node = document) {
  1663. const el = typeof selector === 'string' ?
  1664. node.querySelector(selector) :
  1665. selector;
  1666. return el ? el.textContent.trim() : '';
  1667. }
  1668.  
  1669. /** @return {HTMLElement} */
  1670. function $create(props,
  1671. children = props.children || [],
  1672. referenceNode = props.id && $id(props.id)) {
  1673. let el;
  1674. let childIndex = -1;
  1675. const hasOwnProperty = Object.hasOwnProperty;
  1676. const toAppend = [];
  1677.  
  1678. if (!Array.isArray(children))
  1679. children = [children];
  1680.  
  1681. for (
  1682. let index = 0, node, info = props, ref = referenceNode;
  1683. index <= children.length;
  1684. info = children[index], ref = el.childNodes[childIndex], index++
  1685. ) {
  1686.  
  1687. if (!info)
  1688. continue;
  1689.  
  1690. childIndex++;
  1691.  
  1692. let ns;
  1693. const isNode = info instanceof Node;
  1694.  
  1695. if (isNode) {
  1696. node = info;
  1697. } else {
  1698. let {tag} = info;
  1699. const i = tag ? tag.indexOf(':') : -1;
  1700. if (i >= 0) {
  1701. ns = tag.slice(0, i);
  1702. tag = tag.slice(i + 1);
  1703. }
  1704. node = ref && ref.localName === (tag && tag.toLowerCase()) && ref || (
  1705. !tag ?
  1706. document.createTextNode(info) :
  1707. /^SVG$/i.test(ns) ?
  1708. document.createElementNS('http://www.w3.org/2000/svg', tag) :
  1709. document.createElement(tag));
  1710. }
  1711.  
  1712. const type = node.nodeType;
  1713.  
  1714. if (index === 0)
  1715. el = node;
  1716. else if (!ref)
  1717. toAppend.push(node);
  1718. else if (isNode || ref.localName !== node.localName)
  1719. ref.parentNode.replaceChild(node, ref);
  1720.  
  1721. if (isNode)
  1722. continue;
  1723.  
  1724. if (type === Node.TEXT_NODE) {
  1725. if (ref && ref.nodeValue !== info)
  1726. ref.nodeValue = info;
  1727. continue;
  1728. }
  1729.  
  1730. if (index > 0 && info.children) {
  1731. $create(info, undefined, node);
  1732. continue;
  1733. }
  1734.  
  1735. for (const k in info) {
  1736. if (!hasOwnProperty.call(info, k) ||
  1737. k === 'tag' ||
  1738. k === 'children' ||
  1739. k === 'parent' ||
  1740. k === 'after' ||
  1741. k === 'before')
  1742. continue;
  1743. const v = info[k];
  1744. const attr = k.startsWith('$') ? k.slice(1) : null;
  1745. if (attr || ns)
  1746. $attributize(node, attr || k, v);
  1747. else if (node[k] !== v)
  1748. node[k] = v;
  1749. }
  1750. }
  1751.  
  1752. if (toAppend.length)
  1753. el.append(...toAppend);
  1754. else {
  1755. const numExpected = childIndex + (props.textContent ? 1 : 0);
  1756. const numZombies = el.childNodes.length - numExpected;
  1757. for (let i = 0; i < numZombies; i++)
  1758. el.lastChild.remove();
  1759. }
  1760.  
  1761. if (props.parent &&
  1762. props.parent !== el.parentNode)
  1763. props.parent.appendChild(el);
  1764.  
  1765. if (props.before &&
  1766. props.before !== el.nextSibling)
  1767. props.before.insertAdjacentElement('beforeBegin', el);
  1768.  
  1769. if (props.after &&
  1770. props.after !== el.previousSibling)
  1771. props.after.insertAdjacentElement('afterEnd', el);
  1772.  
  1773. return el;
  1774. }
  1775.  
  1776. function $remove(selectorOrNode, base) {
  1777. const el = selectorOrNode instanceof Node ?
  1778. selectorOrNode :
  1779. $(selectorOrNode, base);
  1780. if (el)
  1781. el.remove();
  1782. }
  1783.  
  1784. function $attributize(node, attr, value) {
  1785. if (value === null)
  1786. node.removeAttribute(attr);
  1787. else if (value !== node.getAttribute(attr))
  1788. node.setAttribute(attr, value);
  1789. }
  1790.  
  1791. App.init();