Nico Nico Ranking NG

ニコニコ動画のランキングとキーワード・タグ検索結果に NG 機能を追加

// ==UserScript==
// @name         Nico Nico Ranking NG
// @namespace    http://userscripts.org/users/121129
// @description  ニコニコ動画のランキングとキーワード・タグ検索結果に NG 機能を追加
// @match        *://www.nicovideo.jp/ranking/genre/*
// @match        *://www.nicovideo.jp/ranking/hot_topic*
// @match        *://www.nicovideo.jp/search/*
// @match        *://www.nicovideo.jp/tag/*
// @version      47
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.xmlHttpRequest
// @grant        GM.openInTab
// @license      MIT License
// @noframes
// @run-at       document-start
// @connect      ext.nicovideo.jp
// ==/UserScript==

// https://d3js.org/d3-dsv/ Version 1.0.0. Copyright 2016 Mike Bostock.
;(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (factory((global.d3 = global.d3 || {})));
}(this, function (exports) { 'use strict';

  function objectConverter(columns) {
    return new Function("d", "return {" + columns.map(function(name, i) {
      return JSON.stringify(name) + ": d[" + i + "]";
    }).join(",") + "}");
  }

  function customConverter(columns, f) {
    var object = objectConverter(columns);
    return function(row, i) {
      return f(object(row), i, columns);
    };
  }

  // Compute unique columns in order of discovery.
  function inferColumns(rows) {
    var columnSet = Object.create(null),
        columns = [];

    rows.forEach(function(row) {
      for (var column in row) {
        if (!(column in columnSet)) {
          columns.push(columnSet[column] = column);
        }
      }
    });

    return columns;
  }

  function dsv(delimiter) {
    var reFormat = new RegExp("[\"" + delimiter + "\n]"),
        delimiterCode = delimiter.charCodeAt(0);

    function parse(text, f) {
      var convert, columns, rows = parseRows(text, function(row, i) {
        if (convert) return convert(row, i - 1);
        columns = row, convert = f ? customConverter(row, f) : objectConverter(row);
      });
      rows.columns = columns;
      return rows;
    }

    function parseRows(text, f) {
      var EOL = {}, // sentinel value for end-of-line
          EOF = {}, // sentinel value for end-of-file
          rows = [], // output rows
          N = text.length,
          I = 0, // current character index
          n = 0, // the current line number
          t, // the current token
          eol; // is the current token followed by EOL?

      function token() {
        if (I >= N) return EOF; // special case: end of file
        if (eol) return eol = false, EOL; // special case: end of line

        // special case: quotes
        var j = I, c;
        if (text.charCodeAt(j) === 34) {
          var i = j;
          while (i++ < N) {
            if (text.charCodeAt(i) === 34) {
              if (text.charCodeAt(i + 1) !== 34) break;
              ++i;
            }
          }
          I = i + 2;
          c = text.charCodeAt(i + 1);
          if (c === 13) {
            eol = true;
            if (text.charCodeAt(i + 2) === 10) ++I;
          } else if (c === 10) {
            eol = true;
          }
          return text.slice(j + 1, i).replace(/""/g, "\"");
        }

        // common case: find next delimiter or newline
        while (I < N) {
          var k = 1;
          c = text.charCodeAt(I++);
          if (c === 10) eol = true; // \n
          else if (c === 13) { eol = true; if (text.charCodeAt(I) === 10) ++I, ++k; } // \r|\r\n
          else if (c !== delimiterCode) continue;
          return text.slice(j, I - k);
        }

        // special case: last token before EOF
        return text.slice(j);
      }

      while ((t = token()) !== EOF) {
        var a = [];
        while (t !== EOL && t !== EOF) {
          a.push(t);
          t = token();
        }
        if (f && (a = f(a, n++)) == null) continue;
        rows.push(a);
      }

      return rows;
    }

    function format(rows, columns) {
      if (columns == null) columns = inferColumns(rows);
      return [columns.map(formatValue).join(delimiter)].concat(rows.map(function(row) {
        return columns.map(function(column) {
          return formatValue(row[column]);
        }).join(delimiter);
      })).join("\n");
    }

    function formatRows(rows) {
      return rows.map(formatRow).join("\n");
    }

    function formatRow(row) {
      return row.map(formatValue).join(delimiter);
    }

    function formatValue(text) {
      return text == null ? ""
          : reFormat.test(text += "") ? "\"" + text.replace(/"/g, "\"\"") + "\""
          : text;
    }

    return {
      parse: parse,
      parseRows: parseRows,
      format: format,
      formatRows: formatRows
    };
  }

  var csv = dsv(",");

  var csvParse = csv.parse;
  var csvParseRows = csv.parseRows;
  var csvFormat = csv.format;
  var csvFormatRows = csv.formatRows;

  var tsv = dsv("\t");

  var tsvParse = tsv.parse;
  var tsvParseRows = tsv.parseRows;
  var tsvFormat = tsv.format;
  var tsvFormatRows = tsv.formatRows;

  exports.dsvFormat = dsv;
  exports.csvParse = csvParse;
  exports.csvParseRows = csvParseRows;
  exports.csvFormat = csvFormat;
  exports.csvFormatRows = csvFormatRows;
  exports.tsvParse = tsvParse;
  exports.tsvParseRows = tsvParseRows;
  exports.tsvFormat = tsvFormat;
  exports.tsvFormatRows = tsvFormatRows;

  Object.defineProperty(exports, '__esModule', { value: true });

}));

;(function() {
  'use strict'

  var createObject = function(prototype, properties) {
    var descriptors = function() {
      return Object.keys(properties).reduce(function(descriptors, key) {
        descriptors[key] = Object.getOwnPropertyDescriptor(properties, key)
        return descriptors
      }, {})
    }
    return Object.defineProperties(Object.create(prototype), descriptors())
  }
  var set = function(target, propertyName) {
    return function(value) { target[propertyName] = value }
  }
  var movieIdOf = function(absoluteMovieURL) {
    return new URL(absoluteMovieURL).pathname.slice('/watch/'.length)
  }
  const ancestor = (child, selector) => {
    for (let n = child.parentNode; n; n = n.parentNode)
      if (n.matches(selector))
        return n
    return null
  }

  var EventEmitter = (function() {
    var EventEmitter = function() {
      this._eventNameToListeners = new Map()
    }
    EventEmitter.prototype = {
      on(eventName, listener) {
        var m = this._eventNameToListeners
        var v = m.get(eventName)
        if (v) {
          v.add(listener)
        } else {
          m.set(eventName, new Set([listener]))
        }
        return this
      },
      emit(eventName) {
        var m = this._eventNameToListeners
        var args = Array.from(arguments).slice(1)
        for (var l of m.get(eventName) || []) l(...args)
      },
      off(eventName, listener) {
        var v = this._eventNameToListeners.get(eventName)
        if (v) v.delete(listener)
      },
    }
    return EventEmitter
  })()

  var Listeners = (function() {
    var Listeners = function(eventNameToListener) {
      this.eventNameToListener = eventNameToListener
      this.eventEmitter = null
    }
    Listeners.prototype = {
      bind(eventEmitter) {
        this.eventEmitter = eventEmitter
        Object.keys(this.eventNameToListener).forEach(function(k) {
          eventEmitter.on(k, this.eventNameToListener[k])
        }, this)
      },
      unbind() {
        if (!this.eventEmitter) return
        Object.keys(this.eventNameToListener).forEach(function(k) {
          this.eventEmitter.off(k, this.eventNameToListener[k])
        }, this)
      },
    }
    return Listeners
  })()

  var ArrayStore = (function(_super) {
    var isObject = function(v) {
      return v === Object(v)
    }
    var valueIfObj = function(v) {
      return isObject(v) ? v.value : v
    }
    var toUpperCase = function(s) {
      return s.toUpperCase()
    }
    var ArrayStore = function(getValue, setValue, key, caseInsensitive) {
      _super.call(this)
      this.getValue = getValue
      this.setValue = setValue
      this.key = key
      this.caseInsensitive = Boolean(caseInsensitive)
      this._arrayWithText = []
    }
    ArrayStore.prototype = createObject(_super.prototype, {
      get array() {
        return this.arrayWithText.map(valueIfObj)
      },
      get arrayWithText() {
        return this._arrayWithText
      },
      _setOf(values) {
        return new Set(this.caseInsensitive ? values.map(toUpperCase) : values)
      },
      get set() {
        return this._setOf(this.array)
      },
      _toUpperCaseIfRequired(value) {
        return this.caseInsensitive ? value.toUpperCase() : value
      },
      _concat(value, text) {
        return this.arrayWithText.concat(text ? {value, text} : value)
      },
      add(value, text) {
        if (this.set.has(this._toUpperCaseIfRequired(value))) return false
        this.arrayWithText.push(text ? {value, text} : value)
        this.setValue(this.key, JSON.stringify(this.arrayWithText))
        this.emit('changed', this.set)
        return true
      },
      async addAsync(value, text) {
        await this.sync()
        return this.add(value, text)
      },
      addAll(values) {
        if (values.length === 0) return
        var oldVals = this.arrayWithText
        var set = this._setOf(oldVals.map(valueIfObj))
        var filtered = values.filter(function(v) {
          return !set.has(this._toUpperCaseIfRequired(valueIfObj(v)))
        }, this)
        if (filtered.length === 0) return
        this.arrayWithText.push(...filtered)
        this.setValue(this.key, JSON.stringify(this.arrayWithText))
        this.emit('changed', this.set)
      },
      _reject(values) {
        var valueSet = this._setOf(values)
        return this.arrayWithText.filter(function(v) {
          return !valueSet.has(this._toUpperCaseIfRequired(valueIfObj(v)))
        }, this)
      },
      remove(values) {
        const oldVals = this.arrayWithText
        const newVals = this._reject(values)
        if (oldVals.length === newVals.length) return
        this._arrayWithText = newVals
        this.setValue(this.key, JSON.stringify(newVals))
        this.emit('changed', this.set)
      },
      async removeAsync(values) {
        await this.sync()
        this.remove(values)
      },
      clear() {
        if (!this.arrayWithText.length) return
        this._arrayWithText = []
        this.setValue(this.key, '[]')
        this.emit('changed', new Set())
      },
      async sync() {
        this._arrayWithText = JSON.parse(await this.getValue(this.key, '[]'))
      },
    })
    return ArrayStore
  })(EventEmitter)

  var Store = (function(_super) {
    var Store = function(getValue, setValue, key, defaultValue) {
      _super.call(this)
      this.getValue = getValue
      this.setValue = setValue
      this.key = key
      this._value = this.defaultValue = defaultValue
    }
    Store.prototype = createObject(_super.prototype, {
      get value() {
        return this._value
      },
      set value(value) {
        if (this._value === value) return
        this._value = value
        this.setValue(this.key, value)
        this.emit('changed', value)
      },
      async sync() {
        this._value = await this.getValue(this.key, this.defaultValue)
      }
    })
    return Store
  })(EventEmitter)

  var Config = (function() {
    var ngMovieVisibleStore = function() {
      var value
      var getValue = function(_, defval) {
        return value === undefined ? defval : value
      }
      var setValue = function(_, v) { value = v }
      return new Store(getValue, setValue, 'ngMovieVisible', false)
    }
    var csv = (function() {
      var RECORD_LENGTH = 3
      var TYPE = 0
      var VALUE = 1
      var TEXT = 2
      var isObject = function(v) {
        return v === Object(v)
      }
      var csvToArray = function(csv) {
        /*
          パース対象の文字列最後の文字がカンマのとき、
          そのカンマが空のフィールドとしてパースされない。
          \n を追加して対処する。
        */
        return d3.csvParseRows(csv + '\n')
      }
      var createRecord = function(type, value, text) {
        var result = []
        result[TYPE] = type
        result[VALUE] = value
        result[TEXT] = text
        return result
      }
      var trimFields = function(record) {
        var r = record
        return createRecord(r[TYPE].trim(), r[VALUE].trim(), r[TEXT].trim())
      }
      var isIntValueType = function(type) {
        return ['ngUserId', 'ngChannelId'].indexOf(type) >= 0
      }
      var hasValidValue = function(record) {
        var v = record[VALUE]
        return v.length !== 0
            && !(isIntValueType(record[TYPE]) && Number.isNaN(Math.trunc(v)))
      }
      var valueToIntIfIntValueType = function(record) {
        var r = record
        return isIntValueType(r[TYPE])
             ? createRecord(r[TYPE], Math.trunc(r[VALUE]), r[TEXT])
             : r
      }
      var records = function(csv) {
        return csvToArray(csv)
          .filter(function(record) { return record.length === RECORD_LENGTH })
          .map(trimFields)
          .filter(hasValidValue)
          .map(valueToIntIfIntValueType)
      }
      var isValueOnlyType = function(type) {
        return ['ngTitle', 'ngTag', 'ngUserName'].indexOf(type) >= 0
      }
      var getValue = function(record) {
        var value = record[VALUE]
        if (isValueOnlyType(record[TYPE])) return value
        var text = record[TEXT]
        return text ? {value, text} : value
      }
      var createTypeToValuesMap = function() {
        return new Map([
          ['ngMovieId', []],
          ['ngTitle', []],
          ['ngTag', []],
          ['ngUserId', []],
          ['ngUserName', []],
          ['ngChannelId', []],
          ['visitedMovieId', []],
        ])
      }
      return {
        create(arrayStore, type) {
          return d3.csvFormatRows(arrayStore.arrayWithText.map(function(value) {
            return isObject(value)
                 ? createRecord(type, value.value, value.text)
                 : createRecord(type, value, '')
          }))
        },
        parse(csv) {
          var result = createTypeToValuesMap()
          for (var r of records(csv)) {
            var values = result.get(r[TYPE])
            if (values) values.push(getValue(r))
          }
          return result
        },
      }
    })()

    var Config = function(getValue, setValue) {
      var store = function(key, defaultValue) {
        return new Store(getValue, setValue, key, defaultValue || true)
      }
      var arrayStore = function(key, caseInsensitive) {
        return new ArrayStore(getValue, setValue, key, caseInsensitive)
      }
      this.visitedMovieViewMode = store('visitedMovieViewMode', 'reduce')
      this.visibleContributorType = store('visibleContributorType', 'all')
      this.openNewWindow = store('openNewWindow')
      this.useGetThumbInfo = store('useGetThumbInfo')
      this.movieInfoTogglable = store('movieInfoTogglable')
      this.descriptionTogglable = store('descriptionTogglable')
      this.seamlessRankingNumber = store('seamlessRankingNumber')
      this.requestingNext = store('requestingNext')
      this.popupVisible = store('popupVisible')
      this.movingUp = store('movingUp')
      this.visitedMovies = arrayStore('visitedMovies')
      this.ngMovies = arrayStore('ngMovies')
      this.ngTitles = arrayStore('ngTitles', true)
      this.ngTags = arrayStore('ngTags', true)
      this.ngUserIds = arrayStore('ngUserIds')
      this.ngUserNames = arrayStore('ngUserNames', true)
      this.ngChannelIds = arrayStore('ngChannelIds')
      this.ngMovieVisible = ngMovieVisibleStore()
    }
    Config.prototype.sync = function() {
      return Promise.all([
        this.visitedMovieViewMode.sync(),
        this.visibleContributorType.sync(),
        this.openNewWindow.sync(),
        this.useGetThumbInfo.sync(),
        this.movieInfoTogglable.sync(),
        this.descriptionTogglable.sync(),
        this.seamlessRankingNumber.sync(),
        this.requestingNext.sync(),
        this.popupVisible.sync(),
        this.movingUp.sync(),
        this.visitedMovies.sync(),
        this.ngMovies.sync(),
        this.ngTitles.sync(),
        this.ngTags.sync(),
        this.ngUserIds.sync(),
        this.ngUserNames.sync(),
        this.ngChannelIds.sync(),
      ])
    }
    Config.prototype.toCSV = async function(targetTypes) {
      await this.sync()
      var csvTexts = []
      if (targetTypes['ngMovieId']) {
        csvTexts.push(csv.create(this.ngMovies, 'ngMovieId'))
      }
      if (targetTypes['ngTitle']) {
        csvTexts.push(csv.create(this.ngTitles, 'ngTitle'))
      }
      if (targetTypes['ngTag']) {
        csvTexts.push(csv.create(this.ngTags, 'ngTag'))
      }
      if (targetTypes['ngUserId']) {
        csvTexts.push(csv.create(this.ngUserIds, 'ngUserId'))
      }
      if (targetTypes['ngUserName']) {
        csvTexts.push(csv.create(this.ngUserNames, 'ngUserName'))
      }
      if (targetTypes['ngChannelId']) {
        csvTexts.push(csv.create(this.ngChannelIds, 'ngChannelId'))
      }
      if (targetTypes['visitedMovieId']) {
        csvTexts.push(csv.create(this.visitedMovies, 'visitedMovieId'))
      }
      return csvTexts.filter(Boolean).join('\n')
    }
    Config.prototype.addFromCSV = async function(csvText) {
      await this.sync()
      var map = csv.parse(csvText)
      this.ngMovies.addAll(map.get('ngMovieId'))
      this.ngTitles.addAll(map.get('ngTitle'))
      this.ngTags.addAll(map.get('ngTag'))
      this.ngUserIds.addAll(map.get('ngUserId'))
      this.ngUserNames.addAll(map.get('ngUserName'))
      this.ngChannelIds.addAll(map.get('ngChannelId'))
      this.visitedMovies.addAll(map.get('visitedMovieId'))
    }
    return Config
  })()

  var ThumbInfo = (function(_super) {
    var parseTags = function(tags) {
      return Array.from(tags, function(t) { return t.textContent })
    }
    var contributor = function(rootElem, type, id, name) {
      return {
        type: type,
        id: parseInt(rootElem.querySelector(id).textContent),
        name: rootElem.querySelector(name).textContent,
      }
    }
    var user = function(rootElem) {
      return contributor(rootElem
                       , 'user'
                       , 'thumb > user_id'
                       , 'thumb > user_nickname')
    }
    var channel = function(rootElem) {
      return contributor(rootElem
                       , 'channel'
                       , 'thumb > ch_id'
                       , 'thumb > ch_name')
    }
    var parseContributor = function(rootElem) {
      var userId = rootElem.querySelector('thumb > user_id')
      return userId ? user(rootElem) : channel(rootElem)
    }
    var parseThumbInfo = function(rootElem) {
      return {
        description: rootElem.querySelector('thumb > description').textContent,
        tags: parseTags(rootElem.querySelectorAll('thumb > tags > tag')),
        contributor: parseContributor(rootElem),
        title: rootElem.querySelector('thumb > title').textContent,
        error: {type: 'NO_ERROR', message: 'no error'},
      }
    }
    var error = function(type, message, id) {
      var result = {error: {type, message}}
      if (id) result.id = id
      return result
    }
    var parseError = function(rootElem) {
      var type = rootElem.querySelector('error > code').textContent
      switch (type) {
        case 'DELETED': return error(type, '削除された動画')
        case 'NOT_FOUND': return error(type, '見つからない、または無効な動画')
        case 'COMMUNITY': return error(type, 'コミュニティ限定動画')
        default: return error(type, 'エラーコード: ' + type)
      }
    }
    var parseResText = function(resText) {
      try {
        var d = new DOMParser().parseFromString(resText, 'application/xml')
        var r = d.documentElement
        var status = r.getAttribute('status')
        switch (status) {
          case 'ok': return parseThumbInfo(r)
          case 'fail': return parseError(r)
          default: return error(status, 'ステータス: ' + status)
        }
      } catch (e) {
        return error('PARSING', 'パースエラー')
      }
    }
    var statusMessage = function(res) {
      return res.status + ' ' + res.statusText
    }

    var ThumbInfo = function(httpRequest, concurrent) {
      _super.call(this)
      this.httpRequest = httpRequest
      this.concurrent = concurrent || 5
      this._requestCount = 0
      this._pendingIds = []
      this._requestedIds = new Set()
    }
    ThumbInfo.prototype = createObject(_super.prototype, {
      _onerror(id) {
        this._requestCount--
        this._requestNextMovie()
        this.emit('errorOccurred', error('ERROR', 'エラー', id))
      },
      _ontimeout(id, retried) {
        if (retried) {
          this._requestCount--
          this._requestNextMovie()
          this.emit('errorOccurred', error('TIMEOUT', 'タイムアウト', id))
        } else {
          this._requestMovie(id, true)
        }
      },
      _onload(id, res) {
        this._requestCount--
        this._requestNextMovie()
        if (res.status === 200) {
          var thumbInfo = parseResText(res.responseText)
          thumbInfo.id = id
          if (thumbInfo.error.type === 'NO_ERROR') {
            this.emit('completed', thumbInfo)
          } else {
            this.emit('errorOccurred', thumbInfo)
          }
        } else {
          this.emit('errorOccurred'
                  , error('HTTP_STATUS', statusMessage(res), id))
        }
      },
      _requestMovie(id, retry) {
        this.httpRequest({
          method: 'GET',
          url: 'https://ext.nicovideo.jp/api/getthumbinfo/' + id,
          timeout: 5000,
          onload: this._onload.bind(this, id),
          onerror: this._onerror.bind(this, id),
          ontimeout: this._ontimeout.bind(this, id, retry),
        })
      },
      _requestNextMovie() {
        var id = this._pendingIds.shift()
        if (!id) return
        this._requestMovie(id)
        this._requestCount++
      },
      _getNewIds(ids) {
        ids = ids || []
        var m = this._requestedIds
        return [...new Set(ids)].filter(function(id) { return !m.has(id) })
      },
      _requestAsPossible() {
        var space = this.concurrent - this._requestCount
        var c = Math.min(this._pendingIds.length, space)
        for (var i = 0; i < c; i++) this._requestNextMovie()
      },
      request(ids, prefer) {
        const newIds = this._getNewIds(ids)
        for (const id of newIds) this._requestedIds.add(id)
        if (prefer) {
          for (const id of newIds) this._requestMovie(id)
          return
        }
        ;[].push.apply(this._pendingIds, newIds)
        this._requestAsPossible()
        return this
      },
    })
    return ThumbInfo
  })(EventEmitter)

  var Tag = (function(_super) {
    var Tag = function(name) {
      _super.call(this)
      this.name = name
      this.ng = false
    }
    Tag.prototype = createObject(_super.prototype, {
      updateNg(upperCaseNgTagNameSet) {
        var pre = this.ng
        this.ng = upperCaseNgTagNameSet.has(this.name.toUpperCase())
        if (pre !== this.ng) this.emit('ngChanged', this.ng)
      },
    })
    return Tag
  })(EventEmitter)

  var Contributor = (function(_super) {
    var Contributor = function(type, id, name) {
      _super.call(this)
      this.type = type
      this.id = id
      this.name = name
      this.ng = false
      this.ngId = false
      this.ngName = ''
    }
    Contributor.prototype = createObject(_super.prototype, {
      _updateNg() {
        var pre = this.ng
        this.ng = this.ngId || Boolean(this.ngName)
        if (pre !== this.ng) this.emit('ngChanged', this.ng)
      },
      updateNgId(ngIdSet) {
        var pre = this.ngId
        this.ngId = ngIdSet.has(this.id)
        if (pre !== this.ngId) this.emit('ngIdChanged', this.ngId)
        this._updateNg()
      },
      _getNewNgName(upperCaseNgNameSet) {
        var n = this.name.toUpperCase()
        for (var ngName of upperCaseNgNameSet)
          if (n.includes(ngName)) return ngName
        return ''
      },
      updateNgName(upperCaseNgNameSet) {
        var pre = this.ngName
        this.ngName = this._getNewNgName(upperCaseNgNameSet)
        if (pre !== this.ngName) this.emit('ngNameChanged', this.ngName)
        this._updateNg()
      },
      get url() {
        throw new Error('must be implemented')
      },
      bindToConfig(config) {
        this.updateNgId(config[this.ngIdStoreName].set)
        config[this.ngIdStoreName].on('changed', this.updateNgId.bind(this))
      },
    })

    var User = function(id, name) {
      Contributor.call(this, 'user', id, name)
    }
    User.prototype = createObject(Contributor.prototype, {
      get ngIdStoreName() { return 'ngUserIds' },
      get url() {
        return 'https://www.nicovideo.jp/user/' + this.id
      },
      bindToConfig(config) {
        Contributor.prototype.bindToConfig.call(this, config)
        this.updateNgName(config.ngUserNames.set)
        config.ngUserNames.on('changed', this.updateNgName.bind(this))
      },
    })

    var Channel = function(id, name) {
      Contributor.call(this, 'channel', id, name)
    }
    Channel.prototype = createObject(Contributor.prototype, {
      get ngIdStoreName() { return 'ngChannelIds' },
      get url() {
        return 'https://ch.nicovideo.jp/channel/ch' + this.id
      },
    })

    Object.assign(Contributor, {
      NULL: new Contributor('unknown', -1, ''),
      TYPES: ['user', 'channel'],
      new(type, id, name) {
        switch (type) {
          case 'user': return new User(id, name)
          case 'channel': return new Channel(id, name)
          default: throw new Error(type)
        }
      },
    })
    return Contributor
  })(EventEmitter)

  var Movie = (function(_super) {
    var Movie = function(id, title) {
      _super.call(this)
      this.id = id
      this.title = title
      this.ngTitle = ''
      this.ngId = false
      this.visited = false
      this._tags = []
      this._contributor = Contributor.NULL
      this._description = ''
      this._error = Movie.NO_ERROR
      this._thumbInfoDone = false
      this._ng = false
    }
    Movie.NO_ERROR = {type: 'NO_ERROR', message: 'no error'}
    Movie.prototype = createObject(_super.prototype, {
      _matchedNgTitle(upperCaseNgTitleSet) {
        var t = this.title.toUpperCase()
        for (var ng of upperCaseNgTitleSet) {
          if (t.includes(ng)) return ng
        }
        return ''
      },
      updateNgTitle(upperCaseNgTitleSet) {
        var pre = this.ngTitle
        this.ngTitle = this._matchedNgTitle(upperCaseNgTitleSet)
        if (pre === this.ngTitle) return
        this.emit('ngTitleChanged', this.ngTitle)
        this._updateNg()
      },
      updateNgId(ngIdSet) {
        var pre = this.ngId
        this.ngId = ngIdSet.has(this.id)
        if (pre === this.ngId) return
        this.emit('ngIdChanged', this.ngId)
        this._updateNg()
      },
      updateVisited(visitedIdSet) {
        var pre = this.visited
        this.visited = visitedIdSet.has(this.id)
        if (pre !== this.visited) this.emit('visitedChanged', this.visited)
      },
      get description() { return this._description },
      set description(description) {
        this._description = description
        this.emit('descriptionChanged', this._description)
      },
      get tags() { return this._tags },
      set tags(tags) {
        this._tags = tags
        this.emit('tagsChanged', this._tags)
        this._updateNg()
        var update = this._updateNg.bind(this)
        for (var t of this._tags) t.on('ngChanged', update)
      },
      get contributor() { return this._contributor },
      set contributor(contributor) {
        this._contributor = contributor
        this.emit('contributorChanged', this._contributor)
        this._updateNg()
        this._contributor.on('ngChanged', this._updateNg.bind(this))
      },
      get error() { return this._error },
      set error(error) {
        this._error = error
        this.emit('errorChanged', this._error)
      },
      get thumbInfoDone() { return this._thumbInfoDone },
      setThumbInfoDone() {
        this._thumbInfoDone = true
        this.emit('thumbInfoDone')
      },
      get ng() { return this._ng },
      _updateNg() {
        var pre = this._ng
        this._ng = this.ngId
          || Boolean(this.ngTitle)
          || this.contributor.ng
          || this.tags.some(function(t) { return t.ng })
        if (pre !== this._ng) this.emit('ngChanged', this._ng)
      },
      addListenerToConfig(config) {
        config.ngMovies.on('changed', this.updateNgId.bind(this))
        config.ngTitles.on('changed', this.updateNgTitle.bind(this))
        config.visitedMovies.on('changed', this.updateVisited.bind(this))
      },
    })
    return Movie
  })(EventEmitter)

  var Movies = (function() {
    var Movies = function(config) {
      this.config = config
      this._idToMovie = new Map()
    }
    Movies.prototype = {
      setIfAbsent(movies) {
        var ngIds = this.config.ngMovies.set
        var ngTitles = this.config.ngTitles.set
        var visitedIds = this.config.visitedMovies.set
        var map = this._idToMovie
        for (var m of movies) {
          if (map.has(m.id)) continue
          map.set(m.id, m)
          m.updateNgId(ngIds)
          m.updateNgTitle(ngTitles)
          m.updateVisited(visitedIds)
          m.addListenerToConfig(this.config)
        }
      },
      get(movieId) {
        return this._idToMovie.get(movieId)
      },
    }
    return Movies
  })()

  var ThumbInfoListener = (function() {
    var createTagBuilder = function(config) {
      var map = new Map()
      return function(name) {
        if (map.has(name)) return map.get(name)
        var tag = new Tag(name)
        map.set(name, tag)
        config.ngTags.on('changed', tag.updateNg.bind(tag))
        return tag
      }
    }
    var createTagsBuilder = function(config) {
      var getTagBy = createTagBuilder(config)
      return function(tagNames) {
        var tags = tagNames.map(getTagBy)
        var ngTagSet = config.ngTags.set
        for (var t of tags) t.updateNg(ngTagSet)
        return tags
      }
    }
    var createContributorBuilder = function(config) {
      var typeToMap = Contributor.TYPES.reduce(function(map, type) {
        return map.set(type, new Map())
      }, new Map())
      return function(o) {
        var map = typeToMap.get(o.type)
        if (map.has(o.id)) return map.get(o.id)
        var contributor = Contributor.new(o.type, o.id, o.name)
        map.set(o.id, contributor)
        contributor.bindToConfig(config)
        return contributor
      }
    }
    return {
      forCompleted(movies) {
        var getTagsBy = createTagsBuilder(movies.config)
        var getContributorBy = createContributorBuilder(movies.config)
        return function(thumbInfo) {
          var m = movies.get(thumbInfo.id)
          m.description = thumbInfo.description
          m.tags = getTagsBy(thumbInfo.tags)
          m.contributor = getContributorBy(thumbInfo.contributor)
          m.setThumbInfoDone()
        }
      },
      forErrorOccurred(movies) {
        return function(thumbInfo) {
          var m = movies.get(thumbInfo.id)
          m.error = thumbInfo.error
          m.setThumbInfoDone()
        }
      },
    }
  })()

  var MovieViewMode = (function(_super) {
    var MovieViewMode = function(movie, config) {
      _super.call(this)
      this.movie = movie
      this.config = config
      this.value = this._newViewMode()
    }
    MovieViewMode.prototype = createObject(_super.prototype, {
      _isHiddenByNg() {
        return !this.config.ngMovieVisible.value && this.movie.ng
      },
      _isHiddenByContributorType() {
        var c = this.movie.contributor
        if (c === Contributor.NULL) return false
        var t = this.config.visibleContributorType.value
        return !(t === 'all' || t === c.type)
      },
      _isHiddenByVisitedMovieViewMode() {
        return this.movie.visited
            && this.config.visitedMovieViewMode.value === 'hide'
      },
      _isHidden() {
        return this.movie.error.type === 'DELETED'
            || this._isHiddenByContributorType()
            || this._isHiddenByNg()
            || this._isHiddenByVisitedMovieViewMode()
      },
      _isReduced() {
        return this.movie.visited
            && this.config.visitedMovieViewMode.value === 'reduce'
      },
      _newViewMode() {
        if (this._isHidden()) return 'hide'
        if (this._isReduced()) return 'reduce'
        return 'doNothing'
      },
      update() {
        var pre = this.value
        this.value = this._newViewMode()
        if (pre !== this.value) this.emit('changed', this.value)
      },
      addListener() {
        var l = this.update.bind(this)
        this.movie
          .on('errorChanged', l)
          .on('ngChanged', l)
          .on('visitedChanged', l)
          .on('contributorChanged', l)
        ;['ngMovieVisible',
          'visibleContributorType',
          'visitedMovieViewMode',
        ].forEach(function(n) {
          this.config[n].on('changed', l)
        }, this)
        return this
      },
    })
    return MovieViewMode
  })(EventEmitter)

  var MovieViewModes = (function(_super) {
    var MovieViewModes = function(config) {
      _super.call(this)
      this.config = config
      this._movieToViewMode = new Map()
      this._emitViewModeChanged = this.emit.bind(this, 'movieViewModeChanged')
    }
    MovieViewModes.prototype = createObject(_super.prototype, {
      get(movie) {
        var m = this._movieToViewMode
        if (m.has(movie)) return m.get(movie)
        var viewMode = new MovieViewMode(movie, this.config)
        m.set(movie, viewMode)
        return viewMode.on('changed', this._emitViewModeChanged).addListener()
      },
      sort() {
        return [...this._movieToViewMode.values()].map(function(m, i) {
          return {i, m}
        }).sort(function(a, b) {
          if (a.m.value === 'hide' && b.m.value !== 'hide') return 1
          if (a.m.value !== 'hide' && b.m.value === 'hide') return -1
          return a.i - b.i
        }).map(function(o) {
          return o.m
        })
      },
    })
    return MovieViewModes
  })(EventEmitter)

  var ConfigDialog = (function(_super) {
    var isValidStr = function(s) {
      return typeof s === 'string' && Boolean(s.trim().length)
    }
    var isPositiveInt = function(n) {
      return Number.isSafeInteger(n) && n > 0
    }
    var initCheckbox = function(config, doc, name) {
      var b = doc.getElementById(name)
      b.checked = config[name].value
      b.addEventListener('change', function() {
        config[name].value = b.checked
      })
    }
    var optionOf = function(v) {
      return typeof v === 'object'
           ? new Option(v.value + ',' + v.text, v.value)
           : new Option(v, v)
    }
    var diffBy = function(target) {
      var SOMETHING_INPUT_TEXT = '何か入力して下さい。'
      var POSITIVE_INT_INPUT_TEXT = '1以上の整数を入力して下さい。'
      var movieUrlOf = function(movieId) {
        return 'https://www.nicovideo.jp/watch/' + movieId
      }
      return {
        'ng-movie-id': {
          targetText: 'NG動画ID',
          storeName: 'ngMovies',
          convert(v) { return v },
          isValid: isValidStr,
          inputRequestText: SOMETHING_INPUT_TEXT,
          urlOf: movieUrlOf,
        },
        'ng-title': {
          targetText: 'NGタイトル',
          storeName: 'ngTitles',
          convert(v) { return v },
          isValid: isValidStr,
          inputRequestText: SOMETHING_INPUT_TEXT,
          urlOf(title) { return 'https://www.nicovideo.jp/search/' + title },
        },
        'ng-tag': {
          targetText: 'NGタグ',
          storeName: 'ngTags',
          convert(v) { return v },
          isValid: isValidStr,
          inputRequestText: SOMETHING_INPUT_TEXT,
          urlOf(tag) { return 'https://www.nicovideo.jp/tag/' + tag },
        },
        'ng-user-id': {
          targetText: 'NGユーザーID',
          storeName: 'ngUserIds',
          convert: Math.trunc,
          isValid(v) { return isPositiveInt(Math.trunc(v)) },
          inputRequestText: POSITIVE_INT_INPUT_TEXT,
          urlOf(userId) { return 'https://www.nicovideo.jp/user/' + userId },
        },
        'ng-user-name': {
          targetText: 'NGユーザー名',
          storeName: 'ngUserNames',
          convert(v) { return v },
          isValid: isValidStr,
          inputRequestText: SOMETHING_INPUT_TEXT,
          urlOf(userName) { return 'https://www.nicovideo.jp/search/' + userName },
        },
        'ng-channel-id': {
          targetText: 'NGチャンネルID',
          storeName: 'ngChannelIds',
          convert: Math.trunc,
          isValid(v) { return isPositiveInt(Math.trunc(v)) },
          inputRequestText: POSITIVE_INT_INPUT_TEXT,
          urlOf(channelId) { return 'https://ch.nicovideo.jp/ch' + channelId },
        },
        'visited-movie-id': {
          targetText: '閲覧済み動画ID',
          storeName: 'visitedMovies',
          convert(v) { return v },
          isValid: isValidStr,
          inputRequestText: SOMETHING_INPUT_TEXT,
          urlOf: movieUrlOf,
        },
      }[target]
    }
    var promptFor = async function(target, config, defaultValue) {
      var d = diffBy(target)
      var r = ''
      do {
        var msg = r ? `"${r}"は登録済みです。\n` : ''
        r = window.prompt(msg + d.targetText, r || defaultValue || '')
        if (r === null) return ''
        while (!d.isValid(r)) {
          r = window.prompt(d.inputRequestText + '\n' + d.targetText)
          if (r === null) return ''
        }
      } while (!(await config[d.storeName].addAsync(d.convert(r))))
      return r
    }

    var ConfigDialog = function(config, doc, openInTab) {
      _super.call(this)
      this.config = config
      this.doc = doc
      this.openInTab = openInTab
      for (var v of config.ngTitles.array) {
        this._e('list').add(new Option(v, v))
      }
      this._e('removeAllButton').disabled = !config.ngTitles.array.length
      initCheckbox(config, doc, 'openNewWindow')
      initCheckbox(config, doc, 'useGetThumbInfo')
      initCheckbox(config, doc, 'movieInfoTogglable')
      initCheckbox(config, doc, 'descriptionTogglable')
      this._on('target', 'change', this._targetChanged.bind(this))
      this._on('addButton', 'click', this._addButtonClicked.bind(this))
      this._on('removeButton', 'click', this._removeButtonClicked.bind(this))
      this._on('removeAllButton', 'click', this._removeAllButtonClicked.bind(this))
      this._on('openButton', 'click', this._openButtonClicked.bind(this))
      this._on('closeButton', 'click', this.emit.bind(this, 'closed'))
      this._on('exportVisibleCheckbox', 'change', this._exportVisibleCheckboxChanged.bind(this))
      this._on('importVisibleCheckbox', 'change', this._importVisibleCheckboxChanged.bind(this))
      this._on('exportButton', 'click', this._exportButtonClicked.bind(this))
      this._on('importButton', 'click', this._importButtonClicked.bind(this))
      var updateButtonsDisabled = this._updateButtonsDisabled.bind(this)
      this._on('target', 'change', updateButtonsDisabled)
      this._on('list', 'change', updateButtonsDisabled)
      this._on('addButton', 'click', updateButtonsDisabled)
      this._on('removeButton', 'click', updateButtonsDisabled)
      this._on('removeAllButton', 'click', updateButtonsDisabled)
    }
    ConfigDialog.prototype = createObject(_super.prototype, {
      _e(id) { return this.doc.getElementById(id) },
      _on(id, eventName, listener) {
        this._e(id).addEventListener(eventName, listener)
      },
      _diffBySelectedTarget() {
        return diffBy(this._e('target').value)
      },
      _updateList() {
        for (var o of Array.from(this._e('list').options)) o.remove()
        var d = this._diffBySelectedTarget()
        for (var val of this.config[d.storeName].arrayWithText) {
          this._e('list').add(optionOf(val))
        }
      },
      _targetChanged() {
        this._updateList()
      },
      _updateButtonsDisabled() {
        var l = this._e('list')
        var d = l.selectedIndex === -1
        this._e('removeButton').disabled = d
        this._e('openButton').disabled = d
        this._e('removeAllButton').disabled = !l.length
      },
      async _addButtonClicked() {
        var r = await promptFor(this._e('target').value, this.config)
        if (r) this._e('list').add(new Option(r, r))
      },
      async _removeButtonClicked() {
        var opts = Array.from(this._e('list').selectedOptions)
        var d = this._diffBySelectedTarget()
        await this.config[d.storeName]
          .removeAsync(opts.map(function(o) { return d.convert(o.value) }))
        for (var o of opts) o.remove()
      },
      _removeAllButtonClicked() {
        var d = this._diffBySelectedTarget()
        if (!window.confirm(`すべての"${d.targetText}"を削除しますか?`)) return
        this.config[d.storeName].clear()
        for (var o of Array.from(this._e('list').options)) o.remove()
      },
      _openButtonClicked() {
        var opts = Array.from(this._e('list').selectedOptions)
        var d = this._diffBySelectedTarget()
        for (var v of opts.map(function(o) { return o.value })) {
          this.openInTab(d.urlOf(v))
        }
      },
      _exportVisibleCheckboxChanged() {
        var n = this._e('exportVisibleCheckbox').checked ? 'remove' : 'add'
        this._e('exportContainer').classList[n]('isHidden')
      },
      _importVisibleCheckboxChanged() {
        var n = this._e('importVisibleCheckbox').checked ? 'remove' : 'add'
        this._e('importContainer').classList[n]('isHidden')
      },
      async _exportButtonClicked() {
        var textarea = this._e('exportTextarea')
        textarea.value = await this.config.toCSV({
          ngMovieId: this._e('exportNgMovieIdCheckbox').checked,
          ngTitle: this._e('exportNgTitleCheckbox').checked,
          ngTag: this._e('exportNgTagCheckbox').checked,
          ngUserId: this._e('exportNgUserIdCheckbox').checked,
          ngUserName: this._e('exportNgUserNameCheckbox').checked,
          ngChannelId: this._e('exportNgChannelIdCheckbox').checked,
          visitedMovieId: this._e('exportVisitedMovieIdCheckbox').checked,
        })
        textarea.focus()
        textarea.select()
      },
      async _importButtonClicked() {
        await this.config.addFromCSV(this._e('importTextarea').value)
        this._updateList()
        this._e('importTextarea').value = ''
      },
    })
    ConfigDialog.promptNgTitle = function(config, defaultValue) {
      promptFor('ng-title', config, defaultValue)
    }
    ConfigDialog.promptNgUserName = function(config, defaultValue) {
      promptFor('ng-user-name', config, defaultValue)
    }
    ConfigDialog.SRCDOC = `<!doctype html>
<html><head><style>
  html {
    margin: 0 auto;
    max-width: 30em;
    height: 100%;
    line-height: 1.5em;
  }
  body {
    height: 100%;
    margin: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .dialog {
    overflow: auto;
    padding: 8px;
    background-color: white;
  }
  p {
    margin: 0;
  }
  .listButtonsWrap {
    display: flex;
  }
  .listButtonsWrap .list {
    flex: auto;
  }
  .listButtonsWrap .list select {
    width: 100%;
  }
  .listButtonsWrap .buttons {
    flex: none;
    display: flex;
    flex-direction: column;
  }
  .listButtonsWrap .buttons input {
    margin-bottom: 5px;
  }
  .sideComment {
    margin-left: 2em;
  }
  .dialogBottom {
    text-align: center;
  }
  .scriptInfo {
    text-align: right;
  }
  .isHidden {
    display: none;
  }
  textarea {
    width: 100%;
  }
</style></head><body>
  <div class=dialog>
    <p><select id=target>
      <option value=ng-movie-id>NG動画ID</option>
      <option value=ng-title selected>NGタイトル</option>
      <option value=ng-tag>NGタグ</option>
      <option value=ng-user-id>NGユーザーID</option>
      <option value=ng-user-name>NGユーザー名</option>
      <option value=ng-channel-id>NGチャンネルID</option>
      <option value=visited-movie-id>閲覧済み動画ID</option>
    </select></p>
    <div class=listButtonsWrap>
      <p class=list><select multiple size=10 id=list></select></p>
      <p class=buttons>
        <input type=button value=追加 id=addButton>
        <input type=button value=削除 disabled id=removeButton>
        <input type=button value=全削除 disabled id=removeAllButton>
        <input type=button value=開く disabled id=openButton>
      </p>
    </div>
    <p><label><input type=checkbox id=openNewWindow>動画を別窓で開く</label></p>
    <p><label><input type=checkbox id=useGetThumbInfo>動画情報を取得する</label></p>
    <fieldset id=togglable>
      <legend>表示・非表示の切り替えボタン</legend>
      <p><label><input type=checkbox id=movieInfoTogglable>タグ、ユーザー、チャンネル</label></p>
      <p><label><input type=checkbox id=descriptionTogglable>動画説明</label></p>
    </fieldset>
    <p>エクスポート<small><label><input id=exportVisibleCheckbox type=checkbox>表示</label></small></p>
    <div id=exportContainer class=isHidden>
      <p><label><input id=exportNgMovieIdCheckbox type=checkbox checked>NG動画ID</label></p>
      <p><label><input id=exportNgTitleCheckbox type=checkbox checked>NGタイトル</label></p>
      <p><label><input id=exportNgTagCheckbox type=checkbox checked>NGタグ</label></p>
      <p><label><input id=exportNgUserIdCheckbox type=checkbox checked>NGユーザーID</label></p>
      <p><label><input id=exportNgUserNameCheckbox type=checkbox checked>NGユーザー名</label></p>
      <p><label><input id=exportNgChannelIdCheckbox type=checkbox checked>NGチャンネルID</label></p>
      <p><label><input id=exportVisitedMovieIdCheckbox type=checkbox checked>閲覧済み動画ID</label></p>
      <p><input id=exportButton type=button value=エクスポート></p>
      <p><textarea id=exportTextarea rows=3></textarea></p>
    </div>
    <p>インポート<small><label><input id=importVisibleCheckbox type=checkbox>表示</label></small></p>
    <div id=importContainer class=isHidden>
      <p><textarea id=importTextarea rows=3></textarea></p>
      <p><input id=importButton type=button value=インポート></p>
    </div>
    <p class=dialogBottom><input type=button value=閉じる id=closeButton></p>
    <p class=scriptInfo><small><a href=https://greasyfork.org/ja/scripts/880-nico-nico-ranking-ng target=_blank>Nico Nico Ranking NG</a></small></p>
  </div>
</body></html>`
    return ConfigDialog
  })(EventEmitter)

  var NicoPage = (function() {
    var TOGGLE_OPEN_TEXT = '▼'
    var TOGGLE_CLOSE_TEXT = '▲'
    var emphasizeMatchedText = function(e, text, createMatchedElem) {
      var t = e.textContent
      if (!text) {
        e.textContent = t
        return
      }
      var i = t.toUpperCase().indexOf(text)
      if (i === -1) {
        e.textContent = t
        return
      }
      while (e.hasChildNodes()) e.removeChild(e.firstChild)
      var d = e.ownerDocument
      if (i !== 0) e.appendChild(d.createTextNode(t.slice(0, i)))
      e.appendChild(createMatchedElem(t.slice(i, i + text.length)))
      if (i + text.length !== t.length) {
        e.appendChild(d.createTextNode(t.slice(i + text.length)))
      }
    }

    var MovieTitle = (function() {
      var MovieTitle = function(elem) {
        this.elem = elem
        this._ngTitle = ''
        this._listeners = new Listeners({
          ngIdChanged: set(this, 'ngId'),
          ngTitleChanged: set(this, 'ngTitle'),
        })
      }
      MovieTitle.prototype = {
        get ngId() {
          return this.elem.classList.contains('nrn-ng-movie-title')
        },
        set ngId(ngId) {
          var n = ngId ? 'add' : 'remove'
          this.elem.classList[n]('nrn-ng-movie-title')
        },
        _createNgTitleElem(textContent) {
          var result = this.elem.ownerDocument.createElement('span')
          result.className = 'nrn-matched-ng-title'
          result.textContent = textContent
          return result
        },
        get ngTitle() { return this._ngTitle },
        set ngTitle(ngTitle) {
          this._ngTitle = ngTitle
          emphasizeMatchedText(this.elem, ngTitle, this._createNgTitleElem.bind(this))
        },
        bindToMovie(movie) {
          this.ngId = movie.ngId
          this.ngTitle = movie.ngTitle
          this._listeners.bind(movie)
          return this
        },
        unbind() {
          this._listeners.unbind()
        },
      }
      return MovieTitle
    })()

    var ActionPane = (function() {
      var createVisitButton = function(doc, movie) {
        var result = doc.createElement('span')
        result.className = 'nrn-visit-button'
        result.textContent = '閲覧済み'
        result.dataset.movieId = movie.id
        result.dataset.type = 'add'
        result.dataset.movieTitle = movie.title
        return result
      }
      var createMovieNgButton = function(doc, movie) {
        var result = doc.createElement('span')
        result.className = 'nrn-movie-ng-button'
        result.textContent = 'NG動画'
        result.dataset.movieId = movie.id
        result.dataset.type = 'add'
        result.dataset.movieTitle = movie.title
        return result
      }
      var createTitleNgButton = function(doc, movie) {
        var result = doc.createElement('span')
        result.className = 'nrn-title-ng-button'
        result.textContent = 'NGタイトル追加'
        result.dataset.movieTitle = movie.title
        result.dataset.ngTitle = ''
        return result
      }
      var createPane = function(doc) {
        var result = doc.createElement('div')
        result.className = 'nrn-action-pane'
        for (var c of Array.from(arguments).slice(1)) result.appendChild(c)
        return result
      }
      var ActionPane = function(doc, movie) {
        this.elem = createPane(doc
                             , createVisitButton(doc, movie)
                             , createMovieNgButton(doc, movie)
                             , createTitleNgButton(doc, movie))
        this._listeners = new Listeners({
          ngIdChanged: set(this, 'ngId'),
          ngTitleChanged: set(this, 'ngTitle'),
          visitedChanged: set(this, 'visited'),
        })
      }
      ActionPane.prototype = {
        get _visitButton() {
          return this.elem.querySelector('.nrn-visit-button')
        },
        get visited() {
          return this._visitButton.dataset.type === 'remove'
        },
        set visited(visited) {
          var b = this._visitButton
          b.textContent = visited ? '未閲覧' : '閲覧済み'
          b.dataset.type = visited ? 'remove' : 'add'
        },
        get _movieNgButton() {
          return this.elem.querySelector('.nrn-movie-ng-button')
        },
        get ngId() {
          return this._movieNgButton.dataset.type === 'remove'
        },
        set ngId(ngId) {
          var b = this._movieNgButton
          b.textContent = ngId ? 'NG解除' : 'NG登録'
          b.dataset.type = ngId ? 'remove' : 'add'
        },
        get _titleNgButton() {
          return this.elem.querySelector('.nrn-title-ng-button')
        },
        get ngTitle() {
          return this._titleNgButton.dataset.ngTitle
        },
        set ngTitle(ngTitle) {
          var b = this._titleNgButton
          b.textContent = ngTitle ? 'NGタイトル削除' : 'NGタイトル追加'
          b.dataset.type = ngTitle ? 'remove' : 'add'
          b.dataset.ngTitle = ngTitle
        },
        bindToMovie(movie) {
          this.ngId = movie.ngId
          this.ngTitle = movie.ngTitle
          this.visited = movie.visited
          this._listeners.bind(movie)
          return this
        },
        unbind() {
          this._listeners.unbind()
        },
      }
      return ActionPane
    })()

    var TagView = (function() {
      var createElem = function(doc, tagName) {
        var a = doc.createElement('a')
        a.className = 'nrn-movie-tag-link'
        a.target = '_blank'
        a.textContent = tagName
        a.href = 'https://www.nicovideo.jp/tag/' + tagName
        var b = doc.createElement('span')
        b.className = 'nrn-tag-ng-button'
        b.textContent = '[+]'
        b.dataset.type = 'add'
        b.dataset.tagName = tagName
        var result = doc.createElement('span')
        result.className = 'nrn-movie-tag'
        result.appendChild(a)
        result.appendChild(b)
        return result
      }
      var TagView = function(doc, tagName) {
        this.tagName = tagName
        this.elem = createElem(doc, tagName)
        this._listeners = new Listeners({ngChanged: set(this, 'ng')})
      }
      TagView.prototype = {
        get _link() {
          return this.elem.querySelector('.nrn-movie-tag-link')
        },
        get ng() {
          return this._link.classList.contains('nrn-movie-ng-tag-link')
        },
        set ng(ng) {
          this._link.classList[ng ? 'add' : 'remove']('nrn-movie-ng-tag-link')
          var b = this.elem.querySelector('.nrn-tag-ng-button')
          b.textContent = ng ? '[x]' : '[+]'
          b.dataset.type = ng ? 'remove' : 'add'
        },
        bindToTag(tag) {
          this.ng = tag.ng
          this._listeners.bind(tag)
          return this
        },
        unbind() {
          this._listeners.unbind()
        },
      }
      return TagView
    })()

    var ContributorView = (function() {
      var ContributorView = function(doc, contributor) {
        this.contributor = contributor
        this.elem = this._createElem(doc)
      }
      ContributorView.prototype = {
        _createElem(doc) {
          var a = doc.createElement('a')
          a.className = 'nrn-contributor-link'
          a.target = '_blank'
          a.href = this.contributor.url
          a.textContent = this.contributor.name
          var b = doc.createElement('span')
          this._setNgButton(b)
          var result = doc.createElement('span')
          result.className = 'nrn-contributor'
          result.appendChild(doc.createTextNode(this._label))
          result.appendChild(a)
          result.appendChild(b)
          return result
        },
        _initContributorDataset(dataset) {
          dataset.contributorType = this.contributor.type
          dataset.id = this.contributor.id
          dataset.name = this.contributor.name
          dataset.type = 'add'
        },
        get _label() {
          throw new Error('must be implemented')
        },
        _setNgButton() {
          throw new Error('must be implemented')
        },
        _bindToContributor() {
          throw new Error('must be implemented')
        },
      }

      var UserView = function UserView(doc, contributor) {
        ContributorView.call(this, doc, contributor)
        this._listeners = new Listeners({
          ngIdChanged: set(this, 'ngId'),
          ngNameChanged: set(this, 'ngName'),
        })
        this._bindToContributor()
      }
      UserView.prototype = createObject(ContributorView.prototype, {
        get _label() {
          return 'ユーザー: '
        },
        _setNgButton(b) {
          var d = b.ownerDocument
          var ngIdButton = d.createElement('span')
          ngIdButton.className = 'nrn-contributor-ng-id-button'
          ngIdButton.textContent = '+ID'
          this._initContributorDataset(ngIdButton.dataset)
          var ngNameButton = d.createElement('span')
          ngNameButton.className = 'nrn-contributor-ng-name-button'
          ngNameButton.textContent = '+名'
          this._initContributorDataset(ngNameButton.dataset)
          b.className = 'nrn-user-ng-button'
          b.appendChild(d.createTextNode('['))
          b.appendChild(ngIdButton)
          b.appendChild(d.createTextNode('/'))
          b.appendChild(ngNameButton)
          b.appendChild(d.createTextNode(']'))
        },
        get ngId() {
          return this.elem.querySelector('.nrn-contributor-link')
            .classList.contains('nrn-ng-id-contributor-link')
        },
        set ngId(ngId) {
          var a = this.elem.querySelector('.nrn-contributor-link')
          a.classList[ngId ? 'add' : 'remove']('nrn-ng-id-contributor-link')
          var b = this.elem.querySelector('.nrn-contributor-ng-id-button')
          b.textContent = ngId ? 'xID' : '+ID'
          b.dataset.type = ngId ? 'remove' : 'add'
        },
        get ngName() {
          var e = this.elem.querySelector('.nrn-matched-ng-contributor-name')
          return e ? e.textContent : ''
        },
        set ngName(ngName) {
          var b = this.elem.querySelector('.nrn-contributor-ng-name-button')
          b.textContent = ngName ? 'x名' : '+名'
          b.dataset.type = ngName ? 'remove' : 'add'
          b.dataset.matched = ngName
          emphasizeMatchedText(
            this.elem.querySelector('.nrn-contributor-link'),
            ngName,
            function(text) {
              var result = this.elem.ownerDocument.createElement('span')
              result.className = 'nrn-matched-ng-contributor-name'
              result.textContent = text
              return result
            }.bind(this))
        },
        _bindToContributor() {
          this.ngId = this.contributor.ngId
          this.ngName = this.contributor.ngName
          this._listeners.bind(this.contributor)
          return this
        },
        unbind() {
          this._listeners.unbind()
        },
      })

      var ChannelView = function ChannelView(doc, contributor) {
        ContributorView.call(this, doc, contributor)
        this._listeners = new Listeners({ngChanged: set(this, 'ng')})
        this._bindToContributor()
      }
      ChannelView.prototype = createObject(ContributorView.prototype, {
        get _label() {
          return 'チャンネル: '
        },
        _setNgButton(e) {
          e.className = 'nrn-contributor-ng-button'
          e.textContent = '[+]'
          this._initContributorDataset(e.dataset)
        },
        get ng() {
          return this.elem.querySelector('.nrn-contributor-link')
            .classList.contains('nrn-ng-contributor-link')
        },
        set ng(ng) {
          var a = this.elem.querySelector('.nrn-contributor-link')
          a.classList[ng ? 'add' : 'remove']('nrn-ng-contributor-link')
          var b = this.elem.querySelector('.nrn-contributor-ng-button')
          b.textContent = ng ? '[x]' : '[+]'
          b.dataset.type = ng ? 'remove' : 'add'
        },
        _bindToContributor() {
          this.ng = this.contributor.ng
          this._listeners.bind(this.contributor)
          return this
        },
        unbind() {
          this._listeners.unbind()
        },
      })

      ContributorView.new = function(doc, contributor) {
        switch (contributor.type) {
          case 'user': return new UserView(doc, contributor)
          case 'channel': return new ChannelView(doc, contributor)
          default: throw new Error(contributor.type)
        }
      }
      return ContributorView
    })()

    var MovieInfo = (function() {
      var createElem = function(doc) {
        var e = doc.createElement('P')
        e.className = 'nrn-error'
        var t = doc.createElement('p')
        t.className = 'nrn-tag-container'
        var c = doc.createElement('p')
        c.className = 'nrn-contributor-container'
        var result = doc.createElement('div')
        result.className = 'nrn-movie-info-container'
        result.appendChild(e)
        result.appendChild(t)
        result.appendChild(c)
        return result
      }
      var createToggle = function(doc) {
        var result = doc.createElement('span')
        result.className = 'nrn-movie-info-toggle'
        result.textContent = TOGGLE_OPEN_TEXT
        return result
      }
      var MovieInfo = function(doc) {
        this.elem = createElem(doc)
        this.toggle = createToggle(doc)
        this.togglable = true
        this._tagViews = []
        this._contributorView = null
        this._error = Movie.NO_ERROR
        this._actionPane = null
        this._listeners = new Listeners({
          tagsChanged: this._createAndSetTagViews.bind(this),
          contributorChanged: this._createAndSetContributorView.bind(this),
          errorChanged: set(this, 'error'),
        })
      }
      MovieInfo.prototype = {
        set actionPane(actionPane) {
          this._actionPane = actionPane
          this.elem.insertBefore(actionPane.elem, this.elem.firstChild)
        },
        get tagViews() { return this._tagViews },
        set tagViews(tagViews) {
          this._tagViews = tagViews
          var e = this.elem.querySelector('.nrn-tag-container')
          for (var v of tagViews) e.appendChild(v.elem)
        },
        get contributorView() { return this._contributorView },
        set contributorView(contributorView) {
          this._contributorView = contributorView
          this.elem.querySelector('.nrn-contributor-container')
            .appendChild(contributorView.elem)
        },
        get error() { return this._error },
        set error(error) {
          if (this._error === error) return
          this._error = error
          this.elem.querySelector('.nrn-error').textContent = error.message
        },
        hasAny() {
          return Boolean(this.elem.querySelector('.nrn-action-pane')
                      || this.elem.querySelector('.nrn-movie-tag')
                      || this.elem.querySelector('.nrn-contributor')
                      || this.error !== Movie.NO_ERROR)
        },
        _createAndSetTagViews(tags) {
          var d = this.elem.ownerDocument
          this.tagViews = tags.map(function(tag) {
            return new TagView(d, tag.name).bindToTag(tag)
          })
        },
        _createAndSetContributorView(contributor) {
          if (contributor === Contributor.NULL) return
          var d = this.elem.ownerDocument
          this.contributorView = ContributorView.new(d, contributor)
        },
        bindToMovie(movie) {
          this._createAndSetTagViews(movie.tags)
          this._createAndSetContributorView(movie.contributor)
          this.error = movie.error
          if (!movie.thumbInfoDone) this._listeners.bind(movie)
        },
        unbind() {
          this._listeners.unbind()
          this.tagViews.forEach(function(v) { v.unbind() })
          if (this.contributorView) this.contributorView.unbind()
          if (this._actionPane) this._actionPane.unbind()
        },
      }
      return MovieInfo
    })()

    var Description = (function() {
      var re = /(sm|so|nm|co|ar|im|lv|mylist\/|watch\/|user\/)(?:\d+)/g
      var typeToHRef = {
        sm: 'https://www.nicovideo.jp/watch/',
        so: 'https://www.nicovideo.jp/watch/',
        nm: 'https://www.nicovideo.jp/watch/',
        co: 'https://com.nicovideo.jp/community/',
        ar: 'https://ch.nicovideo.jp/article/',
        im: 'https://seiga.nicovideo.jp/seiga/',
        lv: 'http://live.nicovideo.jp/watch/',
        'mylist/': 'https://www.nicovideo.jp/',
        'watch/': 'https://www.nicovideo.jp/',
        'user/': 'https://www.nicovideo.jp/',
      }
      var createAnchor = function(doc, href, text) {
        var a = doc.createElement('a')
        a.target = '_blank'
        a.href = href
        a.textContent = text
        return a
      }
      var createCloseButton = function(doc) {
        var result = doc.createElement('span')
        result.className = 'nrn-description-close-button'
        result.textContent = TOGGLE_CLOSE_TEXT
        return result
      }
      var createElem = function(doc, closeButton) {
        var text = doc.createElement('span')
        text.className = 'nrn-description-text'
        var result = doc.createElement('p')
        result.className = 'itemDescription ranking nrn-description'
        result.appendChild(text)
        result.appendChild(closeButton)
        return result
      }
      var createOpenButton = function(doc) {
        var result = doc.createElement('span')
        result.className = 'nrn-description-open-button'
        result.textContent = TOGGLE_OPEN_TEXT
        return result
      }
      var Description = function(doc) {
        this.closeButton = createCloseButton(doc)
        this.elem = createElem(doc, this.closeButton)
        this.openButton = createOpenButton(doc)
        this.original = null
        this.text = ''
        this.linkified = false
        this.togglable = true
        this._listeners = new Listeners({
          'descriptionChanged': set(this, 'text'),
        })
      }
      Description.prototype = {
        linkify() {
          if (this.linkified) return
          this.linkified = true
          var t = this.text
          var d = this.elem.ownerDocument
          var f = d.createDocumentFragment()
          var lastIndex = 0
          for (var r; r = re.exec(t);) {
            f.appendChild(d.createTextNode(t.slice(lastIndex, r.index)))
            f.appendChild(createAnchor(d, typeToHRef[r[1]] + r[0], r[0]))
            lastIndex = re.lastIndex
          }
          f.appendChild(d.createTextNode(t.slice(lastIndex)))
          f.normalize()
          this.elem.firstChild.appendChild(f)
        },
        bindToMovie(movie) {
          this.text = movie.description
          this._listeners.bind(movie)
        },
        unbind() {
          this._listeners.unbind()
        },
      }
      return Description
    })()

    var MovieRoot = (function() {
      var MovieRoot = function(elem) {
        this.elem = elem
        var d = elem.ownerDocument
        this.movieInfo = new MovieInfo(d)
        this.description = new Description(d)
        this._openNewWindow = false
        this.movieTitle = null
        this._movieListeners = new Listeners({
          thumbInfoDone: this.setThumbInfoDone.bind(this),
        })
        this._movieViewModeListeners = new Listeners({
          changed: set(this, 'viewMode'),
        })
        this._configOpenNewWindowListeners = new Listeners({
          changed: set(this, 'openNewWindow'),
        })
      }
      MovieRoot.prototype = {
        markMovieAnchor() {
          for (var a of this._movieAnchors) a.dataset.nrnMovieAnchor = 'true'
        },
        set id(id) {
          for (var a of this._movieAnchors) a.dataset.nrnMovieId = id
        },
        get titleElem() {
          throw new Error('must be implemented')
        },
        set title(title) {
          this.titleElem.textContent = title
          for (var a of this._movieAnchors) a.dataset.nrnMovieTitle = title
        },
        get _reduced() {
          return this.elem.classList.contains('nrn-reduce')
        },
        _halfThumb() {},
        _restoreThumb() {},
        _reduce() {
          this.elem.classList.add('nrn-reduce')
          this._halfThumb()
        },
        _unreduce() {
          this.elem.classList.remove('nrn-reduce')
          this._restoreThumb()
        },
        get _hidden() {
          return this.elem.classList.contains('nrn-hide')
        },
        _hide() {
          this.elem.classList.add('nrn-hide')
        },
        _show() {
          this.elem.classList.remove('nrn-hide')
        },
        get viewMode() {
          if (this.elem.classList.contains('nrn-reduce')) return 'reduce'
          if (this.elem.classList.contains('nrn-hide')) return 'hide'
          return 'doNothing'
        },
        set viewMode(viewMode) {
          if (this._reduced) this._unreduce()
          else if (this._hidden) this._show()
          switch (viewMode) {
            case 'reduce': this._reduce(); break
            case 'hide': this._hide(); break
            case 'doNothing': break
            default: throw new Error(viewMode)
          }
        },
        get _movieAnchorSelectors() {
          throw new Error('must be implemented')
        },
        get _movieAnchors() {
          var result = []
          for (var s of this._movieAnchorSelectors) {
            var a = this.elem.querySelector(s)
            if (a) result.push(a)
          }
          return result
        },
        get openNewWindow() { return this._openNewWindow },
        set openNewWindow(openNewWindow) {
          this._openNewWindow = openNewWindow
          var t = openNewWindow ? '_blank' : ''
          for (var a of this._movieAnchors) a.target = t
        },
        get _movieInfoVisible() {
          return Boolean(this.movieInfo.elem.parentNode)
        },
        set _movieInfoVisible(visible) {
          if (visible) {
            this._addMovieInfo()
            this.movieInfo.toggle.textContent = TOGGLE_CLOSE_TEXT
          } else {
            this.movieInfo.elem.remove()
            this.movieInfo.toggle.textContent = TOGGLE_OPEN_TEXT
          }
        },
        toggleMovieInfo() {
          this._movieInfoVisible = !this._movieInfoVisible
        },
        set actionPane(actionPane) {
          this.movieInfo.actionPane = actionPane
        },
        _addMovieInfo() {
          throw new Error('must be implemented')
        },
        _addMovieInfoToggle() {
          this.elem.querySelector('.itemData')
            .appendChild(this.movieInfo.toggle)
        },
        setMovieInfoToggleIfRequired() {},
        _updateByMovieInfoTogglable() {
          if (!this.movieInfo.hasAny()) return
          if (this.movieInfo.togglable) {
            this._addMovieInfoToggle()
          } else {
            this.movieInfo.toggle.remove()
          }
          this._movieInfoVisible = !this.movieInfo.togglable
        },
        get movieInfoTogglable() {
          return this.movieInfo.togglable
        },
        set movieInfoTogglable(movieInfoTogglable) {
          this.movieInfo.togglable = movieInfoTogglable
          this._updateByMovieInfoTogglable()
        },
        _queryOriginalDescriptionElem() {
          return this.elem.querySelector('.itemDescription')
        },
        get _originalDescriptionElem() {
          var result = this.description.original
          if (!result) {
            result
              = this.description.original
              = this._queryOriginalDescriptionElem()
          }
          return result
        },
        get _descriptionExpanded() {
          return Boolean(this.description.elem.parentNode)
        },
        set _descriptionExpanded(expanded) {
          var o = this._originalDescriptionElem
          var d = this.description
          if (expanded && o.parentNode) {
            d.linkify()
            o.parentNode.replaceChild(d.elem, o)
          } else if (!expanded && d.elem.parentNode) {
            d.elem.parentNode.replaceChild(o, d.elem)
          }
        },
        _updateByDescriptionTogglable() {
          if (!this.description.text) return
          if (this.description.togglable) {
            this._originalDescriptionElem.appendChild(this.description.openButton)
            this.description.elem.appendChild(this.description.closeButton)
          } else {
            this.description.closeButton.remove()
          }
          this._descriptionExpanded = !this.description.togglable
        },
        toggleDescription() {
          this._descriptionExpanded = !this._descriptionExpanded
        },
        get descriptionTogglable() {
          return this.description.togglable
        },
        set descriptionTogglable(descriptionTogglable) {
          this.description.togglable = descriptionTogglable
          this._updateByDescriptionTogglable()
        },
        setThumbInfoDone() {
          this.elem.classList.add('nrn-thumb-info-done')
        },
        get thumbInfoDone() {
          return this.elem.classList.contains('nrn-thumb-info-done')
        },
        bindToMovie(movie) {
          this.movieInfo.bindToMovie(movie)
          this.description.bindToMovie(movie)
          if (movie.thumbInfoDone) this.setThumbInfoDone()
          else this._movieListeners.bind(movie)
        },
        bindToMovieViewMode(movieViewMode) {
          this.viewMode = movieViewMode.value
          this._movieViewModeListeners.bind(movieViewMode)
        },
        bindToConfig(config) {
          this.openNewWindow = config.openNewWindow.value
          this._configOpenNewWindowListeners.bind(config.openNewWindow)
        },
        unbind() {
          this.movieInfo.unbind()
          this.description.unbind()
          this._movieListeners.unbind()
          this._movieViewModeListeners.unbind()
          this._configOpenNewWindowListeners.unbind()
          if (this.movieTitle) this.movieTitle.unbind()
        },
      }
      return MovieRoot
    })()

    var ConfigBar = (function() {
      var createConfigBar = function(doc) {
        var html = `<div id=nrn-config-bar>
    <label>
      閲覧済みの動画を
      <select id=nrn-visited-movie-view-mode-select>
        <option value=reduce>縮小</option>
        <option value=hide>非表示</option>
        <option value=doNothing>通常表示</option>
      </select>
    </label>
    |
    <label>
      投稿者
      <select id=nrn-visible-contributor-type-select>
        <option value=all>全部</option>
        <option value=user>ユーザー</option>
        <option value=channel>チャンネル</option>
      </select>
    </label>
    |
    <label><input type=checkbox id=nrn-ng-movie-visible-checkbox> NG動画を表示</label>
    |
    <span id=nrn-config-button>設定</span>
  </div>`
        var e = doc.createElement('div')
        e.innerHTML = html
        var result = e.firstChild
        result.remove()
        return result
      }
      var ConfigBar = function(doc) {
        this.elem = createConfigBar(doc)
      }
      ConfigBar.prototype = {
        get _viewModeSelect() {
          return this.elem.querySelector('#nrn-visited-movie-view-mode-select')
        },
        get visitedMovieViewMode() {
          return this._viewModeSelect.value
        },
        set visitedMovieViewMode(viewMode) {
          this._viewModeSelect.value = viewMode
        },
        get _visibleContributorTypeSelect() {
          return this.elem.querySelector('#nrn-visible-contributor-type-select')
        },
        get visibleContributorType() {
          return this._visibleContributorTypeSelect.value
        },
        set visibleContributorType(type) {
          this._visibleContributorTypeSelect.value = type
        },
        bindToConfig(config) {
          this.visitedMovieViewMode = config.visitedMovieViewMode.value
          this.visibleContributorType = config.visibleContributorType.value
          config.visitedMovieViewMode.on('changed', set(this, 'visitedMovieViewMode'))
          config.visibleContributorType.on('changed', set(this, 'visibleContributorType'))
          return this
        },
      }
      return ConfigBar
    })()

    var NicoPage = function(doc) {
      this.doc = doc
      this._toggleToMovieRoot = new Map()
    }
    NicoPage.prototype = {
      createConfigBar() {
        return new ConfigBar(this.doc)
      },
      createTables() { return [] },
      createMovieRoot() {
        throw new Error('must be implemented')
      },
      get _configBarContainer() {
        throw new Error('must be implemented')
      },
      addConfigBar(bar) {
        var target = this._configBarContainer
        target.insertBefore(bar.elem, target.firstChild)
      },
      parse() {
        throw new Error('must be implemented')
      },
      mapToggleTo(movieRoot) {
        var m = this._toggleToMovieRoot
        m.set(movieRoot.movieInfo.toggle, movieRoot)
        m.set(movieRoot.description.openButton, movieRoot)
        m.set(movieRoot.description.closeButton, movieRoot)
      },
      unmapToggleFrom(movieRoot) {
        var m = this._toggleToMovieRoot
        m.delete(movieRoot.movieInfo.toggle)
        m.delete(movieRoot.description.openButton)
        m.delete(movieRoot.description.closeButton)
      },
      getMovieRootBy(toggle) {
        return this._toggleToMovieRoot.get(toggle)
      },
      _configDialogLoaded() {},
      showConfigDialog(config) {
        var back = this.doc.createElement('div')
        back.style.backgroundColor = 'black'
        back.style.opacity = '0.5'
        back.style.zIndex = '10000'
        back.style.position = 'fixed'
        back.style.top = '0'
        back.style.left = '0'
        back.style.width = '100%'
        back.style.height = '100%'
        this.doc.body.appendChild(back)

        var f = this.doc.createElement('iframe')
        f.style.position = 'fixed'
        f.style.top = '0'
        f.style.left = '0'
        f.style.width = '100%'
        f.style.height = '100%'
        f.style.zIndex = '10001'
        f.srcdoc = ConfigDialog.SRCDOC
        f.addEventListener('load', function loaded() {
          this._configDialogLoaded(f.contentDocument)
          const openInTab = typeof GM_openInTab === 'undefined'
                            ? GM.openInTab : GM_openInTab
          new ConfigDialog(config, f.contentDocument, openInTab)
            .on('closed', function() {
              f.remove()
              back.remove()
            })
        }.bind(this))
        this.doc.body.appendChild(f)
      },
      bindToConfig() {},
      get _pendingMoviesInvisibleCss() {
        throw new Error('must be implemented')
      },
      _createPendingMoviesInvisibleStyle() {
        var result = this.doc.createElement('style')
        result.id = 'nrn-pending-movies-hide-style'
        result.textContent = this._pendingMoviesInvisibleCss
        return result
      },
      set pendingMoviesVisible(v) {
        var id = 'nrn-pending-movies-hide-style'
        if (v) {
          this.doc.getElementById(id).remove()
        } else {
          if (!this.doc.head) {
            this.doc.documentElement.appendChild(this.doc.createElement('head'))
          }
          this.doc.head.appendChild(this._createPendingMoviesInvisibleStyle())
        }
      },
      get css() {
        throw new Error('must be implemented')
      },
      observeMutation() {},
    }
    Object.assign(NicoPage, {
      MovieTitle,
      ActionPane,
      TagView,
      ContributorView,
      MovieInfo,
      Description,
      MovieRoot,
      ConfigBar,
    })
    return NicoPage
  })()

  var ListPage = (function(_super) {

    var MovieRoot = (function(_super) {
      var MovieRoot = function(elem) {
        _super.call(this, elem)
      }
      MovieRoot.prototype = createObject(_super.prototype, {
        get titleElem() {
          return this.elem.querySelector('.NC-MediaObjectTitle.NC-VideoMediaObject-title')
        },
        get _movieAnchorSelectors() {
          return ['.NC-Link.NC-MediaObject-contents']
        },
        set actionPane(actionPane) {
          this.elem.querySelector('.NC-MediaObject-main').appendChild(actionPane.elem)
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setMovieInfoToggleIfRequired() {
          if (!this.movieInfo.toggle.parentNode) {
            this.elem.querySelector('.NC-MediaObject-action').appendChild(this.movieInfo.toggle)
          }
        },
      })
      return MovieRoot
    })(_super.MovieRoot)

    var SubMovieRoot = (function(_super) {
      var SubMovieRoot = function(elem) {
        _super.call(this, elem)
        elem.classList.add('nrn-sub-movie-root')
      }
      SubMovieRoot.prototype = createObject(_super.prototype, {
        get titleElem() {
          return this.elem.querySelector('.RankingSubVideo-title')
        },
        get _movieAnchorSelectors() {
          return ['.RankingSubVideo-title', '.RankingSubVideo-thumbnail']
        },
        set actionPane(actionPane) {
          this.movieInfo.actionPane = actionPane
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setMovieInfoToggleIfRequired() {
          if (!this.movieInfo.toggle.parentNode) {
            this.elem.appendChild(this.movieInfo.toggle)
          }
        },
      })
      return SubMovieRoot
    })(_super.MovieRoot)

    var AdMovieRoot = (function(_super) {
      var AdMovieRoot = function(elem) {
        _super.call(this, elem)
        elem.classList.add('nrn-ad-movie-root')
      }
      AdMovieRoot.prototype = createObject(_super.prototype, {
        get titleElem() {
          return this.elem.querySelector('.NC-MediaObjectTitle.RankingMainNicoad')
        },
        get _movieAnchorSelectors() {
          return ['.NC-Link.NC-MediaObject-contents']
        },
        set actionPane(actionPane) {
          this.elem.querySelector('.NC-MediaObject-main').appendChild(actionPane.elem)
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setMovieInfoToggleIfRequired() {
          if (!this.movieInfo.toggle.parentNode) {
            this.elem.querySelector('.NC-MediaObject-action').appendChild(this.movieInfo.toggle)
          }
        },
      })
      return AdMovieRoot
    })(_super.MovieRoot)

    var parent = function(className, child) {
      for (var e = child; e; e = e.parentNode) {
        if (e.classList.contains(className)) return e
      }
      return null
    }
    var ListPage = function(doc) {
      _super.call(this, doc)
    }
    ListPage.prototype = createObject(_super.prototype, {
      createTables() { return [] },
      createMovieRoot(resultOfParsing) {
        switch (resultOfParsing.type) {
          case 'main': return new MovieRoot(resultOfParsing.rootElem)
          case 'sub': return new SubMovieRoot(resultOfParsing.rootElem)
          case 'ad': return new AdMovieRoot(resultOfParsing.rootElem)
          default: throw new Error(resultOfParsing.type)
        }
      },
      get _configBarContainer() {
        return this.doc.querySelector('.RankingVideoListContainer')
      },
      parse(target) {
        target = target || this.doc
        return this._parseMain(target).concat(this._parseSub(target))
      },
      _parseMain(target) {
        return Array.from(target.querySelectorAll('.NC-MediaObject.NC-VideoMediaObject.RankingMainVideo'))
          .map(function(item) {
            return {
              type: 'main',
              movie: {
                id: item.dataset.videoId,
                title: item.querySelector('.NC-MediaObjectTitle.NC-VideoMediaObject-title').textContent.trim(),
              },
              rootElem: item,
            }
          })
      },
      _parseSub(target) {
        var selector = '.HotTopicVideosContainer > .BaseRankingContentContainer-main > .RankingSubVideo'
          + ', .SpecifiedGenreRankingVideosContainer-subVideos > .RankingSubVideo'
        return Array.from(target.querySelectorAll(selector))
          .map(function(rootElem) {
            return {
              type: 'sub',
              movie: {
                id: rootElem.querySelector('.RankingSubVideo-thumbnail').pathname.slice('/watch/'.length),
                title: rootElem.querySelector('.Thumbnail.RankingSubVideo .Thumbnail-image').getAttribute('alt').trim(),
              },
              rootElem,
            }
          })
      },
      _configDialogLoaded(doc) {
        doc.getElementById('togglable').hidden = true
      },
      observeMutation(callback) {
        const nodeList = document.querySelectorAll('.NC-MediaObject.NC-NicoadMediaObject.RankingMainNicoad')
        for (const node of Array.from(nodeList)) {
          new MutationObserver((records, observer) => {
            const {target} = records[0]
            if (!target.dataset.contentId) return
            const titleElem = target.querySelector('.NC-MediaObjectTitle.RankingMainNicoad')
            if (!titleElem) return
            observer.disconnect()
            const id = target.dataset.contentId
            const title = titleElem.textContent.trim()
            callback([{
              type: 'ad',
              movie: {id, title},
              rootElem: target,
            }], true)
          }).observe(node, {
            attributes: true,
            attributeFilter: ['data-content-id'],
          })
        }
      },
      get _pendingMoviesInvisibleCss() {
        return `.NC-MediaObject.NC-VideoMediaObject.RankingMainVideo,
.MediaObject.RankingSubVideo,
.NC-MediaObject.RankingMainNicoad {
  visibility: hidden;
}
.NC-MediaObject.NC-VideoMediaObject.RankingMainVideo.nrn-thumb-info-done,
.MediaObject.RankingSubVideo.nrn-thumb-info-done,
.NC-MediaObject.RankingMainNicoad.nrn-thumb-info-done,
.NC-MediaObject.RankingMainNicoad[data-blank] {
  visibility: inherit;
}
`
      },
      get css() {
        return `#nrn-config-button,
.nrn-visit-button:hover,
.nrn-movie-ng-button:hover,
.nrn-title-ng-button:hover,
.nrn-tag-ng-button:hover,
.nrn-contributor-ng-button:hover,
.nrn-contributor-ng-id-button:hover,
.nrn-contributor-ng-name-button:hover,
.nrn-movie-info-toggle:hover {
  text-decoration: underline;
  cursor: pointer;
}
.nrn-movie-tag {
  display: inline-block;
  margin-right: 1em;
}
.nrn-movie-tag-link,
.nrn-contributor-link {
  color: #333333;
}
.nrn-movie-tag-link.nrn-movie-ng-tag-link,
.nrn-contributor-link.nrn-ng-contributor-link,
.nrn-matched-ng-contributor-name,
.nrn-matched-ng-title {
  color: white;
  background-color: fuchsia;
}
.nrn-movie-info-container {
  display: none;
  position: absolute;
  top: 115px;
  background-color: lightgray;
  z-index: 1;
  padding-left: 10px;
}
.NC-MediaObject.RankingMainNicoad.nrn-thumb-info-done.nrn-reduce .RankingMainNicoad-meta {
  border-top: 0px;
}
.RankingMainVideo:hover .nrn-movie-info-container,
.RankingMainNicoad:hover .nrn-movie-info-container,
.nrn-movie-info-container:hover {
  display: block;
}
.nrn-movie-info-container .nrn-tag-container,
.nrn-movie-info-container .nrn-contributor-container {
  line-height: 1.5em;
  margin-top: 4px;
}
.nrn-hide,
.RankingMainVideo.nrn-reduce .RankingMainVideo-description,
.RankingMainNicoad.nrn-reduce .RankingMainNicoad-message div[data-nicoad-message],
.BaseRankingContentContainer.SpecifiedGenreRankingVideosContainer .SpecifiedGenreRankingVideosContainer-subVideo.nrn-hide {
  display: none;
}
.RankingMainVideo.nrn-reduce .NC-Thumbnail.NC-VideoThumbnail,
.RankingMainNicoad.nrn-reduce .NC-Thumbnail.NC-NicoadThumbnail {
  height: 83px;
  width: 140px;
}
.nrn-user-ng-button {
  display: inline-block;
}
.NC-MediaObject.RankingMainVideo .NC-VideoMediaObject-title.nrn-ng-movie-title,
.nrn-contributor-link.nrn-ng-id-contributor-link,
.MediaObject.RankingSubVideo .RankingSubVideo-title.nrn-ng-movie-title,
.NC-MediaObject.RankingMainNicoad .NC-MediaObjectTitle.nrn-ng-movie-title {
  text-decoration: line-through;
}
.RankingMainNicoad {
  position: relative;
}
.RankingMainVideo .nrn-action-pane,
.RankingMainNicoad .nrn-action-pane {
  display: none;
  position: absolute;
  top: 0px;
  right: 35px;
  padding: 3px;
  color: #999;
  background-color: rgb(105, 105, 105);
  z-index: 11;
}
.RankingMainVideo:hover .nrn-action-pane,
.RankingMainNicoad:hover .nrn-action-pane {
  display: block;
}
.RankingMainVideo:hover .nrn-action-pane .nrn-visit-button,
.RankingMainVideo:hover .nrn-action-pane .nrn-movie-ng-button,
.RankingMainVideo:hover .nrn-action-pane .nrn-title-ng-button,
.RankingMainNicoad:hover .nrn-action-pane .nrn-visit-button,
.RankingMainNicoad:hover .nrn-action-pane .nrn-movie-ng-button,
.RankingMainNicoad:hover .nrn-action-pane .nrn-title-ng-button {
  color: white;
}
.RankingMainVideo:hover .nrn-action-pane .nrn-movie-ng-button,
.RankingMainVideo:hover .nrn-action-pane .nrn-title-ng-button,
.RankingMainNicoad:hover .nrn-action-pane .nrn-movie-ng-button,
.RankingMainNicoad:hover .nrn-action-pane .nrn-title-ng-button {
  margin-left: 5px;
  border-left: solid thin;
  padding-left: 5px;
}
.NC-MediaObject.RankingMainNicoad.nrn-thumb-info-done .RankingMainNicoad-point {
  right: 2em;
}
.RankingMainVideo .nrn-movie-info-toggle,
.RankingMainNicoad .nrn-movie-info-toggle {
  display: block;
  color: #999;
  text-align: center;
}
.nrn-sub-movie-root {
  position: relative;
}
.nrn-sub-movie-root .nrn-movie-info-toggle {
  display: block;
  text-align: right;
  background-color: white;
}
.nrn-sub-movie-root .nrn-movie-info-container {
  display: block;
  position: static;
  padding-top: 5px;
}
.nrn-sub-movie-root .nrn-action-pane .nrn-visit-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-movie-ng-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-title-ng-button {
  display: inline-block;
  color: #333333;
}
.nrn-sub-movie-root .nrn-action-pane .nrn-visit-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-movie-ng-button {
  margin-right: 0.5em;
}
.SpecifiedGenreRankingVideosContainer-subVideo.nrn-sub-movie-root .nrn-movie-info-toggle {
  position: absolute;
  right: 0px;
  bottom: 0px;
  padding: 1em 0 0 1em;
  background: rgba(255, 255, 255, 0);
}
.SpecifiedGenreRankingVideosContainer-subVideo.nrn-sub-movie-root .nrn-movie-info-container {
  display: none;
  position: absolute;
  top: 60px;
}
.SpecifiedGenreRankingVideosContainer-subVideo.nrn-sub-movie-root:hover .nrn-movie-info-container,
.SpecifiedGenreRankingVideosContainer-subVideo.nrn-sub-movie-root .nrn-movie-info-container:hover {
  display: block;
}
.nrn-error {
  color: red;
}
`
      },
    })
    Object.assign(ListPage, {
      MovieRoot,
      SubMovieRoot,
    })
    return ListPage
  })(NicoPage)

  var SearchPage = (function(_super) {

    var AbstractMovieRoot = (function(_super) {
      var AbstractMovieRoot = function(elem) {
        _super.call(this, elem)
      }
      AbstractMovieRoot.prototype = createObject(_super.prototype, {
        get titleElem() {
          return this.elem.querySelector('.itemTitle a')
        },
        get _movieAnchorSelectors() {
          return ['.itemTitle a', '.itemThumbWrap']
        },
      })
      return AbstractMovieRoot
    })(_super.MovieRoot)

    var FixedThumbMovieRoot = (function(_super) {
      var FixedThumbMovieRoot = function(elem) {
        _super.call(this, elem)
      }
      FixedThumbMovieRoot.prototype = createObject(_super.prototype, {
        _getThumbElement() {
          const e = this.elem.querySelector('.thumb')
          return e ? e : this.elem.querySelector('.backgroundThumbnail')
        },
        _halfThumb() {
          var e = this._getThumbElement()
          if (!e) return
          var s = e.style
          if (!s.marginTop) return
          s.marginTop = '-9px'
          s.width = '80px'
          s.height = '63px'
        },
        _restoreThumb() {
          var e = this._getThumbElement()
          if (!e) return
          var s = e.style
          if (!s.marginTop) return
          s.marginTop = '-15px'
          s.width = '160px'
          s.height = ''
        },
      })
      return FixedThumbMovieRoot
    })(AbstractMovieRoot)

    var TwoColumnMovieRoot = (function(_super) {
      var TwoColumnMovieRoot = function(elem) {
        _super.call(this, elem)
      }
      TwoColumnMovieRoot.prototype = createObject(_super.prototype, {
        set actionPane(actionPane) {
          this.elem.appendChild(actionPane.elem)
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setThumbInfoDone() {
          _super.prototype.setThumbInfoDone.call(this)
          this._updateByMovieInfoTogglable()
          this._updateByDescriptionTogglable()
        },
      })
      return TwoColumnMovieRoot
    })(FixedThumbMovieRoot)

    var FourColumnMovieRoot = (function(_super) {
      var FourColumnMovieRoot = function(elem) {
        _super.call(this, elem)
        elem.classList.add('nrn-4-column-item')
      }
      FourColumnMovieRoot.prototype = createObject(_super.prototype, {
        set actionPane(actionPane) {
          this.movieInfo.actionPane = actionPane
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setMovieInfoToggleIfRequired() {
          if (!this.movieInfo.toggle.parentNode) {
            this.elem.appendChild(this.movieInfo.toggle)
          }
        },
      })
      return FourColumnMovieRoot
    })(FixedThumbMovieRoot)

    var MovieRoot = (function(_super) {
      var MovieRoot = function(elem) {
        _super.call(this, elem)
      }
      MovieRoot.prototype = createObject(_super.prototype, {
        set actionPane(actionPane) {
          this.elem.appendChild(actionPane.elem)
        },
        _addMovieInfo() {
          this.elem.querySelector('.itemContent')
            .appendChild(this.movieInfo.elem)
        },
        setThumbInfoDone() {
          _super.prototype.setThumbInfoDone.call(this)
          this._updateByMovieInfoTogglable()
          this._updateByDescriptionTogglable()
        },
        bindToConfig(config) {
          _super.prototype.bindToConfig.call(this, config)
          this.movieInfoTogglable = config.movieInfoTogglable.value
          config.movieInfoTogglable.on('changed', set(this, 'movieInfoTogglable'))
          this.descriptionTogglable = config.descriptionTogglable.value
          config.descriptionTogglable.on('changed', set(this, 'descriptionTogglable'))
        },
      })
      return MovieRoot
    })(FixedThumbMovieRoot)

    var SubMovieRoot = (function(_super) {
      var SubMovieRoot = function(elem) {
        _super.call(this, elem)
        elem.classList.add('nrn-sub-movie-root')
      }
      SubMovieRoot.prototype = createObject(_super.prototype, {
        set actionPane(actionPane) {
          this.movieInfo.actionPane = actionPane
        },
        _addMovieInfo() {
          this.elem.appendChild(this.movieInfo.elem)
        },
        setMovieInfoToggleIfRequired() {
          if (!this.movieInfo.toggle.parentNode) {
            this.elem.appendChild(this.movieInfo.toggle)
          }
        },
      })
      return SubMovieRoot
    })(AbstractMovieRoot)

    var createMainMovieRoot = function(rootElem) {
      var singleColumnView = Boolean(rootElem.getElementsByClassName('videoList01Wrap').length)
      if (singleColumnView) return new MovieRoot(rootElem)
      var twoColumnView = Boolean(rootElem.getElementsByClassName('videoList02Wrap').length)
      if (twoColumnView) return new TwoColumnMovieRoot(rootElem)
      return new FourColumnMovieRoot(rootElem)
    }
    var SearchPage = function(doc) {
      _super.call(this, doc)
    }
    SearchPage.prototype = createObject(_super.prototype, {
      parse(target) {
        target = target || this.doc
        return this._parseMain(target).concat(this._parseSub(target))
      },
      _parseItem(item) {
        return {
          type: 'main',
          movie: {
            id: item.dataset.videoId,
            title: item.querySelector('.itemTitle a').title,
          },
          rootElem: item,
        }
      },
      parseAutoPagerizedNodes(target) {
        return [this._parseItem(target)]
      },
      _parseMain(target) {
        return Array.from(target.querySelectorAll('.contentBody.video.uad .item[data-video-item]'))
          .map(item => this._parseItem(item))
      },
      _parseSub(target) {
        return Array.from(target.querySelectorAll('#tsukuaso .item'))
          .map(function(item) {
            return {
              type: 'sub',
              movie: {
                id: item.querySelector('.itemThumb').dataset.id,
                title: item.querySelector('.itemTitle a').textContent,
              },
              rootElem: item,
            }
          })
      },
      get _configBarContainer() {
        return this.doc.querySelector('.column.main')
      },
      createMovieRoot(resultOfParsing) {
        switch (resultOfParsing.type) {
          case 'main':
          case 'ad':
            return createMainMovieRoot(resultOfParsing.rootElem)
          case 'sub':
            return new SubMovieRoot(resultOfParsing.rootElem)
          default:
            throw new Error(resultOfParsing.type)
        }
      },
      observeMutation(callback) {
        const nodeList = document.querySelectorAll('.contentBody.video.uad .item.nicoadVideoItem .itemContent')
        for (const node of Array.from(nodeList)) {
          new MutationObserver((records, observer) => {
            for (const r of records) {
              if (SearchPage._isGettingAdDone(r)) {
                observer.disconnect()
                const item = ancestor(r.target, '.item.nicoadVideoItem')
                callback([SearchPage._parseAdItem(item)])
                return
              }
            }
          }).observe(node, {
            attributes: true,
            attributeOldValue: true,
            attributeFilter: ['style'],
          })
        }
      },
      get _pendingMoviesInvisibleCss() {
        return `.contentBody.video.uad .item,
#tsukuaso .item {
  visibility: hidden;
}
.contentBody.video.uad .item.nrn-thumb-info-done,
#tsukuaso .item.nrn-thumb-info-done,
.contentBody.video.uad.searchUad .item {
  visibility: inherit;
}
`
      },
      get css() {
        return `#nrn-config-bar {
  margin: 10px 0;
}
#nrn-config-button,
.nrn-visit-button:hover,
.nrn-movie-ng-button:hover,
.nrn-title-ng-button:hover,
.nrn-tag-ng-button:hover,
.nrn-contributor-ng-button:hover,
.nrn-contributor-ng-id-button:hover,
.nrn-contributor-ng-name-button:hover,
.nrn-movie-info-toggle:hover,
.nrn-description-open-button:hover,
.nrn-description-close-button:hover {
  text-decoration: underline;
  cursor: pointer;
}
.nrn-description-open-button {
  position: absolute;
  bottom: 0;
  right: 0;
  background-color: white;
}
.nrn-description-text,
.nrn-description-close-button {
  display: block;
}
.nrn-description-close-button {
  text-align: right;
}
.itemData,
.itemDescription {
  position: relative;
}
.nrn-movie-tag {
  display: inline-block;
  margin-right: 1em;
}
.nrn-description-open-button,
.nrn-description-close-button,
.nrn-movie-tag-link,
.nrn-contributor-link {
  color: #333333;
}
.nrn-movie-tag-link.nrn-movie-ng-tag-link,
.nrn-contributor-link.nrn-ng-contributor-link,
.nrn-matched-ng-contributor-name,
.nrn-matched-ng-title {
  color: white;
  background-color: fuchsia;
}
.nrn-movie-info-container .nrn-action-pane {
  line-height: 1.3em;
  padding-top: 4px;
}
.nrn-movie-info-container .nrn-tag-container,
.nrn-movie-info-container .nrn-contributor-container {
  line-height: 1.5em;
  padding-top: 4px;
}
.videoList01 .itemContent .itemDescription.ranking.nrn-description {
  height: auto;
  width: auto;
}
.nrn-movie-info-toggle {
  color: #333333;
  font-size: 85%;
}
.videoList01 .nrn-movie-info-toggle {
  position: absolute;
  right: 0;
  top: 0;
}
.videoList02 .nrn-movie-info-toggle,
.nrn-4-column-item .nrn-movie-info-toggle {
  display: block;
  text-align: right;
}
.videoList02 .nrn-movie-info-container {
  clear: both;
}
.nrn-hide,
.videoList02 .item.nrn-hide,
.video .item.nrn-4-column-item.nrn-hide {
  display: none;
}
.item.nrn-reduce .videoList01Wrap,
.item.nrn-reduce .videoList02Wrap {
  width: 80px;
}
.item.nrn-reduce .itemThumbBox,
.item.nrn-reduce .itemThumbBox .itemThumb,
.item.nrn-reduce .itemThumbBox .itemThumb .itemThumbWrap,
.item.nrn-reduce .itemThumbBox .itemThumb .itemThumbWrap img {
  width: 80px;
  height: 45px;
}
.videoList01 .nrn-action-pane,
.videoList02 .nrn-action-pane {
  display: none;
  position: absolute;
  top: 0px;
  right: 0px;
  padding: 3px;
  color: #999;
  background-color: rgb(105, 105, 105);
  z-index: 11;
}
.videoList02 .nrn-action-pane {
  font-size: 85%;
}
.videoList01 .item:hover .nrn-action-pane,
.videoList02 .item:hover .nrn-action-pane {
  display: block;
}
.videoList01 .item:hover .nrn-action-pane .nrn-visit-button,
.videoList01 .item:hover .nrn-action-pane .nrn-movie-ng-button,
.videoList01 .item:hover .nrn-action-pane .nrn-title-ng-button,
.videoList02 .item:hover .nrn-action-pane .nrn-visit-button,
.videoList02 .item:hover .nrn-action-pane .nrn-movie-ng-button,
.videoList02 .item:hover .nrn-action-pane .nrn-title-ng-button {
  color: white;
}
.videoList01 .item:hover .nrn-action-pane .nrn-movie-ng-button,
.videoList01 .item:hover .nrn-action-pane .nrn-title-ng-button,
.videoList02 .item:hover .nrn-action-pane .nrn-movie-ng-button,
.videoList02 .item:hover .nrn-action-pane .nrn-title-ng-button {
  margin-left: 5px;
  border-left: solid thin;
  padding-left: 5px;
}
.nrn-user-ng-button,
.nrn-tag-ng-button {
  display: inline-block;
}
.nrn-ng-movie-title,
.nrn-contributor-link.nrn-ng-id-contributor-link {
  text-decoration: line-through;
}
.nrn-sub-movie-root {
  position: relative;
}
.nrn-sub-movie-root .nrn-movie-info-toggle {
  display: block;
  text-align: right;
  background-color: white;
}
.nrn-sub-movie-root .nrn-movie-info-container {
  clear: left;
  padding: 10px 0 15px 0;
}
.nrn-sub-movie-root .nrn-action-pane .nrn-visit-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-movie-ng-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-title-ng-button,
.nrn-4-column-item .nrn-action-pane .nrn-visit-button,
.nrn-4-column-item .nrn-action-pane .nrn-movie-ng-button,
.nrn-4-column-item .nrn-action-pane .nrn-title-ng-button {
  display: inline-block;
  color: #333333;
}
.nrn-sub-movie-root .nrn-action-pane .nrn-visit-button,
.nrn-sub-movie-root .nrn-action-pane .nrn-movie-ng-button,
.nrn-4-column-item .nrn-action-pane .nrn-visit-button,
.nrn-4-column-item .nrn-action-pane .nrn-movie-ng-button {
  margin-right: 0.5em;
}
.nrn-error {
  color: red;
}
.videoList02 .item,
.video .item.nrn-4-column-item {
  float: none;
  display: inline-block;
  vertical-align: top;
}
.videoList02 .item,
.video .item.nrn-4-column-item,
.videoList02 .item:nth-child(2n),
.video .item.nrn-4-column-item:nth-child(4n) {
  margin-right: 10px;
}
.video .item.nrn-4-column-item:nth-child(4n+1) {
  clear: none;
}
.nrn-4-column-item .nrn-movie-tag {
  display: block;
}
`
      },
    })
    Object.assign(SearchPage, {
      TwoColumnMovieRoot,
      FourColumnMovieRoot,
      is(location) {
        var p = location.pathname
        return p.startsWith('/search/') || p.startsWith('/tag/')
      },
      _isGettingAdDone(mutationRecord) {
        const r = mutationRecord
        return r.attributeName === 'style'
            && r.oldValue.includes('visibility: hidden;')
            && r.target.getAttribute('style').includes('visibility: visible;')
      },
      _parseAdItem(item) {
        const p = item.querySelector('.count.ads .value a').pathname
        return {
          type: 'ad',
          movie: {
            id: p.slice(p.lastIndexOf('/') + 1),
            title: item.querySelector('.itemTitle a').textContent,
          },
          rootElem: item,
        }
      },
    })
    return SearchPage
  })(NicoPage)

  var Controller = (function() {
    var isMovieAnchor = function(e) {
      return e.dataset.nrnMovieAnchor === 'true'
    }
    var movieAnchor = function(child) {
      for (var n = child; n; n = n.parentNode) {
        if (n.nodeType !== Node.ELEMENT_NODE) return null
        if (isMovieAnchor(n)) return n
      }
    }
    var dataOfMovieAnchor = function(e) {
      return {
        id: e.dataset.nrnMovieId,
        title: e.dataset.nrnMovieTitle,
      }
    }
    var Controller = function(config, page) {
      this.config = config
      this.page = page
    }
    Controller.prototype = {
      addListenersTo(eventTarget) {
        eventTarget.addEventListener('change', this._changed.bind(this))
        eventTarget.addEventListener('click', this._clicked.bind(this))
      },
      _changed(event) {
        switch (event.target.id) {
          case 'nrn-visited-movie-view-mode-select':
            this.config.visitedMovieViewMode.value = event.target.value; break
          case 'nrn-visible-contributor-type-select':
            this.config.visibleContributorType.value = event.target.value; break
          case 'nrn-ng-movie-visible-checkbox':
            this.config.ngMovieVisible.value = event.target.checked; break
          case 'nrn-moving-up-checkbox':
            this.config.movingUp.value = event.target.checked; break
        }
      },
      _addVisitedMovie(target) {
        var d = dataOfMovieAnchor(movieAnchor(target))
        this.config.visitedMovies.addAsync(d.id, d.title)
      },
      _toggleData(target, add, remove) {
        var ds = target.dataset
        switch (ds.type) {
          case 'add': add.call(this, ds); break
          case 'remove': remove.call(this, ds); break
          default: throw new Error(ds.type)
        }
      },
      _toggleVisitedMovie(target) {
        this._toggleData(target, function(ds) {
          this.config.visitedMovies.addAsync(ds.movieId, ds.movieTitle)
        }, function(ds) {
          this.config.visitedMovies.removeAsync([ds.movieId])
        })
      },
      _toggleNgMovie(target) {
        this._toggleData(target, function(ds) {
          this.config.ngMovies.addAsync(ds.movieId, ds.movieTitle)
        }, function(ds) {
          this.config.ngMovies.removeAsync([ds.movieId])
        })
      },
      _toggleNgTitle(target) {
        this._toggleData(target, function(ds) {
          ConfigDialog.promptNgTitle(this.config, ds.movieTitle)
        }, function(ds) {
          this.config.ngTitles.removeAsync([ds.ngTitle])
        })
      },
      _toggleNgTag(target) {
        this._toggleData(target, function(ds) {
          this.config.ngTags.addAsync(ds.tagName)
        }, function(ds) {
          this.config.ngTags.removeAsync([ds.tagName])
        })
      },
      _toggleContributorNgId(target) {
        var name = function(ds) {
          return Contributor.new(ds.contributorType, ds.id, ds.name).ngIdStoreName
        }
        this._toggleData(target, function(ds) {
          this.config[name(ds)].addAsync(parseInt(ds.id), ds.name)
        }, function(ds) {
          this.config[name(ds)].removeAsync([parseInt(ds.id)])
        })
      },
      _toggleNgUserName(target) {
        this._toggleData(target, function(ds) {
          ConfigDialog.promptNgUserName(this.config, ds.name)
        }, function(ds) {
          this.config.ngUserNames.removeAsync([ds.matched])
        })
      },
      _clicked(event) {
        var e = event.target
        if (e.id === 'nrn-config-button') {
          this.page.showConfigDialog(this.config)
        } else if (movieAnchor(e)) {
          this._addVisitedMovie(e)
        } else if (e.classList.contains('nrn-visit-button')) {
          this._toggleVisitedMovie(e)
        } else if (e.classList.contains('nrn-movie-ng-button')) {
          this._toggleNgMovie(e)
        } else if (e.classList.contains('nrn-title-ng-button')) {
          this._toggleNgTitle(e)
        } else if (e.classList.contains('nrn-movie-info-toggle')) {
          this.page.getMovieRootBy(e).toggleMovieInfo()
        } else if (e.classList.contains('nrn-description-open-button')
                || e.classList.contains('nrn-description-close-button')) {
          this.page.getMovieRootBy(e).toggleDescription()
        } else if (e.classList.contains('nrn-tag-ng-button')) {
          this._toggleNgTag(e)
        } else if (e.classList.contains('nrn-contributor-ng-button')) {
          this._toggleContributorNgId(e)
        } else if (e.classList.contains('nrn-contributor-ng-id-button')) {
          this._toggleContributorNgId(e)
        } else if (e.classList.contains('nrn-contributor-ng-name-button')) {
          this._toggleNgUserName(e)
        }
      },
    }
    return Controller
  })()

  var LazyImageLoader = (function() {
    var LazyImageLoader = function(doc) {
      this.doc = doc
      this._requested = false
    }
    LazyImageLoader.prototype = {
      _lazyImages() {
        return Array.from(this.doc.querySelectorAll('.thumb.jsLazyImage'))
      },
      _lazyImagesInView() {
        return this._lazyImages().filter(this._isInView.bind(this))
      },
      _isInView(elem) {
        var r = elem.getBoundingClientRect()
        if (!(r.width && r.height)) return false
        var h = this.doc.defaultView.innerHeight
        return (r.top >= 0 && r.top < h) || (r.bottom > 0 && r.bottom <= h)
      },
      _load() {
        this._lazyImagesInView().forEach(function(img) {
          img.src = img.dataset.original
          img.dataset.original = ''
          img.classList.remove('jsLazyImage')
        })
      },
      request() {
        if (this._requested) return
        this._requested = true
        setTimeout(function() {
          this._requested = false
          this._load()
        }.bind(this), 125)
      },
    }
    return LazyImageLoader
  })()

  var Main = (function() {
    var createMovieRoot = function(resultOfParsing, page, movieViewMode) {
      var movie = movieViewMode.movie
      var result = page.createMovieRoot(resultOfParsing)
      result.actionPane
        = new NicoPage.ActionPane(page.doc, movie).bindToMovie(movie)
      result.setMovieInfoToggleIfRequired()
      result.markMovieAnchor()
      result.id = movie.id
      result.title = movie.title
      result.bindToMovieViewMode(movieViewMode)
      result.bindToConfig(movieViewMode.config)
      result.bindToMovie(movie)
      return result
    }
    var createMovieRoots = function(resultsOfParsing, model, page) {
      for (var r of resultsOfParsing) {
        var movie = model.movies.get(r.movie.id)
        var movieViewMode = model.movieViewModes.get(movie)
        var root = createMovieRoot(r, page, movieViewMode)
        root.movieTitle = new NicoPage.MovieTitle(root.titleElem).bindToMovie(movie)
        page.mapToggleTo(root)
      }
    }
    var createLazyImageLoader = function(doc) {
      var loader = new LazyImageLoader(doc)
      return loader.request.bind(loader)
    }
    var setup = function(resultsOfParsing, model, page) {
      model.createMovies(resultsOfParsing)
      createMovieRoots(resultsOfParsing, model, page)
    }
    var createMessageElem = function(doc, message) {
      var result = doc.createElement('p')
      result.textContent = message
      return result
    }
    function gmXmlHttpRequest() {
      if (typeof GM_xmlhttpRequest === 'undefined')
        return GM.xmlHttpRequest
      return GM_xmlhttpRequest
    }
    var createThumbInfoRequester = function(movies, movieViewModes) {
      var thumbInfo = new ThumbInfo(gmXmlHttpRequest())
        .on('completed', ThumbInfoListener.forCompleted(movies))
        .on('errorOccurred', ThumbInfoListener.forErrorOccurred(movies))
      return function(prefer) {
        thumbInfo.request(
          movieViewModes.sort().map(function(m) { return m.movie.id }), prefer)
      }
    }
    var getThumbInfoRequester = function(movies, movieViewModes) {
      return movies.config.useGetThumbInfo.value
           ? createThumbInfoRequester(movies, movieViewModes)
           : function() {}
    }
    var createModel = function(config) {
      var movies = new Movies(config)
      var movieViewModes = new MovieViewModes(config)
      var requestThumbInfo = getThumbInfoRequester(movies, movieViewModes)
      return {
        config,
        movies,
        movieViewModes,
        requestThumbInfo,
        createMovies(resultsOfParsing) {
          movies.setIfAbsent(resultsOfParsing.map(function(r) {
            return new Movie(r.movie.id, r.movie.title)
          }))
        },
      }
    }
    var createView = function(page) {
      var loadLazyImages = createLazyImageLoader(page.doc)
      var configBar = page.createConfigBar()
      return {
        page,
        loadLazyImages,
        addConfigBar() {
          page.addConfigBar(configBar)
        },
        _bindToConfig(config) {
          page.bindToConfig(config)
          configBar.bindToConfig(config)
          config.movingUp.on('changed', loadLazyImages)
        },
        bindToModel(model) {
          this._bindToConfig(model.config)
          model.movieViewModes.on('movieViewModeChanged'
                                , loadLazyImages)
        },
        bindToWindow() {
          page.doc.defaultView.addEventListener('scroll', loadLazyImages)
        },
        setup(model, targetElem) {
          setup(page.parse(targetElem), model, page)
        },
        setupAndRequestThumbInfo(model, targetElem) {
          this.setup(model, targetElem)
          model.requestThumbInfo()
        },
        observeMutation(model) {
          page.observeMutation(function(resultOfParsing, prefer) {
            setup(resultOfParsing, model, page)
            model.requestThumbInfo(prefer)
          })
        },
      }
    }
    function addStyle(style) {
      const e = document.createElement('style')
      e.textContent = style
      document.head.appendChild(e)
    }
    function gmGetValue() {
      if (typeof GM_getValue === 'undefined')
        return GM.getValue
      return GM_getValue
    }
    function gmSetValue() {
      if (typeof GM_setValue === 'undefined')
        return GM.setValue
      return GM_setValue
    }
    function handleAutoPagerizedNodes(model, page) {
      return function(e) {
        const t = e.target
        if (t.nodeType === Node.ELEMENT_NODE && t.classList.contains('item')) {
          setup(page.parseAutoPagerizedNodes(t), model, page)
          model.requestThumbInfo()
        }
      }
    }
    var domContentLoaded = function(page) {
      return async function() {
        try {
          addStyle(page.css)
          const config = new Config(gmGetValue(), gmSetValue())
          await config.sync()
          var model = createModel(config)
          var view = createView(page)
          view.addConfigBar()
          view.bindToModel(model)
          view.bindToWindow()
          view.setupAndRequestThumbInfo(model)
          view.observeMutation(model)
          new Controller(model.config, page).addListenersTo(page.doc.body)
          if (!model.config.useGetThumbInfo.value) {
            page.pendingMoviesVisible = true
          }
          page.doc.body.addEventListener('AutoPagerize_DOMNodeInserted',
                                         handleAutoPagerizedNodes(model, page))
        } catch (e) {
          console.error(e)
          page.pendingMoviesVisible = true
        }
      }
    }
    var getPage = function() {
      if (SearchPage.is(document.location)) return new SearchPage(document)
      return new ListPage(document)
    }
    var main = function() {
      var page = getPage()
      page.pendingMoviesVisible = false
      if (['interactive', 'complete'].includes(document.readyState))
        domContentLoaded(page)()
      else
        page.doc.addEventListener('DOMContentLoaded', domContentLoaded(page))
    }
    return {main}
  })()

  Main.main()
})()