Wanikani Open Framework - Apiv2 module

Apiv2 module for Wanikani Open Framework

目前為 2023-04-24 提交的版本,檢視 最新版本

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