Wanikani Open Framework - Apiv2 module

Apiv2 module for Wanikani Open Framework

当前为 2019-05-23 提交的版本,查看 最新版本

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