nicovideo Add Stars to Tags

By gifnksm Last update Dec 29, 2010 — Installed 9,309 times.

There are 10 previous versions of this script.

Add Syntax Highlighting (this will take a few seconds, probably freezing your browser while it works)

// ==UserScript==
// @name           nicovideo Add Stars to Tags
// @namespace      http://d.hatena.ne.jp/gifnksm/
// @description    Add stars that represents their uploader set the tags unmodifiable.
// @version        2010-12-29a
// @include        http://www.nicovideo.jp/watch/*
// @resource       style https://github.com/gifnksm/nicovideo-add-stars-to-tags/raw/2010-12-29a/style.css
// @resource       ctg1 https://github.com/gifnksm/nicovideo-add-stars-to-tags/raw/2010-12-29a/ctg1.png
// @resource       ctg2 https://github.com/gifnksm/nicovideo-add-stars-to-tags/raw/2010-12-29a/ctg2.png
// @resource       ctg1lock https://github.com/gifnksm/nicovideo-add-stars-to-tags/raw/2010-12-29a/ctg1lock.png
// @resource       ctg2lock https://github.com/gifnksm/nicovideo-add-stars-to-tags/raw/2010-12-29a/ctg2lock.png
// ==/UserScript==

/*
 * ユーティリティ関数群
 */
Array.prototype.include = function(x) this.indexOf(x) != -1;
Array.prototype.replace = function(newVal) {
  this.splice.apply(this, [0, this.length].concat(newVal));
};

Array.prototype.partition = function(fun, thisp) {
  if (typeof fun != 'function')
    throw new TypeError();

  var res1 = [], res2 = [];
  for (var i = 0, len = this.length; i < len; i++) {
    if (i in this) {
      if (fun.call(thisp, this[i], i, this))
        res1.push(this[i]);
      else
        res2.push(this[i]);
    }
  }
  return [res1, res2];
};


// DOM要素を結合する。引数はScalaのmkString風
Array.prototype.joinDOM = function() {
  var [sep, head, tail] = [null, null, null],
      arg = Array.map(arguments, convertToDOM);

  switch(arg.length) {
  case 0: break;
  case 1: [sep] = arg; break;
  case 3: [head, sep, tail] = arg; break;
  default: throw new Error('invalid arguments');
  }

  var df = document.createDocumentFragment();
  function append(e, clone) {
    if (e !== null) df.appendChild(clone ? e.cloneNode(true) : e);
  }

  append(head);
  for (let [i, elem] in Iterator(this)) {
    if (i > 0) append(sep, true);
    append(convertToDOM(elem));
  }
  append(tail);

  return df;
};

// class関連関数
var {addClassName, removeClassName, hasClassName} = (function() {
    if (document.body.classList !== undefined) {
    return {
      addClassName: function addClassName(elem, className)
        elem.classList.add(className),
      removeClassName: function removeClassName(elem, className)
        elem.classList.remove(className),
      hasClassName: function hasClassName(elem, className)
        elem.classList.contains(className)
    };
  }
  return {
    addClassName: function addClassName(elem, className) {
      if (!hasClassName(elem))
        elem.className += ' ' + className;
    },
    removeClassName: function removeClassName(elem, className) {
      elem.className = elem.className.split(/\s+/).filter(
        function(exist_name) exist_name != className).join(' ');
    },
    hasClassName: function hasClassName(elem, className) {
      return elem.className.split(/\s+/).indexOf(className) != -1;
    }
  };
})();

function setClassName(elem, className, flag) {
  if (flag) addClassName(elem, className);
  else removeClassName(elem, className);
}

// オブジェクトをDOMノードに変換する
function convertToDOM(elem) {
  if (elem === null)
    return null;
  if (elem instanceof String || typeof elem === 'string')
    return document.createTextNode(elem);
  if (elem instanceof XML)
    return e4xToDOM(elem);
  return elem;
}

// XML (E4X)からDOM Nodeへの変換
default xml namespace = "http://www.w3.org/1999/xhtml";
function e4xToDOM(xml, xmlns) {
  var pretty = XML.prettyPrinting;

  // 余分な空白を混入させないように,prettyPrintingを一時的に無効にする
  XML.prettyPrinting = false;
  var doc = (new DOMParser).parseFromString(
    '<root xmlns="' + xmlns + '">' + xml.toXMLString() + "</root>",
    "application/xml");
  XML.prettyPrinting = pretty;

  var imported = document.importNode(doc.documentElement, true);
  var range = document.createRange();
  range.selectNodeContents(imported);
  var fragment = range.extractContents();
  range.detach();
  return fragment.childNodes.length > 1 ? fragment : fragment.firstChild;
}

Object.memoize = function(obj, defs) {
  function add(key, getter) {
    obj.__defineGetter__(key, function() {
                           delete this[key];
                           return this[key] = getter.call(this);
                         });
  }
  for (let key in defs) {
    if (defs.hasOwnProperty(key)) {
      add(key, defs[key]);
    }
  }
};

// Settings
const VideoID = unsafeWindow.Video.id;
const RequestURL = 'http://www.nicovideo.jp/tag_edit/' + VideoID;
const NicopediaTimeout = 30000;

const CategoryTags = [
  // エンタ・音楽・スポ
  "エンターテイメント 音楽 スポーツ",
  // 教養・生活
  "動物 料理 日記 自然 科学 歴史 ラジオ ニコニコ動画講座",
  // 政治
  "政治",
  // やってみた
  "歌ってみた 演奏してみた 踊ってみた 描いてみた ニコニコ技術部",
  // アニメ・ゲーム
  "アニメ ゲーム",
  // 殿堂入りカテゴリ
  "アイドルマスター 東方 VOCALOID 例のアレ その他",
  "R-18"].join(' ').split(/\s+/);
// "投稿者コメント アンケート チャット テスト 台灣"

// prototype拡張された配列にする
const LockedTags = [].concat(unsafeWindow.Video.lockedTags);
const DomainTags = [].concat(unsafeWindow.Video.tags);
const ForeignTags = [];

// Resources
const CategoryIcon1 = GM_getResourceURL('ctg1');
const CategoryIcon2 = GM_getResourceURL('ctg2');
const LockedCategoryIcon1 = GM_getResourceURL('ctg1lock');
const LockedCategoryIcon2 = GM_getResourceURL('ctg2lock');

function className(name) '_GM_tag_' + name;

GM_addStyle(
  GM_getResourceText('style').replace(/__(.+?)__/g, function(_, name) className(name)));


// 各タグをタグ検索ページへのリンクと関連付けて管理するクラス
var TagLink = function(link) {
  this.link = link;
  this.name = link.textContent;
  this.hrefName = link.href.replace(
    new RegExp("^http://www\\.nicovideo\\.jp/tag/"), "");
  this.isLocked = LockedTags.include(this.name);
  this.isCategory = CategoryTags.include(this.name);
  this.isDomain = DomainTags.include(this.name);

  this.resetSelection();

  var self = this;
  link.addEventListener('mousedown', function(e) self._mousedown(e), false);
  link.addEventListener('click', function(e) self._click(e), false);
};
TagLink.ClassNames = {
  And: className('selected_and'),
  Minus: className('selected_minus'),
  Icon: className('icon'),
  Domain: className('domain_tag'),
  Foreign: className('foreign_tag')
};
TagLink.Marks = {};
Object.memoize(
  TagLink.Marks, {
    LockedCategory: function() {
      return e4xToDOM(<img src={LockedCategoryIcon2}
                           class={TagLink.ClassNames.Icon}
                           alt="★カテゴリ?" />);
    },
    Locked: function() {
      return e4xToDOM(<span style="color: #f90;">★</span>);
    },
    Category: function() {
      return e4xToDOM(<img src={CategoryIcon2}
                           class={TagLink.ClassNames.Icon}
                           alt="カテゴリ?" />);
    }
  });
TagLink.prototype = {
  _selectedAnd: false,
  get selectedAnd () { return this._selectedAnd; },
  set selectedAnd (selected) {
    this._selectedAnd = Boolean(selected);
    this._selectedMinus = false;
    this._updateClassName();
    return this._selectedAnd;
  },
  toggleAnd: function() {return this.selectedAnd = !this.selectedAnd; },
  _selectedMinus: false,
  get selectedMinus () { return this._selectedMinus; },
  set selectedMinus (selected) {
    this._selectedMinus = Boolean(selected);
    this._selectedAnd = false;
    this._updateClassName();
    return this._selectedMinus;
  },
  toggleMinus: function() {return this.selectedMinus = !this.selectedMinus; },
  resetSelection: function() { this.selectedAnd = this.selectedMinus = false; },
  _updateClassName: function() {
    setClassName(this.link, TagLink.ClassNames.And, this.selectedAnd);
    setClassName(this.link, TagLink.ClassNames.Minus, this.selectedMinus);
  },
  _mousedown: function(e) {
    // タグ複数選択時に,クリックと共に範囲選択されるのを防ぐ
    if (e.altKey && e.ctrlKey)
      e.preventDefault();
  },
  _click: function(e) {
    // 選択モード or Ctrl+Alt+クリック の場合,タグを選択・選択解除する
    if (AllTags.selectionMode || (e.altKey && e.ctrlKey)) {
      if (e.shiftKey) this.toggleMinus();
      else            this.toggleAnd();
      AllTags.selectionCallbacks.forEach(function(f) f());
      e.preventDefault();
      return;
    }
    // クリックされた場合,タグ検索へ移動
    // 一時的にhrefを書き換えることにより,タグの選択状況を反映させる
    var link = this.link, originalHref = link.href;
    link.href = AllTags.generateSearchURL(this);
    setTimeout(function() link.href = originalHref, 0);
  },
  decorate: function() {
    var parent = this.link.parentNode,
        icon = parent.querySelector('img[alt="カテゴリ"]');
    addClassName(parent, this.isDomain
                 ? TagLink.ClassNames.Domain
                 : TagLink.ClassNames.Foreign);
    if (icon !== null) {
      if (this.isLocked) {
        icon.src = LockedCategoryIcon1;
        icon.alt = '★カテゴリ';
      }
      return;
    }
    var name = (this.isLocked ? 'Locked' : '')+(this.isCategory ? 'Category' : '');
    if (name !== '')
      parent.insertBefore(TagLink.Marks[name].cloneNode(true), this.link);
  }
};



// 全てのタグ(海外タグ含む)を管理するクラス
var AllTags = [];
AllTags.selectionMode = false;
AllTags._showAll = false;
AllTags.__defineGetter__('showAll', function() this._showAll);
AllTags.__defineSetter__('showAll', function(value) {
                           value = Boolean(value);
                           GM_setValue('showAllTags', value);
                           return this._showAll = value;
                         });
AllTags.selectionCallbacks = [];
AllTags.container = null;
AllTags.init = function(container) {
  this.container = container;
  this._updateCache();
  this._updateTags();
};
AllTags.decorate = function(isLoading) {
  this.forEach(function(link) link.decorate());
  if (isLoading)
    return;
  CommandLinks.init(this.container);
  this.container.appendChild(SelectionMenu.menu);
};
AllTags.resetSelection = function() {
  this.forEach(function(link) link.resetSelection());
  this.selectionCallbacks.forEach(function(f) f());
};
AllTags.generateSearchURL = function(clickedTag) {
  var tags = this.filter(
    function(t) t.selectedAnd || (t === clickedTag && !t.selectedMinus)
  ).map(function(t) t.name).concat(
    this.filter(function(t) t.selectedMinus).map(function(t) '-' + t.name)
  );
  if (tags.length == 0)
    return 'javascript: void(0);';
  return 'http://www.nicovideo.jp/tag/' + tags.join('+');
};
AllTags._cacheObj = { all: null, domain: null };
AllTags.__defineGetter__('_cacheName', function() this.showAll ? 'all' : 'domain');
AllTags.__defineGetter__('_cache', function() this._cacheObj[this._cacheName]);
AllTags.__defineSetter__('_cache', function(v) this._cacheObj[this._cacheName] = v);
AllTags.clearCache = function() { this._cache = null; };
AllTags.clearAllCache = function() { this._cacheObj = { all: null, domain: null}; };
AllTags._updateCache = function() { this._cache = this.container.innerHTML; };
AllTags._showingForeignTags = false;
AllTags._updateTags = function() {
  var links = Array.slice(this.container.querySelectorAll('a[rel="tag"]') || []);
  var tags = links.map(function(l) l.textContent);
  if (!this.showAll) {
    DomainTags.replace(tags);
    this._showingForeignTags = false;
  } else {
    let ftags = tags.filter(function(t) !DomainTags.include(t));
    // 海外タグ表示→海外タグ表示と遷移した場合
    if (this._showingForeignTags) {
      let oldtags = this.map(function(t) t.name), newtags = [];
      // 海外タグと判定されたもののうち,新しく登場したものは国内タグと判定する
      [ftags, newtags] = ftags.partition(function(t) oldtags.include(t));
      DomainTags.push.apply(DomainTags, newtags);
    }
    this._showingForeignTags = true;
    ForeignTags.replace(ftags);
  }
  this.replace(links.map(function(link) new TagLink(link)));
};
AllTags.__defineSetter__(
  '_innerHTML', function(html) {
    this.container.innerHTML = html;
    this._updateCache();
    this._updateTags();
    // タグの更新後,大百科のアイコンが付かないニコニコ動画側のバグ(?)への対処
    this.forEach(this.container.querySelectorAll('[rel="tag"]:not(.nicopedia)'),
                 function(link) { addClassName(link, 'nicopedia'); });
    if (unsafeWindow.Nicopedia !== undefined)
      unsafeWindow.Nicopedia.decorateLinks();
    this.decorate(false);
  });
AllTags.refresh = function(callback) {
  var self = this;
  function update(html) {
    if (html.indexOf('<!DOCTYPE') == 0)
      self._innerHTML = '<p style="font-size: 12px; color: #cc0000; padding: 4px;">通信エラー (不正なレスポンスです)</p>';
    else
      self._innerHTML = html;
    if (callback instanceof Function)
      callback();
  }

  if (this._cache !== null) {
    update(this._cache);
    return;
  }
  this.container.innerHTML = '<img src="img/watch/tool_loading.gif" alt="処理中">';
  GM_xmlhttpRequest(
    {method: 'POST', url: RequestURL,
     headers: {'X-Requested-With': 'XMLHttpRequest',
               'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
     data: 'cmd=tags' + (this.showAll ? '&all=1' : ''),
     onload: function(response) update(response.responseText)
    });
};


var HTMLUtil = {
  ClassNames: {
    CommandLink: className('command_link'),
    ToggleButton: className('toggle_button'),
    ToggleLabel: className('toggle_label')
  },
  setAttr: function(elem, attr) {
    for (let name in attr) {
      if (attr.hasOwnProperty(name))
        elem.setAttribute(name, attr[name]);
    }
  },
  _initElem: function(e4x, attr, className, type, fun) {
    var elem = e4xToDOM(e4x);
    this.setAttr(elem, attr);
    // クラス名を追加する (setAttr内でクラスが追加された場合上書きしないように)
    addClassName(elem, className);

    if (fun instanceof Function)
      elem.addEventListener(type, fun, false);
    return elem;
  },
  createCommandLink: function(text, fun, attr) {
    return this._initElem(
      <a href="javascript: void(0);">{text}</a>,
      attr, this.ClassNames.CommandLink,
      'click', fun);
  },
  createToggleButton: function(init, fun, attr) {
    var elem = this._initElem(
      <input type="checkbox"/>,
      attr, this.ClassNames.ToggleButton,
      'change', function() fun(this.checked)
    );
    elem.checked = init;
    return elem;
  },
  createLabeledToggle: function(label, init, fun) {
    var elem = e4xToDOM(<label class={this.ClassNames.ToggleLabel} />);
    elem.appendChild([this.createToggleButton(init, fun),
                      XML(label)].joinDOM());
    return elem;
  }
};

var SelectionMenu = {
  ClassNames: {
    Description: className('description'),
    Menu: className('selection_menu')
  },
  show: function() { this.menu.style.display = ''; },
  hide: function() { this.menu.style.display = 'none'; },
  setVisible: function(value) {
    if (value) SelectionMenu.show();
    else SelectionMenu.hide();
  }
};
Object.memoize(
  SelectionMenu, {
    search: function() {
      var link = HTMLUtil.createCommandLink('選択したタグで検索');
      AllTags.selectionCallbacks.push(
        function() link.href = AllTags.generateSearchURL());
      return link;
    },
    reset: function() {
      return HTMLUtil.createCommandLink(
        '選択解除', function() AllTags.resetSelection());
    },
    description: function() {
      return e4xToDOM(
        <span class={this.ClassNames.Description}>
        タグをクリックで <span class={TagLink.ClassNames.And}>選択</span>、
        Shift+クリックで <span class={TagLink.ClassNames.Minus}>マイナス選択</span>
        </span>);
    },
    menu: function() {
      var menu = e4xToDOM(
          <div class={this.ClassNames.Menu} style="display: none;"/>);
      menu.appendChild([this.search, this.reset, this.description].joinDOM(' | '));
      return menu;
    }
  }
);

var CommandLinks = {
  ClassNames: {
    Commands: className('commands'),
    Separator: className('separator')
  },
  _getEdit: function(container) {
    if (container === null) return null;
    var edit = container.querySelector('a[href^="javascript:startTagEdit"]');
    if (edit === null) return null;
    edit.textContent = "編集";
    addClassName(edit, HTMLUtil.ClassNames.CommandLink);
    edit.removeAttribute('style');
    return edit;
  },
  get counter() {
    if (AllTags.showAll)
      return e4xToDOM(
          <span><strong>{DomainTags.length}</strong>
          +
          <strong class={TagLink.ClassNames.Foreign}>{ForeignTags.length}</strong>
          件</span>);
    return e4xToDOM(<strong>{DomainTags.length}件</strong>);
  },
  init: function(video_tags) {
    var tagsContainer = video_tags.firstElementChild,
        edit = this._getEdit(tagsContainer),
        commandsContainer, links;

    if (edit !== null) {
      commandsContainer = edit.parentNode;
      links = [this.counter, edit, this.refresh, this.foreign, this.select];
    } else {
      // タグを更新できなかったとき。(混雑しています、など)
      if (tagsContainer === null)
        tagsContainer = video_tags;
      commandsContainer = document.createElement('nobr');
      tagsContainer.appendChild(commandsContainer);
      links = [this.refresh];
    }

    addClassName(commandsContainer, this.ClassNames.Commands);
    commandsContainer.appendChild(
      links.joinDOM('[ ',
                    <span class={this.ClassNames.Separator}> | </span>,
                    ' ]'));
  }
};
Object.memoize(
  CommandLinks, {
    refresh: function() {
      return HTMLUtil.createCommandLink(
        '更新', function() {
          AllTags.clearCache();
          AllTags.refresh();
        });
    },
    foreign: function() {
      return HTMLUtil.createLabeledToggle(
        '海外タグ', AllTags.showAll,
        function(value) {
          AllTags.showAll = value;
          AllTags.refresh();
        });
    },
    select: function() {
      return HTMLUtil.createLabeledToggle(
        '複数選択', AllTags.selectionMode,
        function(value) {
          AllTags.selectionMode = value;
          SelectionMenu.setVisible(value);
        });
    }
  }
);

unsafeWindow.finishTagEdit = function(url) {
  var editForm = document.getElementById('tag_edit_form');

  if(editForm !== null)
    editForm.innerHTML = '<img src="img/watch/tool_loading.gif" alt="処理中">';

  AllTags.clearAllCache();
  setTimeout(
    function() {
      AllTags.refresh(
        function() {
          document.getElementById('video_controls').style.display = '';
          if(editForm !== null)
            editForm.parentNode.removeChild(editForm);
        });
    }, 0);
};

// 初期化
(function() {
   AllTags.init(document.getElementById('video_tags'));
   AllTags.showAll = GM_getValue('showAllTags', AllTags.showAll);

   AllTags.decorate(AllTags.showAll);

   // 海外タグを表示しない場合
   if (!AllTags.showAll)
     return;

   // タグがついていない動画の場合,大百科の要素追加を待たず即座に更新
   if (AllTags.length === 0) {
     AllTags.refresh();
     return;
   }

   // 大百科アイコン追加後に更新
   AllTags.container.addEventListener('DOMNodeInserted', inserted, false);
   // タイムアウト設定(大百科アイコンが表示できない場合)
   setTimeout(refresh, NicopediaTimeout);

   // 大百科アイコン挿入後1回だけ更新する
   function refresh() {
     if (refresh.finished) return;
     refresh.finished = true;
     AllTags.container.removeEventListener('DOMNodeInserted', inserted, false);
     setTimeout(function() AllTags.refresh(), 0);
   }
   refresh.finished = false;

   // 追加された要素が大百科アイコンなら更新する
   function inserted(e) {
     var t = e.target;
     if (t.nodeName !== 'A' || t.title === undefined ||
         t.title.indexOf('大百科') !== 0)
       return;
     refresh();
   }
 })();