Fanfiction.net Unwanted Result Filter

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

当前为 2018-09-08 提交的版本,查看 最新版本

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