Kitsu MALonnaised

Shows MyAnimeList.net data on kitsu.app

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Kitsu MALonnaised
// @description  Shows MyAnimeList.net data on kitsu.app
// @version      1.1.0
// @icon         data:image/png;base64,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
//
// @author       tophf
// @namespace    https://github.com/tophf
// @inspired-by  https://greasyfork.org/scripts/5890
//
// @match        https://kitsu.app/*
//
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_getResourceText
// @grant        unsafeWindow
//
// @resource     LZString https://cdn.jsdelivr.net/gh/openstyles/lz-string-unsafe@22af192175b5e1707f49c57de7ce942d4d4ad480/lz-string-unsafe.min.js
// @run-at       document-start
//
// @connect      myanimelist.net
// @connect      self
// ==/UserScript==

/* global LZStringUnsafe */
'use strict';

const API_URL = 'https://kitsu.app/api/edge/';
const MAL_URL = 'https://myanimelist.net/';
const MAL_CDN_URL = 'https://cdn.myanimelist.net/';
let MAL_IMG_EXT = '.jpg';
// maximum number present in a MAL page initially
const MAL_RECS_LIMIT = 24;
const MAL_CAST_LIMIT = 10;
const MAL_STAFF_LIMIT = 4;
const MAL_CSS_CHAR_IMG = 'a[href*="/character/"] img[data-src]';
const MAL_CSS_VA_IMG = 'a[href*="/people/"] img[data-src]';
const KITSU_RECS_PER_ROW = 4;
const KITSU_RECS_HOVER_DELAY = 250;
const KITSU_RECS_HOVER_DURATION = 500;
const KITSU_GRAY_LINK_CLASS = 'import-title';
// IntersectionObserver margin
const LAZY_MARGIN = 200;
const LAZY_ATTR = 'malsrc';
const $LAZY_ATTR = '$' + LAZY_ATTR;

const DB_NAME = 'MALonnaise';
const DB_STORE_NAME = 'data';
const DB_FIELDS = 'path TID time score users favs chars recs'.split(' ');

const HOUR = 3600e3;
const DAY = 24 * HOUR;
const AIR_DATE_MAX_DIFF = 30 * DAY;
const CACHE_DURATION = DAY;
const GZIP = window.CompressionStream;

const ID = (name => Object.defineProperties({
  SCORE: `${name}-SCORE`,
  USERS: `${name}-USERS`,
  FAVS: `${name}-FAVS`,
  CHARS: `${name}-CHARS`,
  RECS: `${name}-RECS`,
}, {
  me: {
    value: name,
  },
  selectAll: {
    value: (suffix = '') =>
      Object.keys(ID)
        .map(id => `#${ID.me}-${id} ${suffix}`)
        .join(','),
  },
}))(GM_info.script.name.replace(/\W/g, ''));

const EXT_LINK = {
  tag: 'SVG:svg',
  viewBox: '0 0 22 22',
  children: [{
    tag: 'SVG:path',
    d: 'M13,0v2h5.6L6.3,14.3l1.4,1.4L20,3.4V9h2V0H13z M0,4v18h18V9l-2,2v9H2V6h9l2-2H0z',
  }],
};

const agent = (() => {
  const data = new Proxy({}, {
    get: (self, name) =>
      self[name] ||
      (self[name] = new Map()),
  });
  return {
    on(name, fn, thisArg) {
      data[name].set(fn, [thisArg]);
    },
    resolveOn(name, thisArg) {
      return new Promise(resolve =>
        data[name].set(resolve, [thisArg, true]));
    },
    fire(name, ...args) {
      const listeners = data[name];
      for (const [fn, [thisArg, once]] of listeners) {
        fn.apply(thisArg, args);
        if (once)
          listeners.delete(fn);
      }
    },
  };
})();


const API = (() => {
  const API_OPTIONS = {
    headers: {
      Accept: 'application/vnd.api+json',
    },
  };
  const handler = {
    get({path}, endpoint) {
      const fn = () => {};
      fn.path = path + (path ? '/' : '') + endpoint;
      return new Proxy(fn, handler);
    },
    async apply(target, thisArg, [options]) {
      for (const [k, v] of Object.entries(options)) {
        if (typeof v === 'object') {
          delete options[k];
          for (const [kk, vv] of Object.entries(v))
            options[`${k}[${kk}]`] = vv;
        }
      }
      const url = `${API_URL}${target.path}?${new URLSearchParams(options)}`;
      return (await fetch(url, API_OPTIONS)).json();
    },
  };
  return new Proxy({path: ''}, handler);
})();

/**
 * @property {Object} data
 * @property {String} renderedPath
 */
class App {

  static async init() {
    App.data = {};
    agent.on(InterceptXHR.register(), App.processMappings);
    agent.on(InterceptHistory.register(), App.onUrlChange);
    window.addEventListener('popstate', () => App.onUrlChange());

    await Cache.init();
    App.onUrlChange();
    App.initStyles();

    // detect WebP support
    $create({
      tag: 'img',
      src: 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
      onload() {
        MAL_IMG_EXT = '.webp';
      },
    });
  }

  static async onUrlChange(path = location.pathname) {
    const [, type, slug] = path.match(/\/(anime|manga)\/([^/?#]+)(?:[?#].*)?$|$/);
    App.hide();
    if (!slug)
      App.data = {path};
    if (App.data.path === path)
      return;
    let data = await Cache.read(type, slug) || {};
    App.data = data;
    if (!data.path) {
      App.findMalEquivalent(type, slug);
      return;
    }
    if (data.expired)
      App.plant(data);
    if (data.expired || !data.score)
      data = await App.processMal({type, slug, TID: data.TID});
    App.plant(data);
  }

  static async findMalEquivalent(type, slug) {
    const kitsuData = await API[type]({
      filter: {slug},
      include: 'mappings',
      fields: {
        mappings: 'externalSite,externalId',
        [type]: 'id,slug,status,subtype,startDate',
      },
    });
    if (await App.processMappings(kitsuData))
      return;
    const {categories: malData} = await Util.fetchJson(`${MAL_URL}search/prefix.json?${
      new URLSearchParams({type, keyword: encodeURIComponent(slug), v: 1})
    }`);
    try {
      const gist = Util.str2gist(slug);
      const ka = kitsuData.data[0].attributes;
      const kDate = +Date.parse(ka.startDate + ' GMT');
      const kSubType = ka.subtype.toLowerCase();
      for (const c of malData) {
        if (type !== c.type.toLowerCase())
          continue;
        for (const {url, name, payload: p} of c.items) {
          const mDate = Date.parse(p.aired.split(' to ')[0] + ' GMT');
          const dateDiff = kDate ? Math.abs(kDate - mDate) : Date.now() - mDate;
          if (dateDiff < AIR_DATE_MAX_DIFF && (
            dateDiff <= DAY && kSubType !== p.media_type.toLowerCase() ||
            Util.str2gist(name) === gist
          )) {
            const TID = MalTypeId.fromUrl(url);
            App.plant({
              TID,
              expired: true,
              score: [Number(p.score) || 0],
              path: type + '/' + slug,
            });
            App.plant(await App.processMal({type, slug, url, TID}));
            return;
          }
        }
      }
    } catch (e) {}
    console.warn('No match on MAL for %s/%s', type, slug, malData, kitsuData);
  }

  static async processMappings(payload) {
    const url = Mal.findUrl(payload);
    if (!url)
      return;
    const {type, attributes: {slug}} = payload.data[0];
    let data = await Cache.read(type, slug);
    if (!data || data.expired || !data.score)
      data = await App.processMal({type, slug, url});
    App.plant(data);
    return true;
  }

  static async processMal({type, slug, url, TID}) {
    App.shouldFadeOut = true;
    App.hide();
    const data = await Mal.scavenge(url || MalTypeId.toUrl(TID));
    data.TID = TID || MalTypeId.urlToTID(url);
    data.path = type + '/' + slug;
    if (App.data.recs)
      data.recs.push(...MalRecs.subtract(App.data.recs, data.recs));
    setTimeout(Cache.write, 0, type, slug, data);
    return data;
  }

  static async plant(data) {
    if (!data || data.path === App.renderedPath)
      return;
    App.data = data;
    const [type, slug] = data.path.split('/');
    Object.defineProperties(data, {
      type: {value: type, configurable: true},
      slug: {value: slug, configurable: true},
      url: {value: MalTypeId.toUrl(data.TID), configurable: true},
    });

    await Mutant.gotPath(data);

    Render.all(data);

    App.renderedPath = data.expired ? '' : data.path;
    App.shouldFadeOut = !data.score;
  }

  static async hide() {
    App.renderedPath = '';
    await Util.nextTick();
    if (!App.shouldFadeOut)
      return;
    for (const el of $$(ID.selectAll()))
      el.style.opacity = 0;
  }

  static initStyles() {
    Mutant.gotTheme().then(() => {
      if (!document.body)
        return;
      const bgColor = getComputedStyle(document.body).backgroundColor;
      document.head.append(
        $create({
          tag: 'style',
          textContent: `
            #${ID.RECS} {
              --${ID.me}-bg-color: ${bgColor};
            }`,
        }));
    });

    const MAIN_TRANSITION = 'opacity .25s';

    const RECS_MIN_HEIGHT = 220;
    const RECS_MAX_HEIGHT = 20e3;
    const RECS_IMG_MARGIN = '.5rem';
    const RECS_TRANSITION_TIMING = `${KITSU_RECS_HOVER_DURATION}ms ${KITSU_RECS_HOVER_DELAY}ms`;

    const EXT_LINK_SIZE_EM = .8;

    // language=CSS
    GM_addStyle(`
      a[mal] svg {
        fill: currentColor;
        margin-left: ${EXT_LINK_SIZE_EM / 2}em;
        width: ${EXT_LINK_SIZE_EM}em;
        height: ${EXT_LINK_SIZE_EM}em;
        display: inline-block;
        opacity: .5;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
      }
      a[mal="title"] svg {
        vertical-align: middle;
      }
      a[mal]:hover svg {
        opacity: 1;
      }
      .media--sidebar .is-sticky {
        position: static !important;
      }
      #SCORE:hover,
      ${ID.selectAll('a:hover')} {
        text-decoration: underline;
      }
      ${ID.selectAll()} {
        transition: ${MAIN_TRANSITION};
      }
      ${ID.selectAll('ins')} {
        display: block;
        width: 100%;
      }
      #SCORE:not(:first-child),
      #USERS,
      #FAVS {
        margin-left: 1em;
      }
      #USERS::before {
        content: '\\1F464';
        margin-right: .25em;
      }
      #FAVS::before {
        content: '\\2764';
        margin-right: .25em;
      }
      /*******************************************************/
      #CHARS h5 {
        display: inline-block;
      }
      #CHARS h5 a {
        font: inherit;
      }
      #CHARS summary {
        cursor: zoom-in;
      }
      #CHARS details[open] summary {
        cursor: zoom-out;
      }
      #CHARS summary:hover {
        color: #fff;
      }
      #CHARS[mal="anime"] div[mal] {
        width: 50%;
        display: inline-block;
      }
      #CHARS[mal="manga"] li {
        width: calc(50% - 4px);
        display: inline-block;
      }
      #CHARS[mal="manga"] ul[hovered] li:nth-child(odd) {
        margin-right: 8px;
      }
      #CHARS div[mal="people"] {
        opacity: .5;
        will-change: opacity;
        transition: opacity .25s .1s;
      }
      #CHARS div[mal="people"] img {
        opacity: .3;
        will-change: opacity;
        transition: opacity .25s .1s;
      }
      #CHARS div[mal="people"]:only-child {
        width: 100%;
        opacity: 1;
      }
      #CHARS div[mal="people"]:only-child img {
        opacity: .15;
      }
      #CHARS:hover div[mal="people"]:only-child img {
        opacity: .45;
      }
      #CHARS:hover div[mal="people"] img {
        opacity: .6;
      }
      #CHARS div[mal="people"]:only-child:hover img,
      #CHARS div[mal="people"]:hover,
      #CHARS div[mal="people"] img:hover {
        opacity: 1;
      }
      #CHARS div[mal]:first-child a {
        font-weight: bold;
      }
      #CHARS li a svg {
        vertical-align: middle;
        line-height: 1.0;
      }
      #CHARS span {
        display: inline-block;
        white-space: pre-line;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: calc(100% - 2 * ${EXT_LINK_SIZE_EM}em); /* room for the ext link icon */
        vertical-align: middle;
        line-height: 1.0;
      }
      #CHARS a div {
        overflow: hidden;
        width: 100%;
      }
      #CHARS div[mal="people"]:only-child {
        width: 100%;
      }
      #CHARS img {
        width: calc(100% + 2px);
        max-width: none;
        margin: -1px;
      }
      #CHARS img[${LAZY_ATTR}]:not([src]) {
        padding: 0 100% ${Util.num2pct(350 / 225)} 0;
      }
      #CHARS div[mal]:not(:only-child) a > :first-child:not(div) {
        margin-top: 60%;
      }
      #CHARS small {
        display: block;
        margin: 0 0 8px 0;
        line-height: 1.0;
      }
      /* replace the site's chars */
      #CHARS ul:not([hovered]) {
        display: flex;
        flex-wrap: wrap;
      }
      #CHARS ul[mal~="one-row"]:not([hovered]) li:nth-child(n + 5),
      #CHARS ul:not([hovered]) li:nth-child(n + 9) {
        display: none;
      }
      #CHARS ul:not([hovered]) li {
        width: calc(25% - 6px);
        margin: 0 3px 6px;
        position: relative;
      }
      #CHARS ul:not([hovered]) div[mal] {
        width: 100%;
      }
      #CHARS ul:not([hovered]) a div {
        border-radius: 3px;
        margin-bottom: .5em;
      }
      #CHARS[mal="anime"] ul:not([hovered]) div[mal="people"],
      #CHARS ul:not([hovered]) small,
      #CHARS ul:not([hovered]) li a[mal] svg{
        display:none;
      }
      #CHARS ul:not([hovered]) span {
        max-width: 100%;
        vertical-align: top;
      }
      /*******************************************************/
      #RECS {
        margin-bottom: 1em;
      }
      #RECS ul {
        display: flex;
        flex-wrap: wrap;
        margin: 0 -${RECS_IMG_MARGIN} 0 0;
        padding: 0;
        max-height: ${RECS_MIN_HEIGHT}px;
        overflow: hidden;
        position: relative;
        contain: layout;
        transition: max-height ${RECS_TRANSITION_TIMING};
      }
      #RECS ul:hover {
        max-height: ${RECS_MAX_HEIGHT}px;
      }
      #RECS ul:not(.hovered) {
        -webkit-mask-image: linear-gradient(#000, transparent);
      }
      #RECS li {
        list-style: none;
        position: relative;
        margin: 0 .5rem .5rem 0;
        width: calc(${Util.num2pct(1 / KITSU_RECS_PER_ROW)} - ${RECS_IMG_MARGIN});
        line-height: 1;
        display: flex;
        flex-direction: column;
      }
      #RECS li[mal="auto-rec"] {
        opacity: .25;
      }
      #RECS li[mal="auto-rec"]:hover {
        opacity: 1;
      }
      #RECS li[mal="more"] {
        width: 100%;
        text-align: center;
        padding: 0;
      }
      #RECS li[mal="more"] a {
        padding: 1em;
      }
      #RECS a[mal="title"] {
        margin: 0 0 ${Util.num2pct(315 / 225)};
        font-size: .8rem;
        font-weight: bolder;
      }
      #RECS div {
        overflow: hidden;
        position: absolute;
        top: 2rem;
        left: 0;
        right: 0;
        bottom: 0;
        background-size: calc(100% + 2px);
        background-position: -1px -1px;
        background-repeat: no-repeat;
        transition: opacity .5s, filter .5s;
        cursor: pointer;
      }
      #RECS li[mal="auto-rec"] div {
        filter: grayscale(1);
      }
      #RECS li[mal="auto-rec"]:hover div {
        filter: none;
      }
      #RECS a[mal="title"] div::after {
        content: "MAL only";
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        box-sizing: content-box;
        width: 2rem;
        height: 2rem;
        margin: auto;
        padding: .75rem .6rem .5rem;
        text-align: center;
        line-height: .9;
        font-weight: bold;
        font-size: 1rem;
        letter-spacing: -.05em;
        border: 3px solid #fff;
        border-radius: 4rem;
        background: #2E51A2;
        color: #fff;
        box-shadow: 2px 3px 10px 2px #000a;
        transition: opacity .5s .1s;
        opacity: 0;
      }
      #RECS a[mal="title"] div:hover::after {
        opacity: 1;
      }
      #RECS span {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        padding: 0;
        margin: 0;
        display: inline-block;
        vertical-align: sub;
        max-width: calc(100% - 1.5 * ${EXT_LINK_SIZE_EM}em);
      }
      #RECS small {
        font-size: .75rem;
        opacity: .75;
        margin-bottom: .25em;
      }
    `
    // language=none
    .replace(
      new RegExp(`#(?=${Object.keys(ID).join('|')})\\b`, 'g'),
      `#${ID.me}-`
    ));
  }
}


/**
 * @property {IDB} db
 */
class Cache { // eslint-disable-line no-redeclare

  static async init() {
    Cache.idb = new IDB(DB_NAME, DB_STORE_NAME);
    await Cache.idb.open({
      onupgradeneeded(e) {
        if (!e.oldVersion) {
          const store = e.target.result.createObjectStore(DB_STORE_NAME, {keyPath: 'path'});
          store.createIndex('TID', 'TID', {unique: true});
          store.createIndex('time', 'time', {unique: false});
        }
      },
    });
    if (GZIP) return;
    Cache.initLZ();
    Cache.zip = Cache.LZ;
    Cache.unzip = Cache.unLZ;
  }

  static initLZ() {
    const url = URL.createObjectURL(new Blob([
      GM_getResourceText('LZString'),
      `;(${() => {
        self.onmessage = ({data: {id, action, value}}) =>
          self.postMessage({
            id,
            value: LZStringUnsafe[action](value),
          });
      }})()`,
    ]));
    const q = [];
    const w = new Worker(url);
    const invokeWorker = (action, value) => new Promise(resolve => {
      const id = performance.now();
      const payload = {id, action, value};
      q.push({resolve, payload});
      if (q.length === 1)
        w.postMessage(payload);
    });
    w.onmessage = ({data: {id, value}}) => {
      const i = q.findIndex(_ => _.payload.id === id);
      q[i].resolve(value);
      q.splice(i, 1);
      if (q.length)
        w.postMessage(q[0].payload);
    };
    URL.revokeObjectURL(url);
    Cache.LZ = invokeWorker.bind(null, 'compressToUTF16');
    Cache.unLZ = invokeWorker.bind(null, 'decompressFromUTF16');
  }

  static async read(type, slug) {
    const path = type + '/' + slug;
    const data = await Cache.idb.get(path);
    if (!data)
      return;
    if (Date.now() - data.time > CACHE_DURATION)
      data.expired = true;
    if (data.lz) {
      for (const [k, v] of Object.entries(data.lz))
        data[k] = Util.parseJson(await Cache.unzip(v));
      data.lz = undefined;
    }
    return data;
  }

  static async write(type, slug, data) {
    data.path = type + '/' + slug;
    data.time = Date.now();
    const toWrite = {};
    let lz, lzKeys;
    for (const k of DB_FIELDS) {
      const v = data[k];
      if (v === undefined)
        continue;
      if (v && typeof v === 'object') {
        const str = JSON.stringify(v);
        if (str.length > 100) {
          (lzKeys || (lzKeys = [])).push(k);
          (lz || (lz = [])).push(Cache.zip(str));
          continue;
        }
      }
      toWrite[k] = v;
    }
    if (lz)
      toWrite.lz = (await Promise.all(lz)).reduce((res, v, i) => ((res[lzKeys[i]] = v), res), {});
    try {
      await Cache.idb.put(toWrite);
    } catch (e) {
      if (e instanceof DOMException &&
          e.code === DOMException.QUOTA_EXCEEDED_ERR) {
        await Cache.cleanup();
        await Cache.idb.put(toWrite);
      } else {
        console.error(e);
      }
    }
  }

  static cleanup() {
    return new Promise(resolve => {
      this.idb.exec({index: 'time', write: true, raw: true})
        .openCursor(IDBKeyRange.upperBound(Date.now - CACHE_DURATION))
        .onsuccess = e => {
          const cursor = /** @type IDBCursorWithValue */ e.target.result;
          if (!cursor) {
            resolve();
            return;
          }
          const {value} = cursor;
          if (value.lz) {
            delete value.lz;
            cursor.update(value);
          }
          cursor.continue();
        };
    });
  }

  static zip(str) {
    const zipped = new Response(str).body.pipeThrough(new GZIP('gzip'));
    return new Response(zipped).blob();
  }

  /** @param {Blob|string|undefined} v */
  static unzip(v) {
    if (!v)
      return;
    if (typeof v === 'string')
      return (!Cache.unLZ && Cache.initLZ(), Cache.unLZ(v));
    /* global DecompressionStream */
    return new Response(v.stream().pipeThrough(new DecompressionStream('gzip'))).text();
  }
}


/**
 * @property {IDBDatabase} db
 */
class IDB {

  constructor(name, storeName) {
    this.name = name;
    this.storeName = storeName;
  }

  open(events) {
    return new Promise(resolve => {
      Object.assign(indexedDB.open(this.name), events, {
        onsuccess: e => {
          this.db = e.target.result;
          resolve();
        },
      });
    });
  }

  get(key, index) {
    return this.exec({index}).get(key);
  }

  put(value) {
    return this.exec({write: true}).put(value);
  }

  /**
   * @param _
   * @param {Boolean} [_.write]
   * @param {String} [_.index]
   * @param {Boolean} [_.raw]
   * @return {Promise<IDBObjectStore|IDBIndex>|IDBObjectStore|IDBIndex}
   */
  exec({write, index, raw} = {}) {
    return new Proxy({}, {
      get: (_, method) =>
        (...args) => {
          let op = this.db
            .transaction(this.storeName, write ? 'readwrite' : 'readonly')
            .objectStore(this.storeName);
          if (index)
            op = op.index(index);
          op = op[method](...args);
          return raw ?
            op :
            new Promise((resolve, reject) => {
              op.onsuccess = e => resolve(e.target.result);
              op.onerror = reject;
            });
        },
    });
  }
}


class InterceptHistory {
  static register() {
    const event = Symbol(this.name);
    const pushState = unsafeWindow.History.prototype.pushState;
    unsafeWindow.History.prototype.pushState = function (state, title, url) {
      pushState.apply(this, arguments);
      agent.fire(event, url);
    };
    return event;
  }
}


class InterceptXHR {
  static register() {
    const event = Symbol(this.name);
    const XHR = unsafeWindow.XMLHttpRequest;
    unsafeWindow.XMLHttpRequest = class extends XHR {
      open(method, url, ...args) {
        if (url.startsWith(API_URL)) {
          const newUrl = InterceptXHR.onOpen.call(this, url);
          if (newUrl === false)
            return;
          if (newUrl) {
            url = newUrl;
            this.addEventListener('load', onLoad, {once: true});
          }
        }
        return super.open(method, url, ...args);
      }
    };
    return event;

    function onLoad(e) {
      agent.fire(event, JSON.parse(e.target.responseText));
    }
  }

  static onOpen(url) {
    // https://kitsu.app/api/edge/anime?........&include=categories.......
    if (
      !App.data.TID &&
      url.includes('&include=') && (
        url.includes('/anime?') ||
        url.includes('/manga?'))
    ) {
      const u = new URL(url);
      u.searchParams.set('include', u.searchParams.get('include') + ',mappings');
      u.searchParams.set('fields[mappings]', 'externalSite,externalId');
      return u.href;
    }
    // https://kitsu.app/api/edge/castings?.....&page%5Blimit%5D=4&......
    if (App.data.chars &&
        url.includes('/castings?') &&
        url.includes('page%5Blimit%5D=4')) {
      this.send = InterceptXHR.sendDummy;
      this.setRequestHeader = InterceptXHR.dummy;
      return false;
    }
  }

  static sendDummy() {
    Object.defineProperty(this, 'responseText', {value: '{"data": []}'});
    this.onload({type: 'load', target: this});
  }

  static dummy() {
    // NOP
  }
}


class Mal {

  static findUrl(data) {
    for (const {type, attributes: a} of data.included || []) {
      if (type === 'mappings' &&
          a.externalSite.startsWith('myanimelist')) {
        const malType = a.externalSite.split('/')[1];
        const malId = a.externalId;
        return MAL_URL + malType + '/' + malId;
      }
    }
  }

  static extract(img, stripId) {
    if (!img)
      return;
    const text = Util.decodeHtml(img.alt) || 0;
    // https://myanimelist.net/character/101457/Chika_Kudou
    // https://myanimelist.net/recommendations/anime/31859-35790
    // https://myanimelist.net/anime/19815/No_Game_No_Life?suggestion
    const a = img.closest('a');
    let aId = a && a.href.match(/\/(\d+(?:-\d+)?)|$/)[1] || 0;
    if (stripId && aId && aId.includes('-'))
      aId = aId.replace(stripId, '');
    // https://cdn.myanimelist.net/r/23x32/images/characters/7/331067.webp?s=xxxxxxxxxx
    // https://cdn.myanimelist.net/r/23x32/images/voiceactors/1/47102.jpg?s=xxxxxxxxx
    // https://cdn.myanimelist.net/r/90x140/images/anime/13/77976.webp?s=xxxxxxx
    const {src} = img.dataset;
    const imgId = src && src.match(/\/(\d+\/\d+)\.|$/)[1] || 0;
    return [text, aId >> 0, imgId];
  }

  static extractChars(doc) {
    const processed = new Set();
    const chars = [];
    for (const img of $$(`${MAL_CSS_CHAR_IMG}, ${MAL_CSS_VA_IMG}`, doc)) {
      const parent = img.closest('table');
      if (processed.has(parent))
        continue;
      // we're assuming a character is a table that contains an actor's table
      // and the character's img comes first so we can add the nested actor's table
      // thus skipping it on subsequent matches for 'a[href*="/people/"] img'
      processed.add($('table', parent));
      const char = $(MAL_CSS_CHAR_IMG, parent);
      let actor;
      if (char) {
        for (const el of $$(MAL_CSS_VA_IMG, parent)) {
          const lang = $text('small', el.closest('tr'));
          if (!lang || lang === 'Japanese') {
            actor = el;
            break;
          }
        }
      } else {
        actor = img;
      }
      chars.push([
        $text('small', parent),
        char ? Mal.extract(char) : [],
        ...(actor ? [Mal.extract(actor)] : []),
      ]);
    }
    return chars.length && chars;
  }

  static async scavenge(url) {
    const doc = await Util.fetchDoc(url);
    let el, score, users, favs;

    el = $('[itemprop="ratingValue"],' +
           '[data-id="info1"] > span:not(.dark_text)', doc);
    if (!el)
      return {};
    score = $text(el).trim();
    score = score && Number(score.match(/[\d.]+|$/)[0]) || score;
    const ratingCount = Util.str2num($text('[itemprop="ratingCount"]', doc));

    while (el.parentElement && !el.parentElement.textContent.includes('Members:'))
      el = el.parentElement;
    while ((!users || !favs) && (el = el.nextElementSibling)) {
      const txt = el.textContent;
      users = users || Util.str2num(txt.match(/Members:\s*([\d,]+)|$/)[1]);
      favs = favs || Util.str2num(txt.match(/Favorites:\s*([\d,]+)|$/)[1]);
    }

    const rxStripOwnId = new RegExp('-?\\b' + url.match(/\d+/)[0] + '\\b-?');
    const recs = $$('#anime_recommendation .link,' +
                    '#manga_recommendation .link', doc)
      .map(a => [
        ...Mal.extract($('img', a), rxStripOwnId),
        parseInt($text('.users', a)) || 0,
      ]);

    return {
      users,
      favs,
      score: score ? [score, ratingCount || 0] : undefined,
      chars: Mal.extractChars(doc) || undefined,
      recs: recs.length ? recs : undefined,
    };
  }

  static async scavengeRecs(url) {
    const doc = await Util.fetchDoc(url);
    const data = App.data;
    const oldRecs = data.recs || [];
    const rxType = new RegExp(`^${url.split('/')[3]}: `, 'i');
    data.recs = $$('a[href*="/recommendations/"]', doc)
      .map(a => {
        const entry = a.closest('table');
        const more = $text('a:not([href^="/"]):not([href^="http"])', entry);
        const count = parseInt(more.match(/\s\d+\s|$/)[0]) + 1 || 1;
        const info = Mal.extract($('a img', entry));
        info[0] = info[0].replace(rxType, '');
        info.push(count);
        return info;
      });
    data.recs.sort(MalRecs.sortFn);
    setTimeout(Cache.write, 0, data.type, data.slug, data);
    return MalRecs.subtract(data.recs, oldRecs);
  }
}


const REC_IDX_NAME = 0;
const REC_IDX_ID = 1;
const REC_IDX_COUNT = 3;


class MalRecs {

  static hasId(recs, id) {
    return recs.some(r => r[REC_IDX_ID] === id);
  }

  static subtract(recsA, recsB) {
    return recsA.filter(([, id]) => !MalRecs.hasId(recsB, id));
  }

  static sortFn(a, b) {
    return b[REC_IDX_COUNT] - a[REC_IDX_COUNT] ||
           a[REC_IDX_NAME] < b[REC_IDX_NAME] && -1 ||
           a[REC_IDX_NAME] > b[REC_IDX_NAME] && 1 ||
           0;
  }
}


class MalTypeId {

  static fromUrl(url) {
    return url.match(/((?:anime|manga)\/\d+)|$/)[1] || '';
  }

  static toUrl(typeId) {
    if (!typeId.includes('/'))
      typeId = MalTypeId.fromTID(typeId).join('/');
    return MAL_URL + typeId;
  }

  static fromTID(short) {
    const t = short.slice(0, 1);
    const fullType = t === 'a' && 'anime' ||
                     t === 'm' && 'manga' ||
                     '';
    return [fullType, short.slice(1)];
  }

  static toTID(typeId) {
    return typeId.slice(0, 1) + typeId.split('/')[1];
  }

  static urlToTID(url) {
    return MalTypeId.toTID(MalTypeId.fromUrl(url));
  }
}


class Mutant {

  static async gotPath({path} = {}) {
    const skipCurrent = !path;
    const selector = 'meta[property="og:url"]' +
                     (skipCurrent ? '' : `[content="${location.origin}/${path}"]`);
    if (Mutant.isWaiting(selector, skipCurrent))
      return agent.resolveOn('gotPath');
    const el = await Mutant.waitFor(selector, document.head, {skipCurrent});
    agent.fire('gotPath', path);
    return el;
  }

  static async gotTheme() {
    const head = await Mutant.waitFor('head', document.documentElement);
    const el = await Mutant.waitFor('link[data-theme]', head);
    try {
      el.sheet.cssRules.item(0);
    } catch (e) {
      await new Promise(done => el.addEventListener('load', done, {once: true}));
    }
  }

  static gotMoved(node, timeout = 10e3) {
    return new Promise(resolve => {
      const parent = node.parentNode;
      let timer;
      const ob = new MutationObserver(() => {
        if (node.parentNode !== parent) {
          ob.disconnect();
          clearTimeout(timer);
          resolve(true);
        }
      });
      ob.observe(parent, {childList: true});
      timer = setTimeout(() => {
        ob.disconnect();
        resolve(false);
      }, timeout);
    });
  }

  static async waitFor(selector, base, {skipCurrent} = {}) {
    return !skipCurrent && $(selector, base) ||
      new Promise(resolve => {
        if (!Mutant._waiting)
          Mutant._waiting = new Set();
        Mutant._waiting.add(selector);
        new MutationObserver((mutations, ob) => {
          for (const {addedNodes} of mutations) {
            for (const n of addedNodes) {
              if (n.matches && n.matches(selector)) {
                Mutant._waiting.delete(selector);
                ob.disconnect();
                resolve(n);
              }
            }
          }
        }).observe(base, {childList: true});
      });
  }

  static isWaiting(selector, asPrefix) {
    if (!Mutant._waiting) {
      Mutant._waiting = new Set();
      return false;
    } else if (asPrefix) {
      for (const s of Mutant._waiting) {
        if (s.startsWith(selector))
          return true;
      }
    } else {
      return Mutant._waiting.has(selector);
    }
  }
}


class Render {

  static all(data) {
    if (!Render.scrollObserver)
      Render.scrollObserver = new IntersectionObserver(Render._lazyLoad, {
        rootMargin: LAZY_MARGIN + 'px',
      });
    Render.stats(data);
    Render.characters(data);
    Render.recommendations(data);
    Render.observe();
  }

  static observe(container) {
    for (const el of $$(`[${LAZY_ATTR}]`, container))
      Render.scrollObserver.observe(el);
  }

  static stats({score: [r, count] = ['N/A'], users, favs, url} = {}) {
    const quarter = r > 0 && Math.max(1, Math.min(4, 1 + (r - .001) / 2.5 >> 0));
    $create(Util.externalLink({
      $mal: '',
      id: ID.SCORE,
      parent: $('.media-rating'),
      href: url,
      title: count && `Scored by ${Util.num2str(count)} users` || '',
      textContent: (r > 0 ? Util.num2pct(r / 10) : r) + ' on MAL',
      className: 'media-community-rating' + (quarter ? ' percent-quarter-' + quarter : ''),
      $style: null,
    }));
    $create({
      tag: 'span',
      id: ID.USERS,
      after: $id(ID.SCORE),
      textContent: Util.num2str(users),
      $style: users ? null : 'opacity:0; display:none',
    });
    $create({
      tag: 'span',
      id: ID.FAVS,
      after: $id(ID.USERS),
      textContent: Util.num2str(favs),
      $style: favs ? null : 'opacity:0; display:none',
    });
  }

  static characters({chars = [], url, type, slug, path}) {
    $remove('.media--main-characters');
    if (App.renderedPath !== path) {
      // hide the previous pics of chars and voice actors
      // to prevent them from flashing briefly during fade-in/hover
      const el = $id(ID.CHARS);
      if (el) {
        const hidden = el.style.opacity === '0';
        for (const img of el.getElementsByTagName('img')) {
          if (hidden || img.src.includes('voiceactors'))
            img.removeAttribute('src');
        }
      }
    }
    const numChars = chars.length;
    let numCastPics = 0;
    let numCast = 0;
    for (const [/*type*/, [char, /*charId*/, charImg]] of chars) {
      numCast += char ? 1 : 0;
      numCastPics += charImg ? 1 : 0;
    }
    const moreCharsPossible = numCast === MAL_CAST_LIMIT ||
                              numChars - numCast === MAL_STAFF_LIMIT;
    // prefer chars with pics, except for main chars who stay in place
    chars = chars
      .map((c, i) => [c, i])
      .sort((
        [[typeA, [, , imgA]], i],
        [[typeB, [, , imgB]], j]
      ) =>
        (typeB === 'Main') - (typeA === 'Main') ||
        !!imgB - !!imgA ||
        i - j)
      .map(([c]) => c);

    $create({
      tag: 'section',
      $mal: type,
      id: ID.CHARS,
      after: $('.media--information'),
      className: 'media--related',
      $style: numChars ? null : 'opacity:0; display:none',
      children: numChars && [{
        tag: 'h5',
        children: [
          'Characters ',
          Util.externalLink({
            href: `${url}/${slug}/characters`,
            textContent: 'on MAL',
            $mal: 'chars-all',
          }),
        ],
      }, {
        tag: 'ul',
        $mal: numCastPics <= 6 ? 'one-row' : '',
        onmouseover: Render._charsHovered,
        onmouseout: Render._charsHovered,
        children: chars.map(Render.char),
      }, moreCharsPossible && {
        tag: 'a',
        href: `/${App.data.path}/characters`,
        className: 'more-link',
        textContent: 'View all characters',
      }],
    });
  }

  static char([type, [char, charId, charImg], [va, vaId, vaImg] = []]) {
    return {
      tag: 'li',
      children: [
        char && {
          tag: 'div',
          $mal: 'char',
          children: [
            Util.externalLink({
              $mal: 'char',
              href: `${MAL_URL}character/${charId}`,
              children: [
                charImg && {
                  tag: 'div',
                  children: [{
                    tag: 'img',
                    [$LAZY_ATTR]: `${MAL_CDN_URL}images/characters/${charImg}${MAL_IMG_EXT}`,
                  }],
                }, {
                  tag: 'span',
                  children: Render.malName(char),
                },
              ],
            }),
            type !== 'Supporting' && {
              tag: 'small',
              textContent: type,
            },
          ],
        },

        va && {
          tag: 'div',
          $mal: 'people',
          children: [
            Util.externalLink({
              $mal: 'people',
              href: `${MAL_URL}people/${vaId}`,
              children: [
                vaImg && {
                  tag: 'div',
                  children: [{
                    tag: 'img',
                    [$LAZY_ATTR]: `${MAL_CDN_URL}images/voiceactors/${vaImg}.jpg`,
                  }],
                }, {
                  tag: 'span',
                  children: Render.malName(va),
                },
              ],
            }),
            !char && {
              tag: 'small',
              textContent: type,
            },
          ],
        },
      ],
    };
  }

  static recommendations({recs, url, slug}) {
    $create({
      tag: 'section',
      id: ID.RECS,
      before: $('.media--reactions'),
      $style: recs ? null : 'opacity:0; display:none',
      children: recs && [{
        tag: 'h5',
        children: [
          'Recommendations ',
          Util.externalLink({
            $mal: 'recs-all',
            href: `${url}/${slug}/userrecs`,
            className: KITSU_GRAY_LINK_CLASS,
            textContent: 'on MAL',
          }),
        ],
      }, {
        tag: 'ul',
        onmouseover: Render.recommendationsHidden,
        onmouseenter: Render.onRecsHovered,
        onmouseleave: Render.onRecsHovered,
        children: recs.slice(0, KITSU_RECS_PER_ROW).map(Render.rec, arguments[0]),
      }],
    });
  }

  static recommendationsHidden() {
    this.onmouseover = null;
    const added = $create({tag: 'div'},
      App.data.recs
        .slice(KITSU_RECS_PER_ROW)
        .map(Render.rec, App.data));
    Render.observe(added);
    if (App.data.recs.length === MAL_RECS_LIMIT) {
      $create({
        tag: 'li',
        $mal: 'more',
        parent: added,
        className: 'media-summary',
        children: {
          tag: 'a',
          href: '#',
          onclick: Render.recommendationsMore,
          className: 'more-link',
          textContent: 'Load more recommendations',
        },
      });
    }
    $(`#${ID.RECS} ul`).append(...added.children);
  }

  static onRecsHovered(e) {
    clearTimeout(Render.recsHoveredTimer);
    const on = e.type === 'mouseenter';
    const delay = KITSU_RECS_HOVER_DELAY + (on ? 0 : KITSU_RECS_HOVER_DURATION * .9);
    Render.recsHoveredTimer = setTimeout(() => this.classList.toggle('hovered', on), delay);
  }

  static async recommendationsMore(e) {
    e.preventDefault();
    Object.assign(this, {
      onclick: null,
      textContent: 'Loading...',
      style: 'pointer-events:none; cursor: wait',
    });
    const block = $id(ID.RECS);
    block.style.cursor = 'progress';
    const newRecs = await Mal.scavengeRecs($('a', block).href);
    const added = $create({tag: 'div'}, newRecs.map(Render.rec, App.data));
    Render.observe(added);
    $('ul', block).append(...added.children);
    block.style.cursor = '';
    setTimeout(() =>
      this.parentNode.remove());
  }

  static rec([name, id, img, count]) {
    const {type, TID} = this;
    return {
      tag: 'li',
      $mal: count ? '' : 'auto-rec',
      onclick: Render._kitsuLinkPreclicked,
      onauxclick: Render._kitsuLinkPreclicked,
      onmousedown: Render._kitsuLinkPreclicked,
      onmouseup: Render._kitsuLinkPreclicked,
      onmouseover: Render.kitsuLink,
      children: [{
        tag: 'small',
        children: [
          !count ?
            'auto-rec' :
            Util.externalLink({
              $mal: 'rec',
              href: `${MAL_URL}recommendations/${type}/${id}-${TID.slice(1)}`,
              className: KITSU_GRAY_LINK_CLASS,
              textContent: `${count} rec${count > 1 ? 's' : ''}`,
            }),
        ],
      }, Util.externalLink({
        $mal: 'title',
        title: name,
        href: `${MAL_URL}${type}/${id}`,
        className: KITSU_GRAY_LINK_CLASS,
        children: [{
          tag: 'span',
          textContent: name,
        }],
      }), {
        tag: 'div',
        [$LAZY_ATTR]: `${MAL_CDN_URL}images/${type}/${img}${MAL_IMG_EXT}`,
      }],
    };
  }

  static async kitsuLink() {
    this.onmouseover = null;

    const image = $('div', this);
    const malLink = $('a[mal="title"]', this);
    const typeId = MalTypeId.fromUrl(malLink.href);
    const TID = MalTypeId.toTID(typeId);
    const [type, id] = typeId.split('/');

    const {path = ''} = await Cache.idb.get(TID, 'TID') || {};
    let slug = path.split('/')[1];

    if (!slug) {
      const mappings = await API.mappings({
        filter: {
          externalId: id,
          externalSite: 'myanimelist/' + type,
        },
      });
      const entry = mappings.data[0];
      if (entry) {
        const mappingId = entry.id;
        const mapping = await API.mappings[mappingId].item({
          fields: {
            [type]: 'slug',
          },
        });
        slug = mapping.data.attributes.slug;
        Cache.write(type, slug, {TID});
      }
    }

    if (slug) {
      $create({
        tag: 'a',
        href: `/${type}/${slug}`,
        className: KITSU_GRAY_LINK_CLASS,
        children: image,
        parent: this,
      });
    } else {
      malLink.appendChild(image);
    }

    if (!this.onmousedown && this.onmouseup) {
      await new Promise(resolve => addEventListener('mouseup', resolve, {once: true}));
      await Util.nextTick();
    }
    this.onclick = null;
    this.onauxclick = null;
    this.onmousedown = null;
    this.onmouseup = null;
  }

  static malName(str) {
    const i = str.indexOf(', ');
    // <wbr> wraps even with "white-space:nowrap" so it's better than unicode zero-width space
    if (i < 0) {
      const words = str.split(/\s+/);
      return words.length <= 2
        ? words.join(' ')
        : words[0] + ' ' + words.slice(1).join('\xA0');
    } else {
      return [
        str.slice(i + 2).replace(/\s+/, '\xA0') + ' ',
        {tag: 'wbr'},
        str.slice(0, i).replace(/\s+/, '\xA0'),
      ];
    }
  }

  static _charsHovered() {
    const hovering = this.matches(':hover');
    if (hovering !== this.hasAttribute('hovered')) {
      clearTimeout(this[ID.me]);
      this[ID.me] = setTimeout(Render._charsHoveredTimer, hovering ? 250 : 1000, this);
    }
  }

  static _charsHoveredTimer(el) {
    $attributize(el, 'hovered', el.matches(':hover') ? '' : null);
  }

  static async _kitsuLinkPreclicked(e) {
    if (!e.target.style.backgroundImage)
      return;
    if (e.type === 'mousedown') {
      this.onmousedown = null;
      return;
    }
    if (e.type === 'mouseup') {
      this.onmouseup = null;
      await Util.nextTick();
      if (!this.onclick)
        return;
    }
    this.onclick = null;
    this.onauxclick = null;
    if (e.altKey || e.metaKey || e.button > 1)
      return;

    let link = e.target.closest('a');
    if (!link) {
      const winner = await Promise.race([
        Mutant.gotMoved(e.target),
        Mutant.gotPath(),
      ]);
      if (winner !== true)
        return;
    }

    const {button: btn, ctrlKey: c, shiftKey: s} = e;
    link = e.target.closest('a');
    if (!btn && !c) {
      link.dispatchEvent(new MouseEvent('click', e));
      if (!s)
        App.onUrlChange(link.pathname);
    } else {
      GM_openInTab(link.href, {
        active: btn === 0 && c && s,
        insert: true,
        setParent: true,
      });
    }
  }

  static _lazyLoad(entries) {
    for (const e of entries) {
      if (e.isIntersecting) {
        const el = e.target;
        let url = el.getAttribute(LAZY_ATTR);

        if (el instanceof HTMLImageElement) {
          if (el.src !== url)
            el.src = url;
        } else {
          url = `url(${url})`;
          if (el.style.backgroundImage !== url)
            el.style.backgroundImage = url;
        }

        el.removeAttribute(LAZY_ATTR);
        Render.scrollObserver.unobserve(el);
      }
    }
  }
}


class Util {

  static str2num(str) {
    return str && Number(str.replace(/,/g, '')) || undefined;
  }

  static str2gist(str) {
    return str.replace(/\W+/g, ' ').trim().toLowerCase();
  }

  static num2str(num) {
    return num && num.toLocaleString() || '';
  }

  static num2pct(n, numDecimals = 2) {
    return (n * 100).toFixed(numDecimals).replace(/\.?0+$/, '') + '%';
  }

  static decodeHtml(str) {
    if (str.includes('&#')) {
      str = str.replace(/&#(x?)([\da-f]);/gi, (_, hex, code) =>
        String.fromCharCode(parseInt(code, hex ? 16 : 10)));
    }
    if (!str.includes('&') ||
        !/&\w+;/.test(str))
      return str;
    if (!Mal.parser)
      Mal.parser = new DOMParser();
    const doc = Mal.parser.parseFromString(str, 'text/html');
    return doc.body.firstChild.textContent;
  }

  static parseJson(str) {
    try {
      return JSON.parse(str);
    } catch (e) {}
  }

  static nextTick() {
    return new Promise(setTimeout);
  }

  static fetchDoc(url) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        url,
        method: 'GET',
        onload(r) {
          const doc = new DOMParser().parseFromString(r.response, 'text/html');
          resolve(doc);
        },
      });
    });
  }

  static fetchJson(url) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        url,
        method: 'GET',
        responseType: 'json',
        onload(r) {
          resolve(r.response);
        },
      });
    });
  }

  static externalLink(
    props,
    children = props.children || props.textContent || []
  ) {
    props.tag = 'a';
    props.target = '_blank';
    props.rel = 'noopener noreferrer';
    props.children = Array.isArray(children) ? children : [children];
    props.children.push(EXT_LINK);
    delete props.textContent;
    return props;
  }
}


/** @return {HTMLElement} */
function $(selector, node = document) {
  return node.querySelector(selector);
}

/** @return {HTMLElement} */
function $id(id, doc = document) {
  return doc.getElementById(id);
}

/** @return {HTMLElement[]} */
function $$(selector, node = document) {
  return [...node.querySelectorAll(selector)];
}

/** @return {String} */
function $text(selector, node = document) {
  const el = typeof selector === 'string' ?
    node.querySelector(selector) :
    selector;
  return el ? el.textContent.trim() : '';
}

/** @return {HTMLElement} */
function $create(props,
                 children = props.children || [],
                 referenceNode = props.id && $id(props.id)) {
  let el;
  let childIndex = -1;
  const hasOwnProperty = Object.hasOwnProperty;
  const toAppend = [];

  if (!Array.isArray(children))
    children = [children];

  for (
    let index = 0, node, info = props, ref = referenceNode;
    index <= children.length;
    info = children[index], ref = el.childNodes[childIndex], index++
  ) {

    if (!info)
      continue;

    childIndex++;

    let ns;
    const isNode = info instanceof Node;

    if (isNode) {
      node = info;
    } else {
      let {tag} = info;
      const i = tag ? tag.indexOf(':') : -1;
      if (i >= 0) {
        ns = tag.slice(0, i);
        tag = tag.slice(i + 1);
      }
      node = ref && ref.localName === (tag && tag.toLowerCase()) && ref || (
        !tag ?
          document.createTextNode(info) :
          /^SVG$/i.test(ns) ?
            document.createElementNS('http://www.w3.org/2000/svg', tag) :
            document.createElement(tag));
    }

    const type = node.nodeType;

    if (index === 0)
      el = node;
    else if (!ref)
      toAppend.push(node);
    else if (isNode || ref.localName !== node.localName)
      ref.parentNode.replaceChild(node, ref);

    if (isNode)
      continue;

    if (type === Node.TEXT_NODE) {
      if (ref && ref.nodeValue !== info)
        ref.nodeValue = info;
      continue;
    }

    if (index > 0 && info.children) {
      $create(info, undefined, node);
      continue;
    }

    for (const k in info) {
      if (!hasOwnProperty.call(info, k) ||
          k === 'tag' ||
          k === 'children' ||
          k === 'parent' ||
          k === 'after' ||
          k === 'before')
        continue;
      const v = info[k];
      const attr = k.startsWith('$') ? k.slice(1) : null;
      if (attr || ns)
        $attributize(node, attr || k, v);
      else if (node[k] !== v)
        node[k] = v;
    }
  }

  if (toAppend.length)
    el.append(...toAppend);
  else {
    const numExpected = childIndex + (props.textContent ? 1 : 0);
    const numZombies = el.childNodes.length - numExpected;
    for (let i = 0; i < numZombies; i++)
      el.lastChild.remove();
  }

  if (props.parent &&
      props.parent !== el.parentNode)
    props.parent.appendChild(el);

  if (props.before &&
      props.before !== el.nextSibling)
    props.before.insertAdjacentElement('beforeBegin', el);

  if (props.after &&
      props.after !== el.previousSibling)
    props.after.insertAdjacentElement('afterEnd', el);

  return el;
}

function $remove(selectorOrNode, base) {
  const el = selectorOrNode instanceof Node ?
    selectorOrNode :
    $(selectorOrNode, base);
  if (el)
    el.remove();
}

function $attributize(node, attr, value) {
  if (value === null)
    node.removeAttribute(attr);
  else if (value !== node.getAttribute(attr))
    node.setAttribute(attr, value);
}

App.init();