Wanikani Open Framework - Apiv2 module

Apiv2 module for Wanikani Open Framework

目前为 2018-04-16 提交的版本,查看 最新版本

此脚本不应直接安装,它是供其他脚本使用的外部库。如果你需要使用该库,请在脚本元属性加入:// @require https://update.cn-greasyfork.org/scripts/38581/265421/Wanikani%20Open%20Framework%20-%20Apiv2%20module.js

  1. // ==UserScript==
  2. // @name Wanikani Open Framework - Apiv2 module
  3. // @namespace rfindley
  4. // @description Apiv2 module for Wanikani Open Framework
  5. // @version 1.0.5
  6. // @copyright 2018+, Robin Findley
  7. // @license MIT; http://opensource.org/licenses/MIT
  8. // ==/UserScript==
  9.  
  10. (function(global) {
  11.  
  12. //########################################################################
  13. //------------------------------
  14. // Published interface.
  15. //------------------------------
  16. global.wkof.Apiv2 = {
  17. clear_cache: clear_cache,
  18. fetch_endpoint: fetch_endpoint,
  19. get_endpoint: get_endpoint,
  20. is_valid_apikey_format: is_valid_apikey_format,
  21. };
  22. //########################################################################
  23.  
  24. function promise(){var a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
  25.  
  26. var available_endpoints = [
  27. 'assignments','level_progressions','resets','review_statistics',
  28. 'reviews','study_materials','subjects','summary','user'
  29. ];
  30. var using_apikey_override = false;
  31. var skip_username_check = false;
  32.  
  33. //------------------------------
  34. // Retrieve the username from the page.
  35. //------------------------------
  36. function get_username() {
  37. try {
  38. return ($('.account a[href^="/users/"]').attr('href') || '').match(/[^\/]+$/)[0];
  39. } catch(e) {
  40. return undefined;
  41. }
  42. }
  43.  
  44. //------------------------------
  45. // Check if a string is a valid apikey format.
  46. //------------------------------
  47. function is_valid_apikey_format(str) {
  48. return ((typeof str === 'string') &&
  49. (str.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) !== null));
  50. }
  51.  
  52. //------------------------------
  53. // Clear any datapoint cache not belonging to the current user.
  54. //------------------------------
  55. function clear_cache(include_non_user) {
  56. var clear_promises = [];
  57. var dir = wkof.file_cache.dir;
  58. for (var idx in available_endpoints) {
  59. var filename = 'Apiv2.'+available_endpoints[idx];
  60. if ((filename === 'Apiv2.subjects' && include_non_user !== true) || !dir[filename]) continue;
  61. clear_promises.push(filename);
  62. }
  63. clear_promises = clear_promises.map(delete_file);
  64.  
  65. if (clear_promises.length > 0) {
  66. console.log('Clearing user cache...');
  67. return Promise.all(clear_promises);
  68. } else {
  69. return Promise.resolve();
  70. }
  71.  
  72. function delete_file(filename){
  73. return wkof.file_cache.delete(filename);
  74. }
  75. }
  76.  
  77. wkof.set_state('wkof.Apiv2.key', 'not_ready');
  78.  
  79. //------------------------------
  80. // Get the API key (either from localStorage, or from the Account page).
  81. //------------------------------
  82. function get_apikey() {
  83. // If we already have the apikey, just return it.
  84. if (is_valid_apikey_format(wkof.Apiv2.key))
  85. return Promise.resolve(wkof.Apiv2.key);
  86.  
  87. // If we don't have the apikey, but override was requested, return error.
  88. if (using_apikey_override)
  89. return Promise.reject('Invalid api2_key_override in localStorage!');
  90.  
  91. // Fetch the apikey from the account page.
  92. console.log('Fetching API key...');
  93. wkof.set_state('wkof.Apiv2.key', 'fetching');
  94. return wkof.load_file('https://www.wanikani.com/settings/account')
  95. .then(parse_page);
  96.  
  97. function parse_page(page){
  98. var page = $(page);
  99. var apikey = page.find('#user_api_key_v2').val();
  100. if (!wkof.Apiv2.is_valid_apikey_format(apikey))
  101. return Promise.reject('No API key (version 2) found on account page!');
  102.  
  103. // Store the api key.
  104. wkof.Apiv2.key = apikey;
  105. localStorage.setItem('apiv2_key', apikey);
  106. wkof.set_state('wkof.Apiv2.key', 'ready');
  107. return apikey;
  108. };
  109. }
  110.  
  111. //------------------------------
  112. // Fetch a URL asynchronously, and pass the result as resolved Promise data.
  113. //------------------------------
  114. function fetch_endpoint(endpoint, options) {
  115. var retry_cnt, endpoint_data, url, headers;
  116. var progress_data = {name:'wk_api_'+endpoint, label:'Wanikani '+endpoint, value:0, max:100};
  117. var bad_key_cnt = 0;
  118.  
  119. // Parse options.
  120. if (!options) options = {};
  121. var filters = options.filters;
  122. if (!filters) filters = {};
  123. var progress_callback = options.progress_callback;
  124.  
  125. // Get timestamp of last fetch from options (if specified)
  126. var last_update = options.last_update;
  127.  
  128. // If no prior fetch... (i.e. no valid last_update)
  129. if (typeof last_update !== 'string' && !(last_update instanceof Date)) {
  130. // If updated_after is present, use it. Otherwise, default to ancient date.
  131. if (filters.updated_after === undefined)
  132. last_update = '1999-01-01T01:00:00.000000Z';
  133. else
  134. last_update = filters.updated_after;
  135. }
  136. // If last_update is a Date object, convert it to ISO string.
  137. // If it's a string, but not an ISO string, try converting to an ISO string.
  138. if (last_update instanceof Date)
  139. last_update = last_update.toISOString().replace(/Z$/,'000Z');
  140. else if (last_update.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/) === null)
  141. last_update = new Date(last_update).toISOString().replace(/Z$/,'000Z');
  142.  
  143. // Set up URL and headers
  144. url = "https://api.wanikani.com/v2/" + endpoint;
  145.  
  146. // Add user-specified data filters to the URL
  147. filters.updated_after = last_update;
  148. var arr = [];
  149. for (var name in filters) {
  150. var value = filters[name];
  151. if (Array.isArray(value)) value = value.join(',');
  152. arr.push(name+'='+value);
  153. }
  154. url += '?'+arr.join('&');
  155.  
  156. // Get API key and fetch the data.
  157. var fetch_promise = promise();
  158. get_apikey()
  159. .then(setup_and_fetch);
  160.  
  161. return fetch_promise;
  162.  
  163. //============
  164. function setup_and_fetch() {
  165. wkof.Progress.update(progress_data);
  166. headers = {
  167. // 'Wanikani-Revision': '20170710', // Placeholder?
  168. 'Authorization': 'Bearer '+wkof.Apiv2.key,
  169. };
  170. headers['If-Modified-Since'] = new Date(last_update).toUTCString(last_update);
  171.  
  172. retry_cnt = 0;
  173. fetch();
  174. }
  175.  
  176. //============
  177. function fetch() {
  178. retry_cnt++;
  179. var request = new XMLHttpRequest();
  180. request.onreadystatechange = received;
  181. request.open('GET', url, true);
  182. for (var key in headers)
  183. request.setRequestHeader(key, headers[key]);
  184. request.send();
  185. }
  186.  
  187. //============
  188. function received(event) {
  189. // ReadyState of 4 means transaction is complete.
  190. if (this.readyState !== 4) return;
  191.  
  192. // Check for rate-limit error. Delay and retry if necessary.
  193. if (this.status === 429 && retry_cnt < 40) {
  194. var delay = Math.min((retry_cnt * 250), 2000);
  195. setTimeout(fetch, delay);
  196. return;
  197. }
  198.  
  199. // Check for bad API key.
  200. if (this.status === 401) return bad_apikey();
  201.  
  202. // Check of 'no updates'.
  203. if (this.status >= 300) {
  204. if (typeof progress_callback === 'function')
  205. progress_callback(endpoint, 0, 1, 1);
  206. progress_data.value = 1;
  207. progress_data.max = 1;
  208. wkof.Progress.update(progress_data);
  209. return fetch_promise.reject({status:this.status, url:url});
  210. }
  211.  
  212. // Process the response data.
  213. var json = JSON.parse(event.target.response);
  214.  
  215. // Data may be a single object, or collection of objects.
  216. // Collections are paginated, so we may need more fetches.
  217. if (json.object === 'collection') {
  218. // It's a multi-page endpoint.
  219. var first_new, so_far, total;
  220. if (endpoint_data === undefined) {
  221. // First page of results.
  222. first_new = 0;
  223. so_far = json.data.length;
  224. } else {
  225. // Nth page of results.
  226. first_new = endpoint_data.data.length;
  227. so_far = first_new + json.data.length;
  228. json.data = endpoint_data.data.concat(json.data);
  229. }
  230. endpoint_data = json;
  231. total = json.total_count;
  232.  
  233. // Call the 'progress' callback.
  234. if (typeof progress_callback === 'function')
  235. progress_callback(endpoint, first_new, so_far, total);
  236. progress_data.value = so_far;
  237. progress_data.max = total;
  238. wkof.Progress.update(progress_data);
  239.  
  240. // If there are more pages, fetch the next one.
  241. if (json.pages.next_url !== null) {
  242. retry_cnt = 0;
  243. url = json.pages.next_url;
  244. fetch();
  245. return;
  246. }
  247.  
  248. // This was the last page. Return the data.
  249. fetch_promise.resolve(endpoint_data);
  250.  
  251. } else {
  252. // Single-page result. Report single-page progress, and return data.
  253. if (typeof progress_callback === 'function')
  254. progress_callback(endpoint, 0, 1, 1);
  255. progress_data.value = 1;
  256. progress_data.max = 1;
  257. wkof.Progress.update(progress_data);
  258. fetch_promise.resolve(json);
  259. }
  260. }
  261.  
  262. //============
  263. function bad_apikey(){
  264. // If we are using an override key, abort and return error.
  265. if (using_apikey_override) {
  266. fetch_promise.reject('Wanikani doesn\'t recognize the apiv2_key_override key ("'+wkof.Apiv2.key+'")');
  267. return;
  268. }
  269.  
  270. // If bad key received too many times, abort and return error.
  271. bad_key_cnt++;
  272. if (bad_key_cnt > 1) {
  273. fetch_promise.reject('Aborting fetch: Bad key reported multiple times!');
  274. return;
  275. }
  276.  
  277. // We received a bad key. Report on the console, then try fetching the key (and data) again.
  278. console.log('Seems we have a bad API key. Erasing stored info.');
  279. localStorage.removeItem('apiv2_key');
  280. wkof.Apiv2.key = undefined;
  281. get_apikey()
  282. .then(populate_user_cache)
  283. .then(setup_and_fetch);
  284. }
  285. }
  286.  
  287.  
  288. var min_update_interval = 60;
  289. var ep_cache = {};
  290.  
  291. //------------------------------
  292. // Get endpoint data from cache with updates from API.
  293. //------------------------------
  294. function get_endpoint(ep_name, options) {
  295. if (!options) options = {};
  296.  
  297. // We cache data for 'min_update_interval' seconds.
  298. // If within that interval, we return the cached data.
  299. // User can override cache via "options.force_update = true"
  300. var ep_info = ep_cache[ep_name];
  301. if (ep_info) {
  302. // If still awaiting prior fetch return pending promise.
  303. // Also, not force_update, return non-expired cache (i.e. resolved promise)
  304. if (options.force_update !== true || ep_info.timer === undefined)
  305. return ep_info.promise;
  306. // User is requesting force_update, and we have unexpired cache.
  307. // Clear the expiration timer since we will re-fetch anyway.
  308. clearTimeout(ep_info.timer);
  309. }
  310.  
  311. // Create a promise to fetch data. The resolved promise will also serve as cache.
  312. var get_promise = promise();
  313. ep_cache[ep_name] = {promise: get_promise};
  314.  
  315. // Make sure the requested endpoint is valid.
  316. var merged_data;
  317. if (available_endpoints.indexOf(ep_name) < 0) {
  318. get_promise.reject(new Error('Invalid endpoint name "'+ep_name+'"'));
  319. return get_promise;
  320. }
  321.  
  322. // Perform the fetch, and process the data.
  323. wkof.file_cache.load('Apiv2.'+ep_name)
  324. .then(fetch, fetch);
  325. return get_promise;
  326.  
  327. //============
  328. function fetch(cache_data) {
  329. if (typeof cache_data === 'string') cache_data = {last_update:null};
  330. merged_data = cache_data;
  331. var fetch_options = Object.assign({}, options);
  332. fetch_options.last_update = cache_data.last_update;
  333. fetch_endpoint(ep_name, fetch_options)
  334. .then(process_api_data, handle_error);
  335. }
  336.  
  337. //============
  338. function process_api_data(fetched_data) {
  339. // Mark the data with the last_update timestamp reported by the server.
  340. if (fetched_data.data_updated_at !== null) merged_data.last_update = fetched_data.data_updated_at;
  341.  
  342. // Process data according to whether it is paginated or not.
  343. if (fetched_data.object === 'collection') {
  344. if (merged_data.data === undefined) merged_data.data = {};
  345. for (var idx = 0; idx < fetched_data.data.length; idx++) {
  346. var item = fetched_data.data[idx];
  347. merged_data.data[item.id] = item;
  348. }
  349. } else {
  350. merged_data.data = fetched_data.data;
  351. }
  352.  
  353. // If it's the 'user' endpoint, we insert the apikey before caching.
  354. if (ep_name === 'user') merged_data.data.apikey = wkof.Apiv2.key;
  355.  
  356. // Save data to cache and finish up.
  357. wkof.file_cache.save('Apiv2.'+ep_name, merged_data)
  358. .then(finish);
  359. }
  360.  
  361. //============
  362. function finish() {
  363. // Return the data, then set up a cache expiration timer.
  364. get_promise.resolve(merged_data.data);
  365. ep_cache[ep_name].timer = setTimeout(expire_cache, min_update_interval*1000);
  366. }
  367.  
  368. //============
  369. function expire_cache() {
  370. // Delete the data from cache.
  371. delete ep_cache[ep_name];
  372. }
  373.  
  374. //============
  375. function handle_error(error) {
  376. if (typeof error === 'string')
  377. get_promise.reject(error);
  378. if (error.status >= 300 && error.status <= 399)
  379. finish();
  380. else
  381. get_promise.reject('Error '+error.status+' fetching "'+error.url+'"');
  382. }
  383. }
  384.  
  385. //########################################################################
  386. //------------------------------
  387. // Make sure user cache matches the current (or override) user.
  388. //------------------------------
  389. function validate_user_cache() {
  390. var user = get_username();
  391. if (!user) {
  392. // Username unavailable if not logged in, or if on Lessons or Reviews pages.
  393. // If not logged in, stop running the framework.
  394. if (location.pathname.match(/^(\/|\/login)$/) !== null)
  395. return Promise.reject('Couldn\'t extract username from user menu! Not logged in?');
  396. skip_username_check = true;
  397. }
  398.  
  399. var apikey = localStorage.getItem('apiv2_key_override');
  400. if (apikey !== null) {
  401. // It looks like we're trying to override the apikey (e.g. for debug)
  402. using_apikey_override = true;
  403. if (!is_valid_apikey_format(apikey)) {
  404. return Promise.reject('Invalid api2_key_override in localStorage!');
  405. }
  406. console.log('Using apiv2_key_override key ('+apikey+')');
  407. } else {
  408. // Use regular apikey (versus override apikey)
  409. apikey = localStorage.getItem('apiv2_key');
  410. if (!is_valid_apikey_format(apikey)) apikey = undefined;
  411. }
  412.  
  413. wkof.Apiv2.key = apikey;
  414.  
  415. // Make sure cache is still valid
  416. return wkof.file_cache.load('Apiv2.user')
  417. .then(process_user_info)
  418. .catch(retry);
  419.  
  420. //============
  421. function process_user_info(user_info) {
  422. // If cache matches, we're done.
  423. if (user_info.data.apikey === wkof.Apiv2.key) {
  424. // We don't check username when using override key.
  425. if (using_apikey_override || skip_username_check || (user_info.data.username === user)) {
  426. wkof.Apiv2.user = user_info.data.username;
  427. return populate_user_cache();
  428. }
  429. }
  430. // Cache doesn't match.
  431. if (!using_apikey_override) {
  432. // Fetch the key from the accounts page.
  433. wkof.Apiv2.key = undefined;
  434. throw 'fetch key';
  435. } else {
  436. // We're using override. No need to fetch key, just populate cache.
  437. return clear_cache().then(populate_user_cache);
  438. }
  439. }
  440.  
  441. //============
  442. function retry() {
  443. // Either empty cache, or user mismatch. Fetch key, then populate cache.
  444. return get_apikey().then(clear_cache).then(populate_user_cache);
  445. }
  446. }
  447.  
  448. //------------------------------
  449. // Populate the user info into cache.
  450. //------------------------------
  451. function populate_user_cache() {
  452. return fetch_endpoint('user')
  453. .then(function(user_info){
  454. // Store the apikey in the cache.
  455. user_info.data.apikey = wkof.Apiv2.key;
  456. wkof.Apiv2.user = user_info.data.username;
  457. wkof.user = user_info.data
  458. return wkof.file_cache.save('Apiv2.user', user_info);
  459. });
  460. }
  461.  
  462. //------------------------------
  463. // Do initialization once document is loaded.
  464. //------------------------------
  465. function notify_ready() {
  466. // Notify listeners that we are ready.
  467. // Delay guarantees include() callbacks are called before ready() callbacks.
  468. setTimeout(function(){wkof.set_state('wkof.Apiv2', 'ready');},0);
  469. }
  470.  
  471. //------------------------------
  472. // Do initialization once document is loaded.
  473. //------------------------------
  474. wkof.include('Progress');
  475. wkof.ready('document,Progress').then(startup);
  476. function startup() {
  477. validate_user_cache()
  478. .then(notify_ready);
  479. }
  480.  
  481. })(window);