Fanfiction.net Unwanted Result Filter

Make up for how limited Fanfiction.net's result filtering is

目前為 2016-06-25 提交的版本,檢視 最新版本

// ==UserScript==
// @name Fanfiction.net Unwanted Result Filter
// @namespace   http://www.ficfan.org/
// @description Make up for how limited Fanfiction.net's result filtering is
//              compared to sites like Twisting the Hellmouth.
// @copyright   2014-2015, Stephan Sokolow (http://www.ssokolow.com/)
// @license     MIT; http://www.opensource.org/licenses/mit-license.php
// @version     0.1.9.1
//
// @require     http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.11.2.min.js
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
//
// @grant       GM_registerMenuCommand
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_log
// @noframes
//
// @compatible  firefox Tested regularly under Greasemonkey.
// @compatible  chrome  Tested occasionally under Tampermonkey.
//
// @match       *://www.fanfiction.net/*
// ==/UserScript==
(function($) {

  /* NOTE TO USERS OF VERSION 0.0.1:
   * Sorry for erasing any modifications you made to the filter settings.
   *
   * I didn't put much thought into version 0.0.1 and, as a result, it was
   * impossible to release an update without erasing modifications.
   *
   * I'm now storing settings via GM_setValue, so it should never happen again
   * and there's a proper configuration GUI available at
   *   Greasemonkey > User Script Commands... > Configure Result Filter...
   */

  // ----==== Configuration ====----

  var IGNORED_OPACITY = 0.3;

  var fieldDefs = {
    'filter_slash': {
      'section': ['Slash Filter',
        'Hide stories with slash based on warnings in story descriptions'],
        'label': 'Enabled',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': true,
    },
    'hide_slash': {
        'label': 'No Placeholder',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': false,
    },
    'slash_pat': {
      'label': 'Slash is...',
      'title': 'A regular expression which matches slash in descriptions',
      'type': 'text',
      'size': 255,
      'default': ".*(slash|yaoi)([.,!:) -]|$)"
    },
    'not_slash_pat': {
      'label': '...but not...',
      'title': 'A regular expression which matches "not slash" and so on',
      'type': 'text',
      'size': 255,
      'default': ".*(fem|not?[ ]+)(slash|yaoi)([.,!: ]|$)"
    },
    'filter_cats': {
      'section': ['Unwanted Category Filter',
        'Hide unwanted fandoms in author pages and "All Crossovers" searches'],
        'label': 'Enabled',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': true,
    },
    'hide_cats': {
        'label': 'No Placeholder',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': false,
    },
    'unwanted_cats': {
      'label': 'Unwanted categories (One per line, blank lines and lines ' +
        'beginning with # will be ignored):',
        'type': 'textarea',
        'size': 100,
        'default': [
          "# --== Never wanted ==-- ",
          "Invader Zim",
          "Supernatural",
          "Twilight",
          "",
          "# --== Not right now ==-- ",
          "Harry Potter & Avengers",
          "Naruto",
        ].join("\n"),
    },
    'unwanted_cats_escape': {
      'label': 'Lines are literal strings (uncheck for regular expressions)',
      'labelPos': 'right',
      'title': 'NOTE: Leading/trailing whitespace is always ignored and ' +
               'newlines always have OR behaviour.',
      'type': 'checkbox',
      'default': true,
    },
    'unwanted_cats_commute': {
      'label': 'Automatically generate "B & A" lines from "A & B" lines',
      'labelPos': 'right',
      'title': "WARNING: This will break regexes with & inside () or []",
      'type': 'checkbox',
      'default': true,
    },
    'filter_manual': {
      'section': ['Manual Filter',
        'Hide an arbitrary list of story IDs'],
        'label': 'Enabled',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': true,
    },
    'hide_manual': {
        'label': 'No Placeholder',
        'labelPos': 'right',
        'type': 'checkbox',
        'default': false,
    },
    'manual_reason': {
      'label': 'Reason to display in placeholders:',
      'type': 'text',
      'size': 255,
      'default': "Already Read"
    },
    'unwanted_manual': {
      'label': 'Unwanted story IDs (One per line, blank lines and lines ' +
        'beginning with # will be ignored):',
        'type': 'textarea',
        'size': 100,
        'default': "",
    },
    // TODO: Ideas for future filters:
    // - Genre (allowing more than one whitelist/blacklist entry)
    // TODO: Ideas for filters requiring an in-page UI:
    // - sort orders not already offered (eg. faves/follows on authors faves)
    // - min/max words as a freeform range entry
    // - filter sets which can be toggled
  };

  var frame = $('<div>').appendTo('body')[0];
  var config_params = {
    'id': 'ffnet_result_filter',
    'title': 'Fanfiction.net Unwanted Result Filter',
    'fields': fieldDefs,
    'css': ('#ffnet_result_filter ' + [
      // Match Fanfiction.net styling more closely
      ".section_header { background-color: #339 !important; }",
      ".section_desc { background-color: #f6f7ee !important; border: none; " +
      "  padding: 1px; }",
      ".config_header { font-size: 16pt; }",
      ".field_label { font-size: 13px; font-weight: normal; }",
      // Layout adjustments for using a non-iframe container
      "label { display: inline; }",
      ".section_header_holder { padding: 15px; margin-bottom: 2em; }",
      ".modal-footer { margin-top: -2em; }",
      ".saveclose_buttons.btn { margin: 0 0 0 5px !important; }",
      ".reset_holder { padding-right: 12px; }",
      "input[type=checkbox] { margin: 2px 4px 2px; }",
      // Make the panel sanely scrollable
      "#ffnet_result_filter_wrapper { " +
        " display: flex; flex-direction: column; height: 100%; }\n" +
      "#ffnrfilter_contentbox { " +
        " flex: 1 1 auto; min-height: 0px; overflow-y: scroll; }\n" +
      // Form layout fixes
      "input[type=text], textarea " +
      "  { width: calc(100% - 1.1em); resize: vertical; }\n" +
      "#ffnet_result_filter_filter_manual_var, " +
      "#ffnet_result_filter_filter_slash_var, " +
      "#ffnet_result_filter_filter_cats_var, " +
      "#ffnet_result_filter_hide_manual_var, " +
      "#ffnet_result_filter_hide_slash_var, " +
      "#ffnet_result_filter_hide_cats_var, " +
      "#ffnet_result_filter_slash_pat_var, " +
      "#ffnet_result_filter_not_slash_pat_var " +
      "    { display: inline-block; margin-right: 1em !important; } " +
      "#ffnet_result_filter_field_slash_pat, " +
      "#ffnet_result_filter_field_not_slash_pat " +
      "    { max-width: 20em; } " +
      "#ffnet_result_filter_field_unwanted_cats { min-height: 10em; }"
      ].join('\n#ffnet_result_filter ')),
    'events': {
      'open': function(doc) {
        // Reconcile GM_config and Bootstrap CSS
        $(this.frame).css({
          'z-index': 1050,
          'top': '50px',
          'height': 'calc(99% - 100px)',
          'border-color': '#d4d4d4',
        }).addClass('modal fade in');
        $('button', this.frame).addClass('btn');
        $('.reset_holder').addClass('btn pull-left');
        $('#ffnet_result_filter_buttons_holder').addClass('modal-footer');
        $('#ffnet_result_filter_slash_pat_var').before("<br>");
        var header = $('.config_header').addClass('modal-header');
        // Move the content into a wrapper DIV for styling
        $('<div>', {id: 'ffnrfilter_contentbox'})
          .insertAfter(header)
          .append($('.section_header_holder').detach());
        // Add a clickable backdrop for the panel
        $("<div>", {'id': 'gm_modal_back'}).click(function() {
          GM_config.close();
        }).addClass('modal-backdrop fade in').appendTo('body');
      },
      'close': function() {
        // Plumb the added backdrop into the close handler
        $('#gm_modal_back').remove();
      }
    },
    'frame': frame,
  };

  // ----==== Functions ====----

  /// Escape string for literal meaning inside a regular expression
  /// Source: http://stackoverflow.com/a/3561711/435253
  var re_escape = function(s) {
    return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
  };

  /// Use with Array.filter() to remove comments and blank lines
  var rows_filter = function(elem, idx, arr) {
    elem = elem.trim()
    if (elem.charAt(0) == '#' || !elem) {
      return false;
    }
    return true;
  };

  /// Parse an array from a newline-delimited string containing # comments
  var parse_lines = function(s) {
    return s.split("\n").map(function(e, i, a) {
      return e.trim();
    }).filter(rows_filter);
  };

  /// Parse a usable list of category patterns from a raw string
  var parse_cats_list = function(s, escape, commute) {
    // Parse the config
    var cats_raw = parse_lines(s);
    if (escape) { cats_raw = cats_raw.map(re_escape) }
    if (commute) {
      var cats_out = [];
      for (var i = 0, len = cats_raw.length; i < len; i++) {
        var line = cats_raw[i];
        cats_out.push(line);
        var parts = line.split(' & ');
        if (parts.length > 1) {
          cats_out.push(parts[1] + ' & ' + parts[0]);
        }
      }
    }
    return cats_out;
  };

  // Parse a usable list of story IDs from a raw string
  var parse_id_list = function(s) {
    return parse_lines(s).filter(rows_filter);
  };

  /// Hide a story entry in a way it can be recovered
  var hide_entry = function(node) {
    $(node).addClass('filtered').hide();
  };

  /// Hide a story entry and add a clickable placeholder
  var add_placeholder = function(node, reason) {
    var $story = $(node);
    var $placeholder = $story.clone();

    $placeholder.html("Click to Show (" + reason + ")").css({
      minHeight: 0,
      maxHeight: '1em',
      color: 'lightgray',
      textAlign: 'center',
      cursor: 'pointer',
    }).click($story, function(e) {
      $(this).slideUp();
      e.data.css('min-height', 0).slideDown();
    }).addClass('filter_placeholder').insertBefore($story);
    hide_entry($story);
  };

  /// Code which must be re-run to reapply filters after changing the config
  var initialize_filters_and_apply = function() {
    // Parse the config
    var bad_cats = parse_cats_list(
      GM_config.get('unwanted_cats'),
      GM_config.get('unwanted_cats_escape'),
      GM_config.get('unwanted_cats_commute')
    );

    if (GM_config.get('filter_manual')) {
      var manual_story_ids = parse_id_list(GM_config.get('unwanted_manual'));
    } else {
      var manual_story_ids = [];
    }
    var story_link_re = new RegExp("\/s\/(\\d+)\/");

    // Generate RegExp objects from the parsed config
    var slash_re = new RegExp(GM_config.get('slash_pat'), 'i');
    var not_slash_re = new RegExp(GM_config.get('not_slash_pat'), 'i');
    var cats_re = new RegExp("(?:^|- )(.*(" + bad_cats.join('|') + ').*) - Rated:.*');
    var cat_link_re = new RegExp(bad_cats.join('|'));

    // Clean up after any previous run
    $(".filter_placeholder").remove();
    $(".filtered").show();
    $(".ignored").css('opacity', 1).show().removeClass('ignored');

    var results = $(".z-list");
    for (var i = 0, reslen = results.length; i < reslen; i++) {
      var story = results[i];
      var meta_row = $('.xgray', story).text();
      var description = $('.z-padtop', story).contents()[0].data;

      // TODO: Redesign to collapse runs of hidden entries
      // TODO: Show a comma-separated list of reasons something was hidden
      var reason = null;
      if (manual_story_ids.length > 0) {
        var story_url = $('a.stitle', story).attr('href');
        var story_id = story_link_re.exec(story_url)[1];
        if (story_id && manual_story_ids.indexOf(story_id) != -1) {
          if (GM_config.get('hide_manual')) {
            hide_entry(story);
          } else {
            add_placeholder(story, GM_config.get('manual_reason'));
          }
          continue;
        }
      }

      if (GM_config.get('filter_slash') && slash_re.test(description)
          && !not_slash_re.test(description)) {
        if (GM_config.get('hide_slash')) {
          hide_entry(story);
        } else {
          add_placeholder(story, "Slash");
        }
        continue;
      }

      if (GM_config.get('filter_cats')) {
        var matches = meta_row.match(cats_re);
        if (matches && matches.length > 0) {
          if (GM_config.get('hide_cats')) {
            hide_entry(story);
          } else {
            add_placeholder(story, matches[1]);
          }
          continue;
        }
      }
    }

    if (GM_config.get('filter_cats')) {
      var hide_cats = GM_config.get('hide_cats');
      $('#list_output a').each(function() {
        $this = $(this);
        // "Browse" wraps the title and entry count in a <div> that lets us
        // easily fade both while "Community" puts them directly in the <td>
        // which defines the column.
        var $parent = $this.parent('div');
        if ($parent.length > 0) { $this = $parent; }

        if (cat_link_re.test($this.text())) {
          $this.addClass('ignored');
          if (hide_cats) {
            $this.hide();
          } else {
            $this.css('opacity', IGNORED_OPACITY);
          }
        }
      });
    }
  };

  // ----==== Begin Main Program ====----

  // Stuff which either must or need only be called once
  GM_config.init(config_params);
  GM_config.onSave = initialize_filters_and_apply;
  GM_registerMenuCommand("Configure Result Filter...",
                         function() { GM_config.open(); }, 'C');

  // Clear out ad box which misaligns "Hidden" message if it's first result
  $($('#content_wrapper_inner ins.adsbygoogle').parent()[0]).remove();

  initialize_filters_and_apply();

}).call(this, jQuery);