Pixiv Tag Translation/Replacement

Shows translations of tags on Pixiv and prompts for untranslated tags.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Pixiv Tag Translation/Replacement
// @description Shows translations of tags on Pixiv and prompts for untranslated tags.
// @namespace   http://scripts.chris.charabaruk.com/pixiv.net/~tag-translation
// @author      coldacid
// @include     http://www.pixiv.net/
// @include     http://www.pixiv.net/*
// @include     http://pixiv.net/
// @include     http://pixiv.net/*
// @include     https://www.pixiv.net/
// @include     https://www.pixiv.net/*
// @include     https://pixiv.net/
// @include     https://pixiv.net/*
// @version     1.3
// @grant       none
// ==/UserScript==

var TagsCollection;
{
  const USER_DATA_KEY = 'com.charabaruk.chris.pixiv.net.tag-translation';
  let version = 1;
  let settings = null;
  let map = null;

  let loadData = function () {
    var userData = window.localStorage[USER_DATA_KEY];
    if (userData) {
      userData = JSON.parse(userData);
    } else {
      userData = {};
    }

    version = userData.version || 1;

    settings = userData.settings || {
      showOriginalTag: true, // or false, or array containing zero or more of ['label', 'hover']
      promptForTranslations: true // or false
    };

    var tags = userData.tags || {
      "R-18": null,
      "3D": null
    };
    tags[Symbol.iterator] = function* () { for (var tag in this) yield [tag, this[tag]]; };
    map = new Map(tags);

    return [version, settings, map];
  };
  let saveData = function () {
    // so we don't overwrite changes made in other tabs, grab the current data first when saving
    var userData = JSON.parse(window.localStorage[USER_DATA_KEY] || `{"version": "${version}", "settings": {}, "tags": {}}`);

    userData.settings = settings; // use current settings
    for (var [k, v] of map.entries()) { userData.tags[k] = v; } // yes, overwrite existing tags when merging

    window.localStorage[USER_DATA_KEY] = JSON.stringify(userData);
  };

  let updateHandlers = new Map();
  let runHandlers = function (tag, translation) {
    for (var [obj, handler] of updateHandlers.entries()) {
      try {
        handler.call(obj, tag, translation);
      } catch (err) {
        console.error('tag translation update handler threw', err);
      }
    }
  };

  window.addEventListener('storage', evt => {
    if (evt.key !== USER_DATA_KEY) { return; }

    console.info("Another tab has updated tag translations, merging");
    var tags = JSON.parse(evt.newValue || "{tags: null}").tags;
    if (!tags) { return; }

    for(var key of Object.getOwnPropertyNames(tags)) {
      if (!map.has(key) || map.get(key) !== tags[key]) {
        console.info(`"${key}": "${map.get(key)}" => "${tags[key]}"`);
        map.set(key, tags[key]); // take remote version over existing one
        runHandlers(key, tags[key]);
      }
    }
  }, false);

  TagsCollection = function TagsCollection () {
    var [version, settings, map] = loadData();

    Object.defineProperty(this, 'version', {value: version});
    Object.defineProperty(this, 'settings', {value: settings});

    Object.defineProperty(this, 'tagUpdated', {
      get: function () { return updateHandler.has(this) ? updateHandler.get(this) : null; },
      set: function (handler) {
        if (!handler || typeof handler !== 'function') {
          updateHandlers.delete(this);
        } else {
          updateHandlers.set(this, handler);
        }
      }
    });
  };

  TagsCollection.prototype.has = function (tag) { return map.has(tag); };
  TagsCollection.prototype.get = function (tag) { return map.get(tag) || tag; };
  TagsCollection.prototype.set = function (tag, translation) {
    if (translation === undefined) {
      if (tag.entries) {
        for (var [key, value] of tag.entries()) {
          map.set(key, value);
        }
      } else if (tag[Symbol.iterator]) {
        for (var [key, value] of tag) {
          map.set(key, value);
        }
      } else if (tag instanceof Object) {
        for (var key of Object.getOwnPropertyNames(tag)) {
          map.set(key, tag[key]);
        }
      } else {
        throw new Error('missing translation');
      }
    } else {
      map.set(tag, translation);
    }

    saveData();
  };
  TagsCollection.prototype.delete = function (tag) {
    map.delete(tag);
    saveData();
  };

  TagsCollection.prototype.tags = function* () { for (var entry of map.entries()) yield entry; };
  TagsCollection.prototype.translations = function () {
    var reversed = {};
    reversed[Symbol.iterator] = function* () { for (var key in this) yield [key, this[key]]; };

    for (var [key, value] of map.entries()) {
      reversed[value] = reversed[value] || [];
      reversed[value].push(key);
    }

    return reversed;
  };

  TagsCollection.prototype.translatedAs = function (translation) {
    translation = translation || '';

    var tags = [];
    for (var [key, value] of map.entries()) {
      if ((value || '').toLowerCase() === translation.toLowerCase())
        tags.push(key);
    }
    return tags;
  };
}

function GM_main ($) {
  var tags = new TagsCollection();
  window.translatedTags = tags;

  var settings = tags.settings;

  function setTagText(node, tag) {
    if (Array.isArray(node) || node instanceof jQuery) {
      node = node[0];
    }

    var showOriginalTag = settings.showOriginalTag,
        showInLabel = showOriginalTag === true || showOriginalTag.indexOf('label') !== -1,
        showOnHover = showOriginalTag === true || showOriginalTag.indexOf('hover') !== -1;

    var $element = $(node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement),
        originalTag = $element.attr('data-tag-translator-original') || $element.text();

    var tagLabel = showInLabel ? `${tag} (${originalTag})` : tag;

    if (node.nodeType === Node.TEXT_NODE) {
      node.textContent = tagLabel;
    } else {
      $element.text(tagLabel);
    }

    $element.attr('data-tag-translator-current', tag);
    if (showOnHover) {
      $element.attr('title', originalTag);
    }
  }

  var tagSelectors = [
    'li.tag > a:not([class~="portal"]):not([target="_blank"])',
    'div.tag-name',
    'section.favorite-tag > ul.favorite-tags > li > a:not([class~="portal"]):not([target="_blank"])',
    'nav.breadcrumb > span a[href^="/tags.php?tag="] > span[itemprop="title"]',
    'ul.tagCloud > li > a:not([class~="portal"]):not([target="_blank"])',
    'ul.tags > li > a:not([class~="portal"]):not([target="_blank"])',
    'table.ws_table td.td2 > a[href^="personal_tags.php?tag="]',
    'div.bookmark-list-unit ul.tag-cloud > li > span.tag[data-tag]',
    'dl.tag-list a.tag-name:not([class~="portal"]):not([target="_blank"])',
    'ul.tag-list a.tag-name:not([class~="portal"]):not([target="_blank"])',
    'h1.column-title > a.self[href^="/search.php"]'
  ].join(', ');

  var untranslated = new Map();
  // content page regular tags, home page featured tags, home page favorite tags
  $(tagSelectors)
    .contents()
    .filter((i, n) => n.nodeType === Node.TEXT_NODE) // only get the text nodes within the selected elements
    .each((i, n) => {
      var $node = $(n),
          tag = $node.text();

      // save original tag value, add edit translation button
      $(n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement)
        .attr('data-tag-translator-original', tag)
        .append('<span class="tags tag-translator-added" style="position:relative;"><span class="portal retranslate">j</span></span>');

      if (tags.has(tag)) {
        // if we have a translation for the tag, update the text for it
        var tl = tags.get(tag);
        console.debug(`Replacing tag "${tag}" with "${tl}"`);
        setTagText($node, tl);
      } else {
        if (!settings.promptForTranslations) {
          // we aren't going to bother with asking for translations, so nothing more to do
          return;
        } else if (/^[\x20-\x7e]*$/.test(tag)) {
          // tag is entirely ASCII, so skip it and go onto the next node for processing
          console.debug(`"${tag}" only uses ASCII printable characters, skipping`);
          return;
        } else {
          // if we don't have a translation and the tag isn't ASCII text, add to the untranslated list
          console.debug(`No translation available for tag "${tag}", adding to the list`);
          let nodes = untranslated.has(tag) ? untranslated.get(tag) : [];
          nodes.push($node);
          untranslated.set(tag, nodes);
        }
      }
    });

  // prompt for translations
  if (untranslated.size > 0) {
    var taglist = Array.from(untranslated.keys()).join(', '),
        tagcount = untranslated.size;
    if (window.confirm(`There are ${tagcount} untranslated tags. Want to translate?\n\nTags: ${taglist}`)) {
      var translations = new Map(), i = 1;
      for (var [tag, $nodes] of untranslated.entries()) {
        // try getting a translated version anyway, just in case it got translated on another tab
        var translated = window.prompt(
          `Translation for: ${tag}\n\nLeave empty to cancel translating, leave as-is to skip [${i++}/${tagcount}]`,
          tags.get(tag));
        if (!translated) { break; }

        // only save if the translation is different from the original tag
        if (tag !== translated) {
          translations.set(tag, translated);
          $nodes.forEach($n => setTagText($n, translated));
        }
      }
      tags.set(translations);
    }
  }

  // set up tag updating
  tags.tagUpdated = (tag, translation) => {
    if (!translation) { translation = tag; } // sanity: if no translation, at least keep the original tag value

    $(`[data-tag-translator-original="${tag}"]`)
      .contents()
      .filter((i, n) => n.nodeType === Node.TEXT_NODE)
      .each((i, n) => setTagText(n, translation));
  };

  // set up translation editing
  $('[data-tag-translator-original] .retranslate').click(function (evt) { // has to be function for proper `this`
    evt.stopPropagation();
    evt.preventDefault();

    var $this = $(this),
        $parent = $($this.parents('[data-tag-translator-original]')[0]),
        tag = $parent.attr('data-tag-translator-original'),
        translation = $parent.attr('data-tag-translator-current') || tags.get(tag),
        $matching = $(`[data-tag-translator-original="${tag}"]`).contents().filter((i, n) => n.nodeType === Node.TEXT_NODE);

    var translated = window.prompt(
      `Translation for: ${tag}\n\nLeave as-is to cancel, clear text to remove translation`,
      translation);
    if (translated === translation) {
      console.debug(`Translation for "${tag}" unchanged`);
      return; // nothing to do
    } else if (!translated) {
      console.debug(`Deleting translation for "${tag}"`);
      tags.delete(tag);
      translated = tag;
    } else {
      console.debug(`Updating translation for "${tag}" from "${translation}" to "${translated}"`);
      tags.set(tag, translated);
    }

    $matching.each((i, n) => setTagText(n, translated));
  });
}

if (typeof jQuery === 'function') {
  console.debug(`Using local jQuery, version ${jQuery.fn.jquery}`);
  GM_main(jQuery);
} else {
  if (jQuery != null) {
    console.debug('No jQuery found');
  } else {
    console.debug(`jQuery is a ${typeof jQuery}`);
  }
  console.debug('Loading jQuery from Google CDN');
  add_jQuery(GM_main, '3.2.1');
}

function add_jQuery(callbackFn, jqVersion) {
  jqVersion      = jqVersion || "3.2.1";
  var D          = document,
      targ       = D.getElementsByTagName('head')[0] || D.body || D.documentElement,
      scriptNode = D.createElement('script');

  scriptNode.src = `//ajax.googleapis.com/ajax/libs/jquery/${jqVersion}/jquery.min.js`;
  scriptNode.addEventListener('load', function () {
    var scriptNode         = D.createElement('script');
    scriptNode.textContent = 'var gm_jQuery = jQuery.noConflict(true);\n(' + callbackFn.toString() + ')(gm_jQuery);';
    targ.appendChild(scriptNode);
  }, false);
  targ.appendChild(scriptNode);
}