MyAnimeList (MAL) Tags Cloud

Displays tags cloud on profile pages.

  1. // ==UserScript==
  2. // @name MyAnimeList (MAL) Tags Cloud
  3. // @namespace https://greasyfork.org/users/7517
  4. // @description Displays tags cloud on profile pages.
  5. // @icon http://i.imgur.com/b7Fw8oH.png
  6. // @license Unlicense
  7. // @version 3.1.3
  8. // @author akarin
  9. // @include /^https?:\/\/myanimelist\.net\/profile/
  10. // @grant none
  11. // @noframes
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. const STATUS = {
  18. ALL: 7, IN_PROGRESS: 1, COMPLETED: 2, ON_HOLD: 3, DROPPED: 4, PLAN_TO: 6
  19. };
  20.  
  21. const OPTS = {
  22. CACHE_VERSION: '3.0.1',
  23. WIDTH: 900,
  24. HEIGHT: 700,
  25. FONT_MIN: 9,
  26. FONT_MAX: 70,
  27. FETCH_DELAY: 1000,
  28. FETCH_TIMEOUT: 30000
  29. };
  30.  
  31. function fetchUrl (url, timeout, delay) {
  32. if (timeout === undefined) {
  33. timeout = OPTS.FETCH_TIMEOUT;
  34. }
  35.  
  36. if (delay === undefined) {
  37. delay = 0;
  38. }
  39.  
  40. let isTimeout = false;
  41. return new Promise((resolve, reject) => setTimeout(() => {
  42. const t = setTimeout(() => {
  43. isTimeout = true;
  44. reject(new Error('fetch timeout'));
  45. }, timeout);
  46.  
  47. fetch(url)
  48. .then((response) => {
  49. clearTimeout(t);
  50. if (!isTimeout) {
  51. resolve(response);
  52. }
  53. })
  54. .catch((err) => {
  55. if (!isTimeout) {
  56. reject(err);
  57. }
  58. });
  59. }, delay));
  60. }
  61.  
  62. class Cache {
  63. constructor (name, username) {
  64. this.name = name;
  65. this.username = username;
  66. }
  67.  
  68. encodeKey (key) {
  69. return this.name + '#' + OPTS.CACHE_VERSION + '#' + this.username + '#' + key;
  70. }
  71.  
  72. loadValue (key, value) {
  73. try {
  74. return JSON.parse(localStorage.getItem(this.encodeKey(key))) || value;
  75. } catch (e) {
  76. console.log(e.name + ': ' + e.message);
  77. return value;
  78. }
  79. }
  80.  
  81. saveValue (key, value) {
  82. localStorage.setItem(this.encodeKey(key), JSON.stringify(value));
  83. }
  84. }
  85.  
  86. class Fancybox {
  87. constructor () {
  88. this.body = document.createElement('div');
  89. this.body.setAttribute('id', 'tc_fancybox_inner');
  90.  
  91. this.outer = document.createElement('div');
  92. this.outer.setAttribute('id', 'tc_fancybox_outer');
  93. this.outer.appendChild(this.body);
  94.  
  95. this.wrapper = document.createElement('div');
  96. this.wrapper.setAttribute('id', 'tc_fancybox_wrapper');
  97. this.wrapper.onlick = () => this.hide();
  98.  
  99. this.hide();
  100. }
  101.  
  102. init (node) {
  103. node.parentNode.insertBefore(this.outer, node.nextSibling);
  104. node.parentNode.insertBefore(this.wrapper, node.nextSibling);
  105. this.wrapper.onclick = () => this.hide();
  106. }
  107.  
  108. show (callback) {
  109. if (this.body.hasChildNodes()) {
  110. Array.from(this.body.childNodes).forEach((node) => {
  111. node.style.display = 'none';
  112. });
  113. }
  114.  
  115. if (callback === undefined || callback === true || callback()) {
  116. this.wrapper.style.display = '';
  117. this.outer.style.display = '';
  118. } else {
  119. this.hide();
  120. }
  121. }
  122.  
  123. hide () {
  124. this.outer.style.display = 'none';
  125. this.wrapper.style.display = 'none';
  126. }
  127. }
  128.  
  129. class MalData {
  130. constructor (username, type, offset, timeout, delay) {
  131. this.username = username;
  132. this.type = type;
  133. this.offset = offset;
  134. this.timeout = timeout;
  135. this.delay = delay;
  136. this.data = {};
  137. this.size = 0;
  138. this.running = false;
  139. }
  140.  
  141. clear () {
  142. this.running = false;
  143. this.data = {};
  144. this.size = 0;
  145. }
  146.  
  147. load (offset) {
  148. return new Promise((resolve, reject) => {
  149. fetchUrl('/' + this.type + 'list/' + this.username + '/load.json?status=7&offset=' + offset, this.timeout, this.delay)
  150. .then((response) => response.json())
  151. .then((data) => resolve(data))
  152. .catch((err) => reject(err));
  153. });
  154. }
  155.  
  156. async populate (callbacks, filter) {
  157. if (this.running) {
  158. return;
  159. }
  160.  
  161. this.clear();
  162. this.running = true;
  163.  
  164. const hasFilter = Array.isArray(filter) && filter.length > 0;
  165.  
  166. for (let offset = 0; ; offset = offset + this.offset) {
  167. let data;
  168. try {
  169. data = await this.load(offset);
  170. } catch (err) {
  171. this.clear();
  172. if (callbacks.hasOwnProperty('onError')) {
  173. callbacks.onError();
  174. }
  175. break;
  176. }
  177.  
  178. if (!Array.isArray(data) || data.length === 0) {
  179. break;
  180. }
  181.  
  182. for (const entry of data) {
  183. this.data[entry[this.type + '_id']] = hasFilter ? Object.keys(entry)
  184. .filter((key) => filter.includes(key))
  185. .reduce((obj, key) => {
  186. obj[key] = entry[key];
  187. return obj;
  188. }, {}) : entry;
  189. }
  190.  
  191. this.size = this.size + data.length;
  192. if (callbacks.hasOwnProperty('onNext')) {
  193. callbacks.onNext(this.size);
  194. }
  195. }
  196.  
  197. let data = Object.assign({}, this.data);
  198. this.clear();
  199. if (callbacks.hasOwnProperty('onFinish')) {
  200. callbacks.onFinish(data);
  201. }
  202. }
  203. }
  204.  
  205. class TagsCloud {
  206. constructor (username, fancybox) {
  207. this.username = username;
  208. this.fancybox = fancybox;
  209. this.cache = new Cache('mal_tags_cloud', this.username);
  210. this.content = {};
  211. this.status = {};
  212. this.tags = {};
  213. this.loader = {};
  214. this.onclick = {};
  215.  
  216. this.header = document.createElement('div');
  217. this.header.classList.add('tc_title');
  218.  
  219. ['anime', 'manga'].forEach((type) => {
  220. let node = document.createElement('select');
  221. node.setAttribute('id', 'tc_sel_' + type);
  222. this.header.appendChild(node);
  223. });
  224.  
  225. this.header.appendChild(document.createElement('span'));
  226. this.header.appendChild(document.createTextNode(' ('));
  227.  
  228. ['anime', 'manga'].forEach((type) => {
  229. let node = document.createElement('a');
  230. node.setAttribute('id', 'tc_upd_' + type);
  231. node.setAttribute('href', 'javascript:void(0);');
  232. node.setAttribute('title', 'Update tags');
  233. node.appendChild(document.createTextNode('update'));
  234. this.header.appendChild(node);
  235. });
  236.  
  237. this.header.appendChild(document.createTextNode(') '));
  238.  
  239. ['anime', 'manga'].forEach((type) => {
  240. let node = document.createElement('sup');
  241. node.setAttribute('id', 'tc_sup_' + type);
  242. this.header.appendChild(node);
  243. });
  244.  
  245. this.body = document.createElement('div');
  246. this.body.setAttribute('id', 'tags_cloud');
  247. this.body.appendChild(this.header);
  248. }
  249.  
  250. init (type, onStatusChange) {
  251. let node = document.createElement('div');
  252. node.classList.add('tags_list');
  253.  
  254. let el = document.createElement('div');
  255. el.classList.add('tags_not_found');
  256. node.appendChild(el);
  257.  
  258. this.body.appendChild(node);
  259. this.content[type] = node;
  260.  
  261. this.status[type] = STATUS.ALL;
  262. this.tags[type] = this.cache.loadValue(type, {});
  263. this.loader[type] = new MalData(this.username, type, 300, OPTS.FETCH_TIMEOUT, OPTS.FETCH_DELAY);
  264. this.onclick[type] = () => {};
  265.  
  266. node = this.header.querySelector('select#tc_sel_' + type);
  267. if (node) {
  268. while (node.hasChildNodes()) {
  269. node.removeChild(node.lastChild);
  270. }
  271.  
  272. el = document.createElement('option');
  273. el.value = STATUS.ALL;
  274. el.appendChild(document.createTextNode('All ' + type.replace(/^a/, 'A').replace(/^m/, 'M')));
  275. node.appendChild(el);
  276.  
  277. el = document.createElement('option');
  278. el.value = STATUS.IN_PROGRESS;
  279. el.appendChild(document.createTextNode(type === 'anime' ? 'Watching' : 'Reading'));
  280. node.appendChild(el);
  281.  
  282. el = document.createElement('option');
  283. el.value = STATUS.COMPLETED;
  284. el.appendChild(document.createTextNode('Completed'));
  285. node.appendChild(el);
  286.  
  287. el = document.createElement('option');
  288. el.value = STATUS.ON_HOLD;
  289. el.appendChild(document.createTextNode('On-Hold'));
  290. node.appendChild(el);
  291.  
  292. el = document.createElement('option');
  293. el.value = STATUS.DROPPED;
  294. el.appendChild(document.createTextNode('Dropped'));
  295. node.appendChild(el);
  296.  
  297. el = document.createElement('option');
  298. el.value = STATUS.PLAN_TO;
  299. el.appendChild(document.createTextNode('Plan to ' + (type === 'anime' ? 'Watch' : 'Read')));
  300. node.appendChild(el);
  301.  
  302. node.onchange = () => {
  303. let select = this.header.querySelector('select#tc_sel_' + type);
  304. if (select) {
  305. onStatusChange(select.value);
  306. }
  307. }
  308. }
  309.  
  310. node = this.header.querySelector('a#tc_upd_' + type);
  311. if (node) {
  312. node.onclick = () => this.onclick[type]();
  313. }
  314. }
  315.  
  316. show (type, status) {
  317. status = parseInt(status);
  318.  
  319. this.onclick[type] = () => {
  320. if (this.loader[type].running) {
  321. alert('Updating in process!');
  322. return;
  323. }
  324.  
  325. let node = this.header.querySelector('select#tc_sel_' + type);
  326. if (node) {
  327. node.setAttribute('disabled', 'disabled');
  328. }
  329.  
  330. node = this.content[type];
  331. while (node.hasChildNodes()) {
  332. node.removeChild(node.lastChild);
  333. }
  334.  
  335. let text = document.createTextNode('Loading...');
  336. node = document.createElement('div');
  337. node.classList.add('tags_not_found');
  338. node.appendChild(text);
  339. this.content[type].appendChild(node);
  340.  
  341. this.loader[type].populate({
  342. onFinish: (data) => {
  343. let tags = {};
  344. for (const entry of Object.values(data)) {
  345. if (typeof entry.tags === 'string' || entry.tags instanceof String) {
  346. for (let tag of entry.tags.split(',')) {
  347. tag = tag.trim();
  348. if (tag.length === 0) {
  349. continue;
  350. }
  351.  
  352. if (!tags.hasOwnProperty(tag)) {
  353. tags[tag] = {};
  354. }
  355.  
  356. let keyStatus = String(entry.status);
  357. tags[tag][keyStatus] = tags[tag].hasOwnProperty(keyStatus) ? tags[tag][keyStatus] + 1 : 1;
  358.  
  359. let keyTotal = String(STATUS.ALL);
  360. tags[tag][keyTotal] = tags[tag].hasOwnProperty(keyTotal) ? tags[tag][keyTotal] + 1 : 1;
  361. }
  362. }
  363. }
  364.  
  365. this.tags[type] = tags;
  366. this.cache.saveValue(type, tags);
  367. this.update(type, status);
  368. },
  369. onNext: (count) => text.nodeValue = 'Loading... (' + count + ' entries)',
  370. onError: () => text.nodeValue = 'Loading... (failed)'
  371. }, [ 'status', 'tags' ]);
  372. };
  373.  
  374. if (this.status[type] !== status || this.content[type].querySelector('.tags_not_found')) {
  375. this.update(type, status);
  376. }
  377.  
  378. this.status[type] = status;
  379. const invType = type === 'anime' ? 'manga' : 'anime';
  380.  
  381. this.content[invType].style.display = 'none';
  382. this.header.querySelectorAll(
  383. 'select#tc_sel_' + invType + ', ' +
  384. 'a#tc_upd_' + invType + ', ' +
  385. 'sup#tc_sup_' + invType
  386. ).forEach((node) => {
  387. node.style.display = 'none';
  388. });
  389.  
  390. this.content[type].style.display = '';
  391. this.header.querySelectorAll(
  392. 'select#tc_sel_' + type + ', ' +
  393. 'a#tc_upd_' + type + ', ' +
  394. 'sup#tc_sup_' + type
  395. ).forEach((node) => {
  396. node.style.display = '';
  397. });
  398.  
  399. let node = this.header.querySelector('span');
  400. if (node) {
  401. while (node.hasChildNodes()) {
  402. node.removeChild(node.lastChild);
  403. }
  404.  
  405. node.appendChild(document.createTextNode('Tags Cloud — '));
  406.  
  407. let el = document.createElement('a');
  408. el.setAttribute('id', 'tc_link_' + type);
  409. el.setAttribute('href', 'javascript:void(0);');
  410. el.setAttribute('title', 'Switch to ' + invType);
  411. el.appendChild(document.createTextNode(type.toLowerCase().replace(/^a/, 'A').replace(/^m/, 'M')));
  412. el.onclick = () => this.fancybox.show(() => this.show(invType, this.status[invType]));
  413. node.appendChild(el);
  414.  
  415. node.appendChild(document.createTextNode(' · ' + this.username));
  416. }
  417.  
  418. this.body.style.display = '';
  419. return true;
  420. }
  421.  
  422. update (type, status) {
  423. if (this.loader[type].running) {
  424. return;
  425. }
  426. let node = this.header.querySelector('select#tc_sel_' + type);
  427. if (node) {
  428. node.setAttribute('disabled', 'disabled');
  429. }
  430. this.remap(type, status);
  431. if (node) {
  432. node.removeAttribute('disabled');
  433. }
  434. }
  435.  
  436. remap (type, status) {
  437. status = parseInt(status);
  438. const keyStatus = String(status);
  439.  
  440. let node = this.content[type];
  441. while (node.hasChildNodes()) {
  442. node.removeChild(node.lastChild);
  443. }
  444.  
  445. let max = 0;
  446. let min = Number.MAX_VALUE;
  447. let tags = {};
  448.  
  449. for (const [tag, data] of Object.entries(this.tags[type])) {
  450. if (data.hasOwnProperty(keyStatus)) {
  451. tags[tag] = data[keyStatus];
  452. max = Math.max(max, tags[tag]);
  453. min = Math.min(min, tags[tag]);
  454. }
  455. }
  456.  
  457. let keys = Object.keys(tags)
  458. .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
  459. const len = keys.length;
  460.  
  461. if (len === 0) {
  462. node = document.createElement('div');
  463. node.classList.add('tags_not_found');
  464. node.appendChild(document.createTextNode('Tags not found'));
  465. this.content[type].appendChild(node);
  466.  
  467. node = this.header.querySelector('sup#tc_sup_' + type);
  468. if (node) {
  469. while (node.hasChildNodes()) {
  470. node.removeChild(node.lastChild);
  471. }
  472. }
  473.  
  474. return;
  475. }
  476.  
  477. node = this.header.querySelector('sup#tc_sup_' + type);
  478. if (node) {
  479. while (node.hasChildNodes()) {
  480. node.removeChild(node.lastChild);
  481. }
  482. if (len > 1) {
  483. node.appendChild(document.createTextNode('Total: ' + len));
  484. }
  485. }
  486.  
  487. const sizeDiff = max - min;
  488. const fontDiff = OPTS.FONT_MAX - OPTS.FONT_MIN;
  489. const tagLink = '/' + type + 'list/' + this.username + '?status=' + status + '&tag=';
  490.  
  491. keys.forEach((tag, i) => {
  492. const count = tags[tag];
  493. const fontSize = OPTS.FONT_MIN + (fontDiff * (count - min) / sizeDiff);
  494.  
  495. let span = document.createElement('span');
  496. span.classList.add('cloud_tag');
  497. span.style.fontSize = Math.round(fontSize) + 'px';
  498. span.style.margin = '0 ' + Math.round(fontSize / 4) + 'px';
  499.  
  500. let el = document.createElement('a');
  501. el.setAttribute('href', tagLink + tag);
  502. el.setAttribute('target', '_blank');
  503. el.appendChild(document.createTextNode(tag));
  504. span.appendChild(el);
  505.  
  506. if (count > 1) {
  507. el = document.createElement('sup');
  508. el.appendChild(document.createTextNode(String(count)));
  509. span.appendChild(el);
  510. }
  511.  
  512. node = this.content[type];
  513. node.appendChild(span);
  514. if (i + 1 < len) {
  515. node.appendChild(document.createTextNode(', '));
  516. }
  517. });
  518. }
  519. }
  520.  
  521. const username = document.querySelector('.user-profile .user-function .icon-user-function#comment')
  522. .getAttribute('href').match(/\/([^/]+)#lastcomment$/)[1].trim();
  523.  
  524. let fancybox = new Fancybox();
  525. fancybox.init(document.querySelector('#contentWrapper'));
  526.  
  527. let cloud = new TagsCloud(username, fancybox);
  528. fancybox.body.appendChild(cloud.body);
  529.  
  530. ['anime', 'manga'].forEach((type) => {
  531. cloud.init(type, (status) => fancybox.show(() => cloud.show(type, status)));
  532.  
  533. let el = document.querySelector('.profile .user-statistics .user-statistics-stats .updates.' + type + ' > h5');
  534. if (el) {
  535. let history = el.querySelector('a[href*="/history/"]');
  536. if (history) {
  537. history.textContent = history.textContent.replace(/^(Anime|Manga)\sHistory$/, 'History');
  538.  
  539. let node = document.createElement('a');
  540. node.setAttribute('href', 'javascript:void(0);');
  541. node.classList.add('floatRightHeader');
  542. node.classList.add('ff-Verdana');
  543. node.classList.add('mr4');
  544. node.appendChild(document.createTextNode('Tags Cloud'));
  545. node.onclick = () => fancybox.show(() => cloud.show(type, cloud.status[type]));
  546. el.insertBefore(node, history.nextSibling);
  547.  
  548. let span = document.createElement('span');
  549. span.classList.add('floatRightHeader');
  550. span.classList.add('ff-Verdana');
  551. span.classList.add('mr4');
  552. span.appendChild(document.createTextNode('-'));
  553. el.insertBefore(span, node);
  554. }
  555. }
  556. });
  557.  
  558. let style = document.createElement('style');
  559. style.setAttribute('type', 'text/css');
  560. style.appendChild(document.createTextNode(
  561. 'div#tc_fancybox_wrapper { position: fixed; width: 100%; height: 100%; top: 0; left: 0; background: rgba(102, 102, 102, 0.3); z-index: 99990; }' +
  562. 'div#tc_fancybox_inner { width: ' + OPTS.WIDTH + 'px !important; height: ' + OPTS.HEIGHT + 'px !important; overflow: hidden; }' +
  563. 'div#tc_fancybox_outer { position: absolute; display: block; width: auto; height: auto; padding: 10px; border-radius: 8px; top: 80px; left: 50%; margin-top: 0 !important; margin-left: ' + (-OPTS.WIDTH / 2) + 'px !important; background: #fff; box-shadow: 0 0 15px rgba(32, 32, 32, 0.4); z-index: 99991; }' +
  564. 'div#tags_cloud { width: 100%; height: 100%; text-align: center; padding-top: 45px; box-sizing: border-box; }' +
  565. 'div#tags_cloud sup { color: #90a0b0; font-weight: lighter; }' +
  566. 'div#tags_cloud .tc_title { position: absolute; top: 10px; left: 10px; width: ' + OPTS.WIDTH + 'px; font-size: 16px; font-weight: normal; text-align: center; margin: 0; border: 0; }' +
  567. 'div#tags_cloud .tc_title select { position: absolute; left: 0; top: 0; }' +
  568. 'div#tags_cloud .tc_title a[id^="tc_upd_"] { font-size: 12px; font-weight: normal; }' +
  569. 'div#tags_cloud .tc_title sup { position: absolute; right: 0; top: 0; }' +
  570. 'div#tags_cloud .tc_title:after { content: ""; display: block; position: relative; width: 100%; height: 8px; margin: 0.5em 0 0; padding: 0; border-top: 1px solid #ebebeb; background: center bottom no-repeat radial-gradient(#f6f6f6, #fff 70%); background-size: 100% 16px; }' +
  571. 'div.tags_list { width: 100%; height: 100%; overflow-x: hidden; overflow-y: auto; color: #666; font-size:' + OPTS.FONT_MIN + 'px; border: 1px solid #eee; box-sizing: border-box; }' +
  572. 'div.tags_list .cloud_tag { white-space: nowrap; }' +
  573. 'div.tags_list .tags_not_found { font-size: 12px; font-weight: normal; margin-top: 20px; }'
  574. ));
  575. document.querySelector('head').appendChild(style);
  576. }());