on.js

A micro-library for DRY:ing up the boring boilerplate of user scripts

Dette scriptet burde ikke installeres direkte. Det er et bibliotek for andre script å inkludere med det nye metadirektivet // @require https://update.greatest.deepsurf.us/scripts/480/1446/onjs.js

  1. // ==UserScript==
  2. // @name on.js
  3. // @namespace https://github.com/johan
  4. // @description A micro-library for DRY:ing up the boring boilerplate of user scripts
  5. // @version 1.0
  6. // @exclude *
  7. // ==/UserScript==
  8.  
  9. /* coffee-script example usage - at https://github.com/johan/dotjs/commits/johan
  10.  
  11. on path_re: ['^/([^/]+)/([^/]+)(/?.*)', 'user', 'repo', 'rest']
  12. query: true
  13. dom:
  14. keyboard: 'css .keyboard-shortcuts'
  15. branches: 'css+ .js-filter-branches h4 a'
  16. dates: 'css* .commit-group-heading'
  17. tracker: 'css? #gauges-tracker[defer]'
  18. johan_ci: 'xpath* //li[contains(@class,"commit")][.//a[.="johan"]]'
  19. ready: (path, query, dom) ->
  20.  
  21. ...would make something like this call, as the path regexp matched, and there
  22. were DOM matches for the two mandatory "keyboard" and "branches" selectors:
  23.  
  24. ready( { user: 'johan', repo: 'dotjs', rest: '/commits/johan' }
  25. , {} // would contain all query args (if any were present)
  26. , { keyboard: Node<a href="#keyboard_shortcuts_pane">
  27. , branches: [ Node<a href="/johan/dotjs/commits/coffee">
  28. , Node<a href="/johan/dotjs/commits/dirs">
  29. , Node<a href="/johan/dotjs/commits/gh-pages">
  30. , Node<a href="/johan/dotjs/commits/johan">
  31. , Node<a href="/johan/dotjs/commits/jquery-1.8.2">
  32. , Node<a href="/johan/dotjs/commits/master">
  33. ]
  34. , dates: [ Node<h3 class="commit-group-heading">Oct 07, 2012</h3>
  35. , Node<h3 class="commit-group-heading">Aug 29, 2012</h3>
  36. , ...
  37. ]
  38. , tracker: null
  39. , johan_ci: [ Node<li class="commit">, ... ]
  40. }
  41. )
  42.  
  43. A selector returns an array of matches prefixed for "css*" and "css+" (ditto
  44. xpath), and a single result if it is prefixed "css" or "css?":
  45.  
  46. If your script should only run on pages with a particular DOM node (or set of
  47. nodes), use the 'css' or 'css+' (ditto xpath) forms - and your callback won't
  48. get fired on pages that lack them. The 'css?' and 'css*' forms would run your
  49. callback but pass null or [] respectively, on not finding such nodes. You may
  50. recognize the semantics of x, x?, x* and x+ from regular expressions.
  51.  
  52. (see http://goo.gl/ejtMD for a more thorough discussion of something similar)
  53.  
  54. The dom property is recursively defined so you can make nested structures.
  55. If you want a property that itself is an object full of matched things, pass
  56. an object of sub-dom-spec:s, instead of a string selector:
  57.  
  58. on dom:
  59. meta:
  60. base: 'xpath? /head/base
  61. title: 'xpath string(/head/title)'
  62. commits: 'css* li.commit'
  63. ready: (dom) ->
  64.  
  65. You can also deconstruct repeated templated sections of a page into subarrays
  66. scraped as per your specs, by picking a context node for a dom spec. This is
  67. done by passing a two-element array: a selector resolving what node/nodes you
  68. look at and a dom spec describing how you want it/them deconstructed for you:
  69.  
  70. on dom:
  71. meta:
  72. [ 'xpath /head',
  73. base: 'xpath? base
  74. title: 'xpath string(title)'
  75. ]
  76. commits:
  77. [ 'css* li.commit',
  78. avatar_url: ['css img.gravatar', 'xpath string(@src)']
  79. author_name: 'xpath string(.//*[@class="author-name"])'
  80. ]
  81. ready: (dom) ->
  82.  
  83. The mandatory/optional selector rules defined above behave as you'd expect as
  84. used for context selectors too: a mandatory node or array of nodes will limit
  85. what pages your script gets called on to those that match it, so your code is
  86. free to assume it will always be there when it runs. An optional context node
  87. that is not found will instead result in that part of your DOM being null, or
  88. an empty array, in the case of a * selector.
  89.  
  90. Finally, there is the xpath! keyword, which is similar to xpath, but it also
  91. mandates that whatever is returned is truthy. This is useful when you use the
  92. xpath functions returning strings, numbers and of course booleans, to assert
  93. things about the pages you want to run on, like 'xpath! count(//img) = 0', if
  94. you never want the script to run on pages with inline images, say.
  95.  
  96. After you have called on(), you may call on.dom to do page scraping later on,
  97. returning whatever matched your selector(s) passed. Mandatory selectors which
  98. failed to match at this point will return undefined, optional selectors null:
  99.  
  100. on.dom('xpath //a[@id]') => undefined or <a id="...">
  101. on.dom('xpath? //a[@id]') => null or <a id="...">
  102. on.dom('xpath+ //a[@id]') => undefined or [<a id="...">, <a id="...">, ...]
  103. on.dom('xpath* //a[@id]') => [] or [<a id="...">, <a id="...">, ...]
  104.  
  105. A readable way to detect a failed mandatory match is on.dom(...) === on.FAIL;
  106.  
  107. Github pjax hook: for re-running a script's on() block for every pjax request
  108. to a site - add a pushstate hook as per http://goo.gl/LNSv1 -- and be sure to
  109. make your script reentrant, so that it won't try to process the same elements
  110. again, if they are still sitting around in the page (see ':not([augmented])')
  111.  
  112. */
  113.  
  114. function on(opts, plugins) {
  115. var Object_toString = Object.prototype.toString
  116. , Array_slice = Array.prototype.slice
  117. , FAIL = 'dom' in on ? undefined : (function() {
  118. var tests =
  119. { path_re: { fn: test_regexp }
  120. , query: { fn: test_query }
  121. , dom: { fn: test_dom
  122. , my: { 'css*': $c
  123. , 'css+': one_or_more($c)
  124. , 'css?': $C
  125. , 'css': not_null($C)
  126. , 'xpath*': $x
  127. , 'xpath+': one_or_more($x)
  128. , 'xpath?': $X
  129. , 'xpath!': truthy($x)
  130. , 'xpath': not_null($X)
  131. }
  132. }
  133. , inject: { fn: inject }
  134. }
  135. , name, test, me, my, mine
  136. ;
  137.  
  138. for (name in tests) {
  139. test = tests[name];
  140. me = test.fn;
  141. if ((my = test.my))
  142. for (mine in my)
  143. me[mine] = my[mine];
  144. on[name] = me;
  145. }
  146. })()
  147.  
  148. , input = [] // args for the callback(s?) the script wants to run
  149. , rules = Object.create(opts) // wraps opts in a pokeable inherit layer
  150. , debug = get('debug')
  151. , script = get('name')
  152. , ready = get('ready')
  153. , load = get('load')
  154. , pushState = get('pushstate')
  155. , pjax_event = get('pjaxevent')
  156. , name, rule, test, result, retry, plugin
  157. ;
  158.  
  159. if (typeof ready !== 'function' &&
  160. typeof load !== 'function' &&
  161. typeof pushState !== 'function') {
  162. alert('no on function');
  163. throw new Error('on() needs at least a "ready" or "load" function!');
  164. }
  165.  
  166. if (plugins)
  167. for (name in plugins)
  168. if ((rule = plugins[name]) && (test = on[name]))
  169. for (plugin in rule)
  170. if (!(test[plugin])) {
  171. on._parse_dom_rule = null;
  172. test[plugin] = rule[plugin];
  173. }
  174.  
  175. if (pushState && history.pushState &&
  176. (on.pushState = on.pushState || []).indexOf(opts) === -1) {
  177. on.pushState.push(opts); // make sure we don't re-register after navigation
  178. initPushState(pushState, pjax_event);
  179. }
  180.  
  181. try {
  182. for (name in rules) {
  183. rule = rules[name];
  184. if (rule === undefined) continue; // was some callback or other non-rule
  185. test = on[name];
  186. if (!test) throw new Error('did not grok rule "'+ name +'"!');
  187. result = test(rule);
  188. if (result === FAIL) return false; // the page doesn't satisfy all rules
  189. input.push(result);
  190. }
  191. }
  192. catch(e) {
  193. if (debug) console.warn("on(debug): we didn't run because " + e.message);
  194. return false;
  195. }
  196.  
  197. if (ready) {
  198. ready.apply(opts, input.concat());
  199. }
  200. if (load) window.addEventListener('load', function() {
  201. load.apply(opts, input.concat());
  202. });
  203. return input.concat(opts);
  204.  
  205. function get(x) { rules[x] = undefined; return opts[x]; }
  206. function isArray(x) { return Object_toString.call(x) === '[object Array]'; }
  207. function isObject(x) { return Object_toString.call(x) === '[object Object]'; }
  208. function array(a) { return Array_slice.call(a, 0); } // array:ish => Array
  209. function arrayify(x) { return isArray(x) ? x : [x]; } // non-array? => Array
  210. function inject(fn, args) {
  211. var script = document.createElement('script')
  212. , parent = document.documentElement;
  213. args = JSON.stringify(args || []).slice(1, -1);
  214. script.textContent = '('+ fn +')('+ args +');';
  215. parent.appendChild(script);
  216. parent.removeChild(script);
  217. }
  218.  
  219. function initPushState(callback, pjax_event) {
  220. if (!history.pushState.armed) {
  221. inject(function(pjax_event) {
  222. function reportBack() {
  223. var e = document.createEvent('Events');
  224. e.initEvent('history.pushState', !'bubbles', !'cancelable');
  225. document.dispatchEvent(e);
  226. }
  227. var pushState = history.pushState;
  228. history.pushState = function on_pushState() {
  229. if (pjax_event && window.$ && $.pjax)
  230. $(document).one(pjax_event, reportBack);
  231. else
  232. setTimeout(reportBack, 0);
  233. return pushState.apply(this, arguments);
  234. };
  235. }, [pjax_event]);
  236. history.pushState.armed = pjax_event;
  237. }
  238.  
  239. retry = function after_pushState() {
  240. rules = Object.create(opts);
  241. rules.load = rules.pushstate = undefined;
  242. rules.ready = callback;
  243. on(rules);
  244. };
  245.  
  246. document.addEventListener('history.pushState', function() {
  247. if (debug) console.log('on.pushstate', location.pathname);
  248. retry();
  249. }, false);
  250. }
  251.  
  252. function test_query(spec) {
  253. var q = unparam(this === on || this === window ? location.search : this);
  254. if (spec === true || spec == null) return q; // decode the query for me!
  255. throw new Error('bad query type '+ (typeof spec) +': '+ spec);
  256. }
  257.  
  258. function unparam(query) {
  259. var data = {};
  260. (query || '').replace(/\+/g, '%20').split('&').forEach(function(kv) {
  261. kv = /^\??([^=&]*)(?:=(.*))?/.exec(kv);
  262. if (!kv) return;
  263. var prop, val, k = kv[1], v = kv[2], e, m;
  264. try { prop = decodeURIComponent(k); } catch (e) { prop = unescape(k); }
  265. if ((val = v) != null)
  266. try { val = decodeURIComponent(v); } catch (e) { val = unescape(v); }
  267. data[prop] = val;
  268. });
  269. return data;
  270. }
  271.  
  272. function test_regexp(spec) {
  273. if (!isArray(spec)) spec = arrayify(spec);
  274. var re = spec.shift();
  275. if (typeof re === 'string') re = new RegExp(re);
  276. if (!(re instanceof RegExp))
  277. throw new Error((typeof re) +' was not a regexp: '+ re);
  278.  
  279. var ok = re.exec(this === on || this === window ? location.pathname : this);
  280. if (ok === null) return FAIL;
  281. if (!spec.length) return ok;
  282. var named = {};
  283. ok.shift(); // drop matching-whole-regexp part
  284. while (spec.length) named[spec.shift()] = ok.shift();
  285. return named;
  286. }
  287.  
  288. function truthy(fn) { return function(s) {
  289. var x = fn.apply(this, arguments); return x || FAIL;
  290. }; }
  291.  
  292. function not_null(fn) { return function(s) {
  293. var x = fn.apply(this, arguments); return x !== null ? x : FAIL;
  294. }; }
  295.  
  296. function one_or_more(fn) { return function(s) {
  297. var x = fn.apply(this, arguments); return x.length ? x : FAIL;
  298. }; }
  299.  
  300. function $c(css) { return array(this.querySelectorAll(css)); }
  301. function $C(css) { return this.querySelector(css); }
  302.  
  303. function $x(xpath) {
  304. var doc = this.evaluate ? this : this.ownerDocument, next;
  305. var got = doc.evaluate(xpath, this, null, 0, null), all = [];
  306. switch (got.resultType) {
  307. case 1/*XPathResult.NUMBER_TYPE*/: return got.numberValue;
  308. case 2/*XPathResult.STRING_TYPE*/: return got.stringValue;
  309. case 3/*XPathResult.BOOLEAN_TYPE*/: return got.booleanValue;
  310. default: while ((next = got.iterateNext())) all.push(next); return all;
  311. }
  312. }
  313. function $X(xpath) {
  314. var got = $x.call(this, xpath);
  315. return got instanceof Array ? got[0] || null : got;
  316. }
  317.  
  318. function quoteRe(s) { return (s+'').replace(/([-$(-+.?[-^{|}])/g, '\\$1'); }
  319.  
  320. // DOM constraint tester / scraper facility:
  321. // "this" is the context Node(s) - initially the document
  322. // "spec" is either of:
  323. // * css / xpath Selector "selector_type selector"
  324. // * resolved for context [ context Selector, spec ]
  325. // * an Object of spec(s) { property_name: spec, ... }
  326. function test_dom(spec, context) {
  327. // returns FAIL if it turned out it wasn't a mandated match at this level
  328. // returns null if it didn't find optional matches at this level
  329. // returns Node or an Array of nodes, or a basic type from some XPath query
  330. function lookup(rule) {
  331. switch (typeof rule) {
  332. case 'string': break; // main case - rest of function
  333. case 'object': if ('nodeType' in rule || rule.length) return rule;
  334. // fall-through
  335. default: throw new Error('non-String dom match rule: '+ rule);
  336. }
  337. if (!on._parse_dom_rule) on._parse_dom_rule = new RegExp('^(' +
  338. Object.keys(on.dom).map(quoteRe).join('|') + ')\\s*(.*)');
  339. var match = on._parse_dom_rule.exec(rule), type, func;
  340. if (match) {
  341. type = match[1];
  342. rule = match[2];
  343. func = test_dom[type];
  344. }
  345. if (!func) throw new Error('unknown dom match rule '+ type +': '+ rule);
  346. return func.call(this, rule);
  347. }
  348.  
  349. var results, result, i, property_name;
  350. if (context === undefined) {
  351. context = this === on || this === window ? document : this;
  352. }
  353.  
  354. // validate context:
  355. if (context === null || context === FAIL) return FAIL;
  356. if (isArray(context)) {
  357. for (results = [], i = 0; i < context.length; i++) {
  358. result = test_dom.call(context[i], spec);
  359. if (result !== FAIL)
  360. results.push(result);
  361. }
  362. return results;
  363. }
  364. if (typeof context !== 'object' || !('nodeType' in context))
  365. throw new Error('illegal context: '+ context);
  366.  
  367. // handle input spec format:
  368. if (typeof spec === 'string') return lookup.call(context, spec);
  369. if (isArray(spec)) {
  370. context = lookup.call(context, spec[0]);
  371. if (context === null || context === FAIL) return context;
  372. return test_dom.call(context, spec[1]);
  373. }
  374. if (isObject(spec)) {
  375. results = {};
  376. for (property_name in spec) {
  377. result = test_dom.call(context, spec[property_name]);
  378. if (result === FAIL) return FAIL;
  379. results[property_name] = result;
  380. }
  381. return results;
  382. }
  383.  
  384. throw new Error("dom spec was neither a String, Object nor Array: "+ spec);
  385. }
  386. }