Fanfiction.net Unwanted Result Filter

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

目前为 2015-11-29 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Fanfiction.net Unwanted Result Filter
  3. // @namespace http://www.ficfan.org/
  4. // @description Make up for how limited Fanfiction.net's result filtering is
  5. // compared to sites like Twisting the Hellmouth.
  6. // @copyright 2014-2015, Stephan Sokolow (http://www.ssokolow.com/)
  7. // @license MIT; http://www.opensource.org/licenses/mit-license.php
  8. // @version 0.1.2
  9. //
  10. // @require http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.11.2.min.js
  11. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  12. //
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_log
  17. // @noframes
  18. //
  19. // @compatible firefox Tested regularly under Greasemonkey.
  20. // @compatible chrome Tested occasionally under Tampermonkey.
  21. //
  22. // @match *://www.fanfiction.net/*
  23. // ==/UserScript==
  24. (function($) {
  25.  
  26. /* NOTE TO USERS OF VERSION 0.0.1:
  27. * Sorry for erasing any modifications you made to the filter settings.
  28. *
  29. * I didn't put much thought into version 0.0.1 and, as a result, it was
  30. * impossible to release an update without erasing modifications.
  31. *
  32. * I'm now storing settings via GM_setValue, so it should never happen again
  33. * and there's a proper configuration GUI available at
  34. * Greasemonkey > User Script Commands... > Configure Result Filter...
  35. */
  36.  
  37. // ----==== Configuration ====----
  38.  
  39. var fieldDefs = {
  40. 'filter_slash': {
  41. 'section': ['Slash Filter',
  42. 'Hide stories with slash based on warnings in story descriptions'],
  43. 'label': 'Enabled',
  44. 'labelPos': 'right',
  45. 'type': 'checkbox',
  46. 'default': true,
  47. },
  48. 'hide_slash': {
  49. 'label': 'No Placeholder',
  50. 'labelPos': 'right',
  51. 'type': 'checkbox',
  52. 'default': false,
  53. },
  54. 'slash_pat': {
  55. 'label': 'Slash is...',
  56. 'title': 'A regular expression which matches slash in descriptions',
  57. 'type': 'text',
  58. 'size': 255,
  59. 'default': ".*(slash|yaoi)([.,!: ]|$)"
  60. },
  61. 'not_slash_pat': {
  62. 'label': '...but not...',
  63. 'title': 'A regular expression which matches "not slash" and so on',
  64. 'type': 'text',
  65. 'size': 255,
  66. 'default': ".*(fem|not?[ ]+)(slash|yaoi)([.,!: ]|$)"
  67. },
  68. 'filter_cats': {
  69. 'section': ['Unwanted Category Filter',
  70. 'Hide unwanted fandoms in author pages and "All Crossovers" searches'],
  71. 'label': 'Enabled',
  72. 'labelPos': 'right',
  73. 'type': 'checkbox',
  74. 'default': true,
  75. },
  76. 'hide_cats': {
  77. 'label': 'No Placeholder',
  78. 'labelPos': 'right',
  79. 'type': 'checkbox',
  80. 'default': false,
  81. },
  82. 'unwanted_cats': {
  83. 'label': 'Unwanted categories (One per line, blanks lines and lines ' +
  84. 'beginning with # will be ignored):',
  85. 'type': 'textarea',
  86. 'size': 100,
  87. 'default': [
  88. "# --== Never wanted ==-- ",
  89. "Invader Zim",
  90. "Supernatural",
  91. "Twilight",
  92. "",
  93. "# --== Not right now ==-- ",
  94. "Harry Potter & Avengers",
  95. "Naruto",
  96. ].join("\n"),
  97. },
  98. 'unwanted_cats_escape': {
  99. 'label': 'Lines are literal strings (uncheck for regular expressions)',
  100. 'labelPos': 'right',
  101. 'title': 'NOTE: Leading/trailing whitespace is always ignored and ' +
  102. 'newlines always have OR behaviour.',
  103. 'type': 'checkbox',
  104. 'default': true,
  105. },
  106. 'unwanted_cats_commute': {
  107. 'label': 'Automatically generate "B & A" lines from "A & B" lines',
  108. 'labelPos': 'right',
  109. 'title': "WARNING: This will break regexes with & inside () or []",
  110. 'type': 'checkbox',
  111. 'default': true,
  112. }
  113. // TODO: Ideas for future filters:
  114. // - Genre (allowing more than one whitelist/blacklist entry)
  115. // TODO: Ideas for filters requiring an in-page UI:
  116. // - sort orders not already offered (eg. faves/follows on authors faves)
  117. // - min/max words as a freeform range entry
  118. // - already read (ideally, support hooking in an external API w/i caching)
  119. // - filter sets which can be toggled
  120. };
  121.  
  122. var frame = $('<div>').appendTo('body')[0];
  123. var config_params = {
  124. 'id': 'ffnet_result_filter',
  125. 'title': 'Fanfiction.net Unwanted Result Filter',
  126. 'fields': fieldDefs,
  127. 'css': ('#ffnet_result_filter ' + [
  128. // Match Fanfiction.net styling more closely
  129. ".section_header { background-color: #339 !important; }",
  130. ".section_desc { background-color: #f6f7ee !important; border: none; " +
  131. " padding: 1px; }",
  132. ".config_header { font-size: 16pt; }",
  133. ".field_label { font-size: 13px; font-weight: normal; }",
  134. // Layout adjustments for using a non-iframe container
  135. "#ffnet_result_filter_wrapper { flex: 1 1 auto; display: flex; " +
  136. " flex-direction: column; }\n" +
  137. ".force_display_flex { display: flex !important; }",
  138. "label { display: inline; }",
  139. ".section_header_holder { padding: 15px; margin-bottom: 2em; }",
  140. ".modal-footer { margin-top: -2em; }",
  141. ".saveclose_buttons.btn { margin: 0 0 0 5px !important; }",
  142. ".reset_holder { padding-right: 12px; }",
  143. "input[type=checkbox] { margin: 2px 4px 2px; }",
  144. // Form layout fixes
  145. "input[type=text], textarea " +
  146. " { width: calc(100% - 1.1em); resize: vertical; }\n" +
  147. "#ffnet_result_filter_filter_slash_var, " +
  148. "#ffnet_result_filter_filter_cats_var, " +
  149. "#ffnet_result_filter_hide_slash_var, " +
  150. "#ffnet_result_filter_hide_cats_var " +
  151. " { display: inline-block; margin-right: 1em !important; } " +
  152. "#ffnet_result_filter_field_unwanted_cats { min-height: 10em; }"
  153. ].join('\n#ffnet_result_filter ')),
  154. 'events': {
  155. 'open': function(doc) {
  156. // Reconcile GM_config and Bootstrap CSS
  157. $(this.frame).css({
  158. 'z-index': 1050,
  159. 'top': '50px',
  160. 'height': 'calc(99% - 100px)',
  161. 'flex-direction': 'column',
  162. 'border-color': '#d4d4d4',
  163. }).addClass('modal fade in force_display_flex');
  164. var header = $('.config_header').addClass('modal-header');
  165. $('<div>', {id: 'ffnrfilter_contentbox'})
  166. .insertAfter(header)
  167. .css('flex', '1')
  168. .append($('.section_header_holder').detach());
  169. $('#ffnet_result_filter_buttons_holder').addClass('modal-footer');
  170. $('button', this.frame).addClass('btn');
  171. $('.reset_holder').addClass('btn pull-left');
  172. $("<div>", {'id': 'gm_modal_back'}).click(function() {
  173. GM_config.close();
  174. }).addClass('modal-backdrop fade in').appendTo('body');
  175. },
  176. 'close': function() {
  177. $(this.frame).removeClass('force_display_flex');
  178. $('#gm_modal_back').remove();
  179. }
  180. },
  181. 'frame': frame,
  182. };
  183.  
  184. // ----==== Functions ====----
  185.  
  186. /// Escape string for literal meaning inside a regular expression
  187. /// Source: http://stackoverflow.com/a/3561711/435253
  188. var re_escape = function(s) {
  189. return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
  190. };
  191.  
  192. /// Use with Array.filter() to remove comments and blank lines
  193. var rows_filter = function(elem, idx, arr) {
  194. elem = elem.trim()
  195. if (elem.charAt(0) == '#' || !elem) {
  196. return false;
  197. }
  198. return true;
  199. };
  200.  
  201. /// Parse an array from a newline-delimited string containing # comments
  202. var parse_lines = function(s) {
  203. return s.split("\n").map(function(e, i, a) {
  204. return e.trim();
  205. }).filter(rows_filter);
  206. };
  207.  
  208. /// Parse a usable list of category patterns from a raw string
  209. var parse_cats_list = function(s, escape, commute) {
  210. // Parse the config
  211. var cats_raw = parse_lines(s);
  212. if (escape) { cats_raw = cats_raw.map(re_escape) }
  213. if (commute) {
  214. var cats_out = [];
  215. for (var i = 0, len = cats_raw.length; i < len; i++) {
  216. var line = cats_raw[i];
  217. cats_out.push(line);
  218. var parts = line.split(' & ');
  219. if (parts.length > 1) {
  220. cats_out.push(parts[1] + ' & ' + parts[0]);
  221. }
  222. }
  223. }
  224. return cats_out;
  225. }
  226.  
  227. /// Hide a story entry in a way it can be recovered
  228. var hide_entry = function(node) {
  229. $(node).addClass('filtered').hide();
  230. };
  231.  
  232. /// Hide a story entry and add a clickable placeholder
  233. var add_placeholder = function(node, reason) {
  234. var $story = $(node);
  235. var $placeholder = $story.clone();
  236.  
  237. $placeholder.html("Click to Show (" + reason + ")").css({
  238. minHeight: 0,
  239. maxHeight: '1em',
  240. color: 'lightgray',
  241. textAlign: 'center',
  242. cursor: 'pointer',
  243. }).click($story, function(e) {
  244. $(this).slideUp();
  245. e.data.css('min-height', 0).slideDown();
  246. }).addClass('filter_placeholder').insertBefore($story);
  247. hide_entry($story);
  248. };
  249.  
  250. /// Code which must be re-run to reapply filters after changing the config
  251. var initialize_filters_and_apply = function() {
  252. // Parse the config
  253. var bad_cats = parse_cats_list(
  254. GM_config.get('unwanted_cats'),
  255. GM_config.get('unwanted_cats_escape'),
  256. GM_config.get('unwanted_cats_commute')
  257. );
  258.  
  259. // Generate RegExp objects from the parsed config
  260. var slash_re = new RegExp(GM_config.get('slash_pat'), 'i');
  261. var not_slash_re = new RegExp(GM_config.get('not_slash_pat'), 'i');
  262. var cats_re = new RegExp(".*(" + bad_cats.join('|') + ').*Rated:.*');
  263.  
  264.  
  265. // Clean up after any previous run
  266. $(".filter_placeholder").remove();
  267. $(".filtered").show();
  268.  
  269. var results = $(".z-list");
  270. for (var i = 0, reslen = results.length; i < reslen; i++) {
  271. var story = results[i];
  272. var meta_row = $('.xgray', story).text();
  273. var description = $('.z-padtop', story).contents()[0].data;
  274.  
  275. // TODO: Redesign to collapse runs of hidden entries
  276. var reason = null;
  277. if (GM_config.get('filter_slash') && slash_re.test(description)
  278. && !not_slash_re.test(description)) {
  279. if (GM_config.get('hide_slash')) {
  280. hide_entry(story);
  281. } else {
  282. add_placeholder(story, "Slash");
  283. }
  284. } else if (GM_config.get('filter_cats')) {
  285. var matches = meta_row.match(cats_re);
  286. if (matches && matches.length > 0) {
  287. if (GM_config.get('hide_cats')) {
  288. hide_entry(story);
  289. } else {
  290. add_placeholder(story, matches[1]);
  291. }
  292. }
  293. }
  294. }
  295. };
  296.  
  297. // ----==== Begin Main Program ====----
  298.  
  299. // Stuff which either must or need only be called once
  300. GM_config.init(config_params);
  301. GM_config.onSave = initialize_filters_and_apply;
  302. GM_registerMenuCommand("Configure Result Filter...",
  303. function() { GM_config.open(); }, 'C');
  304.  
  305. // Clear out ad box which misaligns "Hidden" message if it's first result
  306. $($('#content_wrapper_inner ins.adsbygoogle').parent()[0]).remove();
  307.  
  308. initialize_filters_and_apply();
  309.  
  310. }).call(this, jQuery);