Fanfiction.net Unwanted Result Filter

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

目前为 2016-06-23 提交的版本,查看 最新版本

  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.9
  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 IGNORED_OPACITY = 0.3;
  40.  
  41. var fieldDefs = {
  42. 'filter_slash': {
  43. 'section': ['Slash Filter',
  44. 'Hide stories with slash based on warnings in story descriptions'],
  45. 'label': 'Enabled',
  46. 'labelPos': 'right',
  47. 'type': 'checkbox',
  48. 'default': true,
  49. },
  50. 'hide_slash': {
  51. 'label': 'No Placeholder',
  52. 'labelPos': 'right',
  53. 'type': 'checkbox',
  54. 'default': false,
  55. },
  56. 'slash_pat': {
  57. 'label': 'Slash is...',
  58. 'title': 'A regular expression which matches slash in descriptions',
  59. 'type': 'text',
  60. 'size': 255,
  61. 'default': ".*(slash|yaoi)([.,!:) -]|$)"
  62. },
  63. 'not_slash_pat': {
  64. 'label': '...but not...',
  65. 'title': 'A regular expression which matches "not slash" and so on',
  66. 'type': 'text',
  67. 'size': 255,
  68. 'default': ".*(fem|not?[ ]+)(slash|yaoi)([.,!: ]|$)"
  69. },
  70. 'filter_cats': {
  71. 'section': ['Unwanted Category Filter',
  72. 'Hide unwanted fandoms in author pages and "All Crossovers" searches'],
  73. 'label': 'Enabled',
  74. 'labelPos': 'right',
  75. 'type': 'checkbox',
  76. 'default': true,
  77. },
  78. 'hide_cats': {
  79. 'label': 'No Placeholder',
  80. 'labelPos': 'right',
  81. 'type': 'checkbox',
  82. 'default': false,
  83. },
  84. 'unwanted_cats': {
  85. 'label': 'Unwanted categories (One per line, blank lines and lines ' +
  86. 'beginning with # will be ignored):',
  87. 'type': 'textarea',
  88. 'size': 100,
  89. 'default': [
  90. "# --== Never wanted ==-- ",
  91. "Invader Zim",
  92. "Supernatural",
  93. "Twilight",
  94. "",
  95. "# --== Not right now ==-- ",
  96. "Harry Potter & Avengers",
  97. "Naruto",
  98. ].join("\n"),
  99. },
  100. 'unwanted_cats_escape': {
  101. 'label': 'Lines are literal strings (uncheck for regular expressions)',
  102. 'labelPos': 'right',
  103. 'title': 'NOTE: Leading/trailing whitespace is always ignored and ' +
  104. 'newlines always have OR behaviour.',
  105. 'type': 'checkbox',
  106. 'default': true,
  107. },
  108. 'unwanted_cats_commute': {
  109. 'label': 'Automatically generate "B & A" lines from "A & B" lines',
  110. 'labelPos': 'right',
  111. 'title': "WARNING: This will break regexes with & inside () or []",
  112. 'type': 'checkbox',
  113. 'default': true,
  114. },
  115. 'filter_manual': {
  116. 'section': ['Manual Filter',
  117. 'Hide an arbitrary list of story IDs'],
  118. 'label': 'Enabled',
  119. 'labelPos': 'right',
  120. 'type': 'checkbox',
  121. 'default': true,
  122. },
  123. 'hide_manual': {
  124. 'label': 'No Placeholder',
  125. 'labelPos': 'right',
  126. 'type': 'checkbox',
  127. 'default': false,
  128. },
  129. 'manual_reason': {
  130. 'label': 'Reason to display in placeholders:',
  131. 'type': 'text',
  132. 'size': 255,
  133. 'default': "Already Read"
  134. },
  135. 'unwanted_manual': {
  136. 'label': 'Unwanted story IDs (One per line, blank lines and lines ' +
  137. 'beginning with # will be ignored):',
  138. 'type': 'textarea',
  139. 'size': 100,
  140. 'default': "",
  141. },
  142. // TODO: Ideas for future filters:
  143. // - Genre (allowing more than one whitelist/blacklist entry)
  144. // TODO: Ideas for filters requiring an in-page UI:
  145. // - sort orders not already offered (eg. faves/follows on authors faves)
  146. // - min/max words as a freeform range entry
  147. // - filter sets which can be toggled
  148. };
  149.  
  150. var frame = $('<div>').appendTo('body')[0];
  151. var config_params = {
  152. 'id': 'ffnet_result_filter',
  153. 'title': 'Fanfiction.net Unwanted Result Filter',
  154. 'fields': fieldDefs,
  155. 'css': ('#ffnet_result_filter ' + [
  156. // Match Fanfiction.net styling more closely
  157. ".section_header { background-color: #339 !important; }",
  158. ".section_desc { background-color: #f6f7ee !important; border: none; " +
  159. " padding: 1px; }",
  160. ".config_header { font-size: 16pt; }",
  161. ".field_label { font-size: 13px; font-weight: normal; }",
  162. // Layout adjustments for using a non-iframe container
  163. "label { display: inline; }",
  164. ".section_header_holder { padding: 15px; margin-bottom: 2em; }",
  165. ".modal-footer { margin-top: -2em; }",
  166. ".saveclose_buttons.btn { margin: 0 0 0 5px !important; }",
  167. ".reset_holder { padding-right: 12px; }",
  168. "input[type=checkbox] { margin: 2px 4px 2px; }",
  169. // Make the panel sanely scrollable
  170. "#ffnet_result_filter_wrapper { " +
  171. " display: flex; flex-direction: column; height: 100%; }\n" +
  172. "#ffnrfilter_contentbox { " +
  173. " flex: 1 1 auto; min-height: 0px; overflow-y: scroll; }\n" +
  174. // Form layout fixes
  175. "input[type=text], textarea " +
  176. " { width: calc(100% - 1.1em); resize: vertical; }\n" +
  177. "#ffnet_result_filter_filter_manual_var, " +
  178. "#ffnet_result_filter_filter_slash_var, " +
  179. "#ffnet_result_filter_filter_cats_var, " +
  180. "#ffnet_result_filter_hide_manual_var, " +
  181. "#ffnet_result_filter_hide_slash_var, " +
  182. "#ffnet_result_filter_hide_cats_var, " +
  183. "#ffnet_result_filter_slash_pat_var, " +
  184. "#ffnet_result_filter_not_slash_pat_var " +
  185. " { display: inline-block; margin-right: 1em !important; } " +
  186. "#ffnet_result_filter_field_slash_pat, " +
  187. "#ffnet_result_filter_field_not_slash_pat " +
  188. " { max-width: 20em; } " +
  189. "#ffnet_result_filter_field_unwanted_cats { min-height: 10em; }"
  190. ].join('\n#ffnet_result_filter ')),
  191. 'events': {
  192. 'open': function(doc) {
  193. // Reconcile GM_config and Bootstrap CSS
  194. $(this.frame).css({
  195. 'z-index': 1050,
  196. 'top': '50px',
  197. 'height': 'calc(99% - 100px)',
  198. 'border-color': '#d4d4d4',
  199. }).addClass('modal fade in');
  200. $('button', this.frame).addClass('btn');
  201. $('.reset_holder').addClass('btn pull-left');
  202. $('#ffnet_result_filter_buttons_holder').addClass('modal-footer');
  203. $('#ffnet_result_filter_slash_pat_var').before("<br>");
  204. var header = $('.config_header').addClass('modal-header');
  205. // Move the content into a wrapper DIV for styling
  206. $('<div>', {id: 'ffnrfilter_contentbox'})
  207. .insertAfter(header)
  208. .append($('.section_header_holder').detach());
  209. // Add a clickable backdrop for the panel
  210. $("<div>", {'id': 'gm_modal_back'}).click(function() {
  211. GM_config.close();
  212. }).addClass('modal-backdrop fade in').appendTo('body');
  213. },
  214. 'close': function() {
  215. // Plumb the added backdrop into the close handler
  216. $('#gm_modal_back').remove();
  217. }
  218. },
  219. 'frame': frame,
  220. };
  221.  
  222. // ----==== Functions ====----
  223.  
  224. /// Escape string for literal meaning inside a regular expression
  225. /// Source: http://stackoverflow.com/a/3561711/435253
  226. var re_escape = function(s) {
  227. return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
  228. };
  229.  
  230. /// Use with Array.filter() to remove comments and blank lines
  231. var rows_filter = function(elem, idx, arr) {
  232. elem = elem.trim()
  233. if (elem.charAt(0) == '#' || !elem) {
  234. return false;
  235. }
  236. return true;
  237. };
  238.  
  239. /// Parse an array from a newline-delimited string containing # comments
  240. var parse_lines = function(s) {
  241. return s.split("\n").map(function(e, i, a) {
  242. return e.trim();
  243. }).filter(rows_filter);
  244. };
  245.  
  246. /// Parse a usable list of category patterns from a raw string
  247. var parse_cats_list = function(s, escape, commute) {
  248. // Parse the config
  249. var cats_raw = parse_lines(s);
  250. if (escape) { cats_raw = cats_raw.map(re_escape) }
  251. if (commute) {
  252. var cats_out = [];
  253. for (var i = 0, len = cats_raw.length; i < len; i++) {
  254. var line = cats_raw[i];
  255. cats_out.push(line);
  256. var parts = line.split(' & ');
  257. if (parts.length > 1) {
  258. cats_out.push(parts[1] + ' & ' + parts[0]);
  259. }
  260. }
  261. }
  262. return cats_out;
  263. };
  264.  
  265. // Parse a usable list of story IDs from a raw string
  266. var parse_id_list = function(s) {
  267. return parse_lines(s).filter(rows_filter);
  268. };
  269.  
  270. /// Hide a story entry in a way it can be recovered
  271. var hide_entry = function(node) {
  272. $(node).addClass('filtered').hide();
  273. };
  274.  
  275. /// Hide a story entry and add a clickable placeholder
  276. var add_placeholder = function(node, reason) {
  277. var $story = $(node);
  278. var $placeholder = $story.clone();
  279.  
  280. $placeholder.html("Click to Show (" + reason + ")").css({
  281. minHeight: 0,
  282. maxHeight: '1em',
  283. color: 'lightgray',
  284. textAlign: 'center',
  285. cursor: 'pointer',
  286. }).click($story, function(e) {
  287. $(this).slideUp();
  288. e.data.css('min-height', 0).slideDown();
  289. }).addClass('filter_placeholder').insertBefore($story);
  290. hide_entry($story);
  291. };
  292.  
  293. /// Code which must be re-run to reapply filters after changing the config
  294. var initialize_filters_and_apply = function() {
  295. // Parse the config
  296. var bad_cats = parse_cats_list(
  297. GM_config.get('unwanted_cats'),
  298. GM_config.get('unwanted_cats_escape'),
  299. GM_config.get('unwanted_cats_commute')
  300. );
  301.  
  302. if (GM_config.get('filter_manual')) {
  303. var manual_story_ids = parse_id_list(GM_config.get('unwanted_manual'));
  304. } else {
  305. var manual_story_ids = [];
  306. }
  307. var story_link_re = new RegExp("\/s\/(\\d+)\/");
  308.  
  309. // Generate RegExp objects from the parsed config
  310. var slash_re = new RegExp(GM_config.get('slash_pat'), 'i');
  311. var not_slash_re = new RegExp(GM_config.get('not_slash_pat'), 'i');
  312. var cats_re = new RegExp("(?:^|- )(.*(" + bad_cats.join('|') + ').*) - Rated:.*');
  313. var cat_link_re = new RegExp(bad_cats.join('|'));
  314.  
  315. // Clean up after any previous run
  316. $(".filter_placeholder").remove();
  317. $(".filtered").show();
  318. $(".ignored").css('opacity', 1).show().removeClass('ignored');
  319.  
  320. var results = $(".z-list");
  321. for (var i = 0, reslen = results.length; i < reslen; i++) {
  322. var story = results[i];
  323. var meta_row = $('.xgray', story).text();
  324. var description = $('.z-padtop', story).contents()[0].data;
  325.  
  326. // TODO: Redesign to collapse runs of hidden entries
  327. var reason = null;
  328. if (GM_config.get('filter_slash') && slash_re.test(description)
  329. && !not_slash_re.test(description)) {
  330. if (GM_config.get('hide_slash')) {
  331. hide_entry(story);
  332. } else {
  333. add_placeholder(story, "Slash");
  334. }
  335. continue;
  336. }
  337.  
  338. if (GM_config.get('filter_cats')) {
  339. var matches = meta_row.match(cats_re);
  340. if (matches && matches.length > 0) {
  341. if (GM_config.get('hide_cats')) {
  342. hide_entry(story);
  343. } else {
  344. add_placeholder(story, matches[1]);
  345. }
  346. continue;
  347. }
  348. }
  349.  
  350. if (manual_story_ids.length > 0) {
  351. var story_url = $('a.stitle', story).attr('href');
  352. var story_id = story_link_re.exec(story_url)[1];
  353. if (story_id && manual_story_ids.indexOf(story_id) != -1) {
  354. if (GM_config.get('hide_manual')) {
  355. hide_entry(story);
  356. } else {
  357. add_placeholder(story, GM_config.get('manual_reason'));
  358. }
  359. }
  360. }
  361. }
  362.  
  363. if (GM_config.get('filter_cats')) {
  364. var hide_cats = GM_config.get('hide_cats');
  365. $('#list_output a').each(function() {
  366. $this = $(this);
  367. // "Browse" wraps the title and entry count in a <div> that lets us
  368. // easily fade both while "Community" puts them directly in the <td>
  369. // which defines the column.
  370. var $parent = $this.parent('div');
  371. if ($parent.length > 0) { $this = $parent; }
  372.  
  373. if (cat_link_re.test($this.text())) {
  374. $this.addClass('ignored');
  375. if (hide_cats) {
  376. $this.hide();
  377. } else {
  378. $this.css('opacity', IGNORED_OPACITY);
  379. }
  380. }
  381. });
  382. }
  383. };
  384.  
  385. // ----==== Begin Main Program ====----
  386.  
  387. // Stuff which either must or need only be called once
  388. GM_config.init(config_params);
  389. GM_config.onSave = initialize_filters_and_apply;
  390. GM_registerMenuCommand("Configure Result Filter...",
  391. function() { GM_config.open(); }, 'C');
  392.  
  393. // Clear out ad box which misaligns "Hidden" message if it's first result
  394. $($('#content_wrapper_inner ins.adsbygoogle').parent()[0]).remove();
  395.  
  396. initialize_filters_and_apply();
  397.  
  398. }).call(this, jQuery);