turkoptiscript

User script for Turkopticon -- review requesters on Amazon Mechanical Turk

目前為 2017-02-19 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name turkoptiscript
  3. // @author feihtality
  4. // @namespace https://greasyfork.org/en/users/12709
  5. // @version 1.0.0-rc0
  6. // @description User script for Turkopticon -- review requesters on Amazon Mechanical Turk
  7. // @license ISC
  8. // @include https://*.mturk.com/*
  9. // @exclude https://www.mturk.com/mturk/findhits?*hit_scraper
  10. // @grant none
  11. // ==/UserScript==
  12. (function () {
  13. 'use strict';
  14.  
  15. function qs(...args) {
  16. return (args[1] || document).querySelector(args[0]);
  17. }
  18.  
  19. function qsa(...args) {
  20. return Array.from((args[1] || document).querySelectorAll(args[0]));
  21. }
  22.  
  23. function make(tag, attrs = {}, namespace) {
  24. const el = namespace ? document.createElementNS(namespace, tag) : document.createElement(tag);
  25. Object.keys(attrs).forEach(attr => el.setAttribute(attr, attrs[attr]));
  26. return el;
  27. }
  28.  
  29. function format(response) {
  30. const payRate = (pay, time, total) => ((pay / time) * 60 ** 2).toFixed(2),
  31. toDays = (seconds) => (seconds / 86400.0).toFixed(2);
  32. return !isNaN(response)
  33. ? `${toDays(response)} days`
  34. : response.length > 2
  35. ? `$${payRate(...response)}/hr`
  36. : `${response[0]} of ${response[1]}`
  37. }
  38.  
  39. class HITCapsule {
  40. constructor(el, lockup) {
  41. this.elRef = el;
  42. this.attrs = {};
  43. this._lockup = lockup;
  44. }
  45.  
  46. init(selector) {
  47. if (selector) this.elRef = this.elRef.closest(selector);
  48. return this;
  49. }
  50.  
  51. inject(data) { this._lockup.inject(data || {}, this.attrs).attach(this.elRef); }
  52.  
  53. extract(attrs, env, data) {
  54. const { root, leaf } = env,
  55. method = leaf === 'preview' ? '_extractPreview' : '_extractDefault';
  56. if (root === 'next')
  57. Object.assign(this.attrs, attrs.reduce((a, b) => (a[b] = data[b]) && a, {}));
  58. else
  59. attrs.forEach(attr => this.attrs[attr] = this[method](attr, env));
  60. return this;
  61. }
  62.  
  63. _extractDefault(attr, env) {
  64. if (env.leaf === 'statusdetail' && attr === 'title')
  65. return this._get('.statusdetailTitleColumnValue').textContent;
  66.  
  67. switch (attr) {
  68. case 'reward':
  69. return this._get('span.reward').textContent.slice(1);
  70. case 'rid':
  71. return this._get('[href*="requesterId"]').href.match(/requesterId=([^=&]+)/)[1];
  72. case 'rname':
  73. return this._get('.requesterIdentity').textContent;
  74. case 'title':
  75. return this._get('a.capsulelink').textContent.trim();
  76. }
  77. }
  78.  
  79. _extractPreview(attr) {
  80. switch (attr) {
  81. case 'reward':
  82. return this._get('span.reward').textContent.slice(1);
  83. case 'rid':
  84. return qs('input[name=requesterId]').value;
  85. case 'rname':
  86. return qs('input[name=prevRequester]').value;
  87. case 'title':
  88. return this._get('.capsulelink_bold').textContent.trim();
  89. }
  90. }
  91.  
  92. _get(selector) { return qs(selector, this.elRef); }
  93. }
  94.  
  95. class Extractor$$1 {
  96. constructor() {
  97. this._selector = new Selector();
  98. }
  99.  
  100. init() {
  101. this.env = Extractor$$1.getEnv();
  102. this._lockup = new Lockup(this.env);
  103. this._selector.init(this.env);
  104.  
  105. const isNext = this.env.root === 'next',
  106. model = isNext ? JSON.parse(qs(this._selector.anchor).closest('div').dataset['reactProps']) : null;
  107. this._data = model ? Extractor$$1.pruneReactModel(model, this.env) : null;
  108. return this;
  109. }
  110.  
  111. collect(fn) {
  112. let collection;
  113. if (fn && typeof fn === 'function')
  114. collection = fn(this._selector.anchor);
  115. else throw new TypeError('expected a function');
  116.  
  117. const keys = 'title rname rid reward'.split(' ');
  118. this.collection = collection
  119. .map((c, i) => {
  120. const data = this._data ? this._data[i] : null;
  121. return new HITCapsule(c, this._lockup)
  122. .init(this._selector.base)
  123. .extract(keys, this.env, data);
  124. })
  125. .reduce((a, b) => (a[b.attrs.rid] ? a[b.attrs.rid].push(b) : (a[b.attrs.rid] = [b])) && a, {});
  126. return this;
  127. }
  128.  
  129. static getEnv() {
  130. const strat = { root: 'legacy', leaf: 'default' },
  131. path = document.location.pathname;
  132. if (document.domain.includes('worker') || qs('body > .container-fluid'))
  133. strat.root = 'next';
  134. if (path.includes('statusdetail'))
  135. strat.leaf = 'statusdetail';
  136. else if (/(myhits|tasks)/.test(path))
  137. strat.leaf = 'queue';
  138. else if (qs('#theTime'))
  139. strat.leaf = 'preview';
  140. return strat;
  141. }
  142.  
  143. static pruneReactModel(model, env) {
  144. return model['bodyData'].map(d => {
  145. const src = env.leaf === 'queue' ? d['project'] : d;
  146.  
  147. const { monetary_reward: { amount_in_dollars:reward }, requester_id:rid, title, requester_name:rname } = src;
  148.  
  149. return { rid: rid, rname: rname, title: title, reward: reward };
  150. });
  151. }
  152.  
  153. }
  154.  
  155. class Selector {
  156. constructor() {
  157. this.selectors = {
  158. next : {
  159. default: { anchor: 'li.table-row', base: null },
  160. queue : { anchor: 'li.table-row', base: null }
  161. },
  162. legacy: {
  163. default : { anchor: '.requesterIdentity', base: 'table[height]' },
  164. preview : { anchor: 'a[id|="requester.tooltip"]', base: 'table[style]' },
  165. queue : { anchor: '.requesterIdentity', base: 'table[height]' },
  166. statusdetail: {
  167. anchor: '.statusdetailRequesterColumnValue',
  168. base : 'tr',
  169. inject: '.statusdetailRequesterColumnValue'
  170. }
  171. }
  172. };
  173. }
  174.  
  175. init(env) { this.env = env; }
  176.  
  177. get anchor() {
  178. const { root, leaf } = this.env;
  179. return this.selectors[root][leaf].anchor;
  180. }
  181.  
  182. get base() {
  183. const { root, leaf } = this.env;
  184. return this.selectors[root][leaf].base;
  185. }
  186.  
  187. get inject() {
  188. const { root, leaf } = this.env;
  189. return this.selectors[root][leaf].inject || '.capsule_field_text';
  190. }
  191. }
  192.  
  193. class Lockup {
  194. constructor(env) {
  195. this.env = env;
  196. this.idol = createLockup(env);
  197. }
  198.  
  199. inject({ aggregates:agg }, scrapeData) {
  200. this.clone = this.idol.cloneNode(true);
  201. const selector = '.to-fc';
  202.  
  203. if (agg) {
  204. [].forEach.call(qs(selector, this.clone).children, el => el.classList.toggle('hidden'));
  205. qs('a.hidden', this.clone).classList.toggle('hidden');
  206. ['all', 'recent'].forEach(range => {
  207. Object.keys(agg[range]).forEach(attr => {
  208. const val = agg[range][attr],
  209. crude = val instanceof Array || attr === 'pending';
  210. qs(`[data-range=${range}][data-attr=${attr}]`, this.clone)
  211. .textContent = crude ? format(val) : val;
  212. });
  213. });
  214. }
  215.  
  216. [].forEach.call(qsa('a', this.clone), el => buildLink(el, k => scrapeData[k]));
  217. qs('.to-rn', this.clone).textContent = scrapeData.rname;
  218. return this;
  219. }
  220.  
  221. attach(context) {
  222. const ref = context instanceof HTMLLIElement
  223. ? qs('span>span', context)
  224. : (qs('.capsule_field_text', context) || qs('a', context));
  225. ref.parentNode.insertBefore(this.clone, ref);
  226. }
  227. }
  228.  
  229. function createLockup(env) {
  230. const
  231. pos = env.root === 'legacy' ? 'to-rel' : 'to-abs',
  232. root = make('div', { class: `to-hdi ${pos}` }),
  233. lockup = make('div', { class: 'to-lockup to-abs' }),
  234. flex = lockup.appendChild(make('div', { class: 'to-fc' })),
  235. labels = ['pay rate', 'time pending', 'response', 'recommend', 'tos', 'broken'],
  236. attrs = ['reward', 'pending', 'comm', 'recommend', 'tos', 'broken'];
  237.  
  238. root.appendChild(make('svg', { height: 20, width: 20 }, 'http://www.w3.org/2000/svg'))
  239. .appendChild(make('path', {
  240. fill: '#657b83',
  241. d : 'M10 0c-5.52 0-10 4.48-10 10 0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10zm4.22 5.38c1.34 0 2.41 0.42 3.22 1.25 0.81 0.83 1.22 2.02 1.22 3.5 0 1.47-0.39 2.61-1.19 3.44-0.8 0.83-1.88 1.25-3.22 1.25-1.36 0-2.45-0.42-3.25-1.25-0.8-0.83-1.19-1.95-1.19-3.41 0-0.93 0.13-1.71 0.41-2.34 0.21-0.46 0.49-0.88 0.84-1.25 0.36-0.37 0.76-0.63 1.19-0.81 0.57-0.24 1.23-0.37 1.97-0.37zm-12.47 0.16h7.25v1.56h-2.72v7.56h-1.84v-7.56h-2.69v-1.56zm12.5 1.44c-0.76 0-1.38 0.26-1.84 0.78-0.46 0.52-0.69 1.29-0.69 2.34 0 1.03 0.21 1.81 0.69 2.34 0.48 0.53 1.11 0.81 1.84 0.81 0.73 0 1.31-0.28 1.78-0.81 0.47-0.53 0.72-1.32 0.72-2.37 0-1.05-0.23-1.83-0.69-2.34-0.46-0.51-1.05-0.75-1.81-0.75z'
  242. }, 'http://www.w3.org/2000/svg'));
  243. root.appendChild(lockup);
  244. lockup.insertBefore(make('div', { class: 'to-rn' }), flex);
  245.  
  246. let tmp, tagAttrs;
  247. tmp = flex.appendChild(make('div', { style: 'margin:10px 0 0' }));
  248. tmp.innerHTML = 'This requester has not been reviewed yet.';
  249.  
  250. tmp = flex.appendChild(make('div', { class: 'hidden' }));
  251. tmp.innerHTML = '<span class="to-th">&nbsp;</span>' + labels.map(v => `<span>${v}</span>`).join('');
  252.  
  253. ['recent', 'all'].forEach(range => {
  254. tmp = flex.appendChild(make('div', { class: 'hidden' }));
  255. const label = `<span class="to-th">${range === 'all' ? 'All time' : 'Last 90 days'}</span>`;
  256. let inner = attrs.map((attr, i) => `<span data-range="${range}" data-attr="${attr}">---</span>`);
  257. tmp.innerHTML = label + inner.join('');
  258. });
  259.  
  260. tagAttrs = {
  261. class : 'hidden',
  262. 'data-rid' : '',
  263. 'data-path': '/requesters',
  264. target : '_blank',
  265. };
  266. tmp = lockup.appendChild(make('a', tagAttrs));
  267. tmp.textContent = 'View on Turkopticon';
  268.  
  269. tagAttrs = {
  270. 'data-rid' : '',
  271. 'data-rname' : '',
  272. 'data-title' : '',
  273. 'data-reward': '',
  274. 'data-path' : '/reviews/new',
  275. target : '_blank',
  276. };
  277. tmp = lockup.appendChild(make('a', tagAttrs));
  278. tmp.textContent = 'Add a new review';
  279.  
  280. return root;
  281. }
  282.  
  283. function buildLink(el, cb) {
  284. const ds = Object.keys(el.dataset).filter(k => k !== 'path'),
  285. href = 'https://turkopticon.info' + el.dataset.path;
  286.  
  287. ds.forEach(k => el.dataset[k] = cb(k));
  288. if (el.dataset.path === '/requesters')
  289. el.href = href + '/' + ds.map(k => el.dataset[k]).join('/');
  290. else
  291. el.href = href + '?' + ds.map(k => `${k}=${el.dataset[k]}`).join('&');
  292.  
  293. return el;
  294. }
  295.  
  296. class ApiQuery {
  297. constructor(action, method) {
  298. this.URI = 'https://api.turkopticon.info/' + (action || '');
  299. this.method = method || 'GET';
  300. this.version = '2.0-alpha';
  301. }
  302.  
  303. send(params) {
  304. this.params = params ? new Params(params) : null;
  305.  
  306. return new Promise((accept, reject) => {
  307. const xhr = new XMLHttpRequest(),
  308. url = this.params ? `${this.URI}?${this.params.toString()}` : this.URI;
  309. xhr.open(this.method, url);
  310. xhr.responseType = 'json';
  311. xhr.setRequestHeader('Accept', `application/vnd.turkopticon.v${this.version}+json`);
  312. xhr.send();
  313. xhr.onload = ({ target:{ response } }) => accept(response);
  314. xhr.onerror = e => reject(e);
  315. });
  316. }
  317. }
  318.  
  319. class Params {
  320. constructor(params) { this.params = params; }
  321.  
  322. toString() { return Params.toParams(this.params); }
  323.  
  324. static toParams(obj, scope) {
  325. if (typeof obj === 'object' && !(obj instanceof Array))
  326. return Object.keys(obj).map(k => Params.toParams(obj[k], scope ? `${scope}[${k}]` : k)).join('&');
  327. else
  328. return `${scope}=${obj.toString()}`;
  329. }
  330. }
  331.  
  332. try {
  333. appendCss();
  334. const extr = new Extractor$$1().init().collect(qsa),
  335. rids = Object.keys(extr.collection);
  336.  
  337. new ApiQuery('requesters')
  338. .send({ rids: rids, fields: { requesters: ['rid', 'aggregates'] } })
  339. .then(response => response.data.reduce((a, b) => (a[b.attributes.rid] = b.attributes) && a, {}))
  340. .then(data => rids.forEach(rid => extr.collection[rid].forEach(capsule => capsule.inject(data[rid]))))
  341. .catch(console.error.bind(console, '#apierror'));
  342. } catch(err) {
  343. console.error(err);
  344. }
  345.  
  346. function appendCss() {
  347. const style = document.head.appendChild(make('style'));
  348. style.innerHTML = `
  349. .to-rel { position:relative; }
  350. .to-abs { position:absolute; }
  351. .to-hdi { display:inline-block; font-size:12px; cursor:default; line-height:14px; }
  352. .to-hdi:hover > svg { float:left; z-index:3; position:relative; }
  353. .to-hdi:hover > .to-lockup { display:block; z-index:2; }
  354. .to-hdi .hidden, .to-nhdi .hidden { display:none }
  355. .to-nhdi { font-size:12px; }
  356. .to-lockup { display:none; width:300px; top:-1px; left:-5px; background:#fff; padding:5px; box-shadow:0px 2px 10px 1px rgba(0,0,0,0.7); }
  357. .to-lockup a { display:inline-block; width:50%; text-align:center; margin-top:10px; color:crimson; }
  358. .to-rn { margin:0 0 3px 25px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  359. .to-fc { display:flex; }
  360. .to-fc > div { flex:1; }
  361. .to-fc .to-th { font-weight:700; width:100%; background:#6a8ca3; color:#fff }
  362. .to-fc span { display:block; padding:3px 0; margin:0; }
  363. `;
  364. }
  365.  
  366. }());