Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle

Add Export/Import history buttons at the bottom of the page. Color&rename the confusingly named Seen/Unseen buttons. Enhance the title. Fix "Mark as seen on open" triggering on external links. :: Standalone feature: Light/Dark site skin toggle button.

  1. // ==UserScript==
  2. // @name Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle
  3. // @description Add Export/Import history buttons at the bottom of the page. Color&rename the confusingly named Seen/Unseen buttons. Enhance the title. Fix "Mark as seen on open" triggering on external links. :: Standalone feature: Light/Dark site skin toggle button.
  4. // @author C89sd
  5. // @version 1.17
  6. // @match https://archiveofourown.org/*
  7. // @grant GM_xmlhttpRequest
  8. // @namespace https://gf.qytechs.cn/users/1376767
  9. // ==/UserScript==
  10.  
  11. const ENHANCED_SEEN_BUTTON = true; // Seen button is colored and renamed
  12. const COLORED_TITLE_LINK = true; // Title becomes a colored link
  13. const ENHANCED_MARK_SEEN_ON_OPEN = true; // Enable improved "Mark seen on open" feature with a distinction between SEEN Now and Old SEEN
  14. const IGNORE_EXTERNAL_LINKS = true; // Ignore external links, only Mark SEEN within archiveofourown.org
  15. const SITE_SKINS = [ "Default", "Reversi" ];
  16.  
  17.  
  18. // Function to fetch the preferences form, extract the authenticity token, and get the skin_id
  19. function getPreferencesForm(user) {
  20. // GET the preferences
  21. fetch(`https://archiveofourown.org/users/${user}/preferences`, {
  22. method: 'GET',
  23. headers: {
  24. 'Content-Type': 'text/html'
  25. }
  26. })
  27. .then(response => response.text())
  28. .then(responseText => {
  29. const doc = new DOMParser().parseFromString(responseText, 'text/html');
  30.  
  31. // Extract the authenticity token
  32. const authenticity_token = doc.querySelector('input[name="authenticity_token"]')?.value;
  33. if (authenticity_token) {
  34. // console.log('authenticity_token: ', authenticity_token); // Log the token
  35. } else {
  36. alert('[userscript:Extend AO3] Error\n[authenticity_token] not found!');
  37. return;
  38. }
  39.  
  40. // Find the <form class="edit_preference">
  41. const form = doc.querySelector('form.edit_preference');
  42. if (form) {
  43. // console.log('Form:', form); // Log the form
  44.  
  45. // Extract the action URL for the form submission
  46. const formAction = form.getAttribute('action');
  47. // console.log('Form Action:', formAction);
  48.  
  49. // Find the <select id="preference_skin_id"> list
  50. const skinSelect = form.querySelector('#preference_skin_id');
  51. if (skinSelect) {
  52. // console.log('Found skin_id <select> element:', skinSelect); // Log the select
  53.  
  54. const workSkinIds = [];
  55. let currentSkinId = null;
  56. let unmatchedSkins = [...SITE_SKINS];
  57.  
  58. // Loop through the <option value="skinId">skinName</option>
  59. const options = skinSelect.querySelectorAll('option');
  60. options.forEach(option => {
  61. const optionValue = option.value;
  62. const optionText = option.textContent.trim();
  63.  
  64. if (SITE_SKINS.includes(optionText)) {
  65. // console.log('- option: value=', optionValue, ", text=", optionText, option.selected ? "SELECTED" : ".");
  66.  
  67. workSkinIds.push(optionValue);
  68.  
  69. // Remove matched name from unmatchedSkins
  70. unmatchedSkins = unmatchedSkins.filter(name => name !== optionText);
  71.  
  72. if (option.selected) { // <option selected="selected"> is the current one
  73. currentSkinId = optionValue;
  74. }
  75. }
  76. });
  77.  
  78. // console.log('SKINS: ', SITE_SKINS, ", workSkinIds: ", workSkinIds);
  79.  
  80. // Alert if any SITE_SKINS was not matched to an ID
  81. if (unmatchedSkins.length > 0) {
  82. alert("ERROR.\nThe following skins were not found in the list under 'My Preferences > Your site skin'. Please check for spelling mistakes:\n[" + unmatchedSkins.join(", ") + "]\nThey will be skipped for now.");
  83. }
  84.  
  85. // Cycle the ids: find the current ID in the list and pick the next modulo the array length
  86. if (workSkinIds.length > 0) {
  87. let nextSkinId = null;
  88. let currentIndex = workSkinIds.indexOf(currentSkinId);
  89.  
  90. if (currentSkinId === null || currentIndex === -1) {
  91. // If currentSkinId is null or not found, select the first workSkinId
  92. nextSkinId = workSkinIds[0];
  93. alert("Current skin was not in list, first skin \"" + SITE_SKINS[0] + "\" will be applied.")
  94. } else {
  95. let nextIndex = (currentIndex + 1) % workSkinIds.length;
  96. nextSkinId = workSkinIds[nextIndex];
  97. }
  98.  
  99. // console.log('Next skin ID:', nextSkinId);
  100.  
  101. // ------ POST settings update
  102. // NOTE: This triggers mutiple redirects ending in 404 .. but it works !
  103. // so we manualy handle and reload the page at the first redirect instead.
  104.  
  105. // // This approach is way simpler but I did not find how to get the current selected skin id, and I need it to decide the next skin to use. This approach seems to not update the settings, but maybe the id can be found on the page?
  106. // fetch(`https://archiveofourown.org/skins/${nextSkinId}/set`, {
  107. // credentials: 'include'
  108. // })
  109. // .then(() => window.location.reload())
  110. // .catch(error => {
  111. // console.error('Error setting the skin:', error);
  112. // alert('[userscript:Extend AO3] Error\nError setting the skin: ' + error);
  113. // });
  114.  
  115. const formData = new URLSearchParams();
  116. formData.append('_method', 'patch');
  117. formData.append('authenticity_token', authenticity_token);
  118. formData.append('preference[skin_id]', nextSkinId);
  119. formData.append('commit', 'Update'); // Ensure the commit button is also included
  120.  
  121. fetch(formAction, {
  122. method: 'POST',
  123. body: formData.toString(),
  124. headers: {
  125. 'Content-Type': 'application/x-www-form-urlencoded',
  126. 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', //'application/json',
  127. 'Sec-Fetch-Dest': 'document',
  128. 'Sec-Fetch-Mode': 'navigate',
  129. 'Sec-Fetch-Site': 'same-origin',
  130. 'Sec-Fetch-User': '?1',
  131. 'Upgrade-Insecure-Requests': '1',
  132. 'Referer': `https://archiveofourown.org/users/${user}/preferences`
  133. },
  134. credentials: 'include',
  135. redirect: 'manual' // Prevents automatic redirect handling
  136. })
  137. .then(response => {
  138. // If there is a redirect, response will have status code 3xx
  139. if (response.type === 'opaqueredirect') {
  140. // console.log('Redirect blocked, handling manually.');
  141.  
  142. window.location.reload(); // reload the page
  143. return;
  144. } else {
  145. return response.text();
  146. }
  147. })
  148. .then(responseText => {
  149. // console.log('Form submitted successfully:', responseText);
  150. window.location.reload(); // reload the page
  151. return;
  152. })
  153. .catch(error => {
  154. console.error('Error submitting the form:', error);
  155. alert('[userscript:Extend AO3] Error\nError submitting the form: ' + error);
  156. });
  157. }
  158. } else {
  159. alert('[userscript:Extend AO3] Error\nNo <select> element with id="preference_skin_id" found in the form');
  160. }
  161. } else {
  162. alert('[userscript:Extend AO3] Error\nNo form found with class "edit_preference"');
  163. }
  164. })
  165. .catch(error => {
  166. alert('[userscript:Extend AO3] Error\nError fetching preferences form: ' + error);
  167. });
  168. }
  169.  
  170. // Button callback
  171. function toggleLightDark() {
  172. const greetingElement = document.querySelector('#greeting a');
  173.  
  174. if (!greetingElement) {
  175. alert('[userscript:Extend AO3] Error\nUsername not found in top right corner "Hi, user!"');
  176. return;
  177. }
  178.  
  179. const user = greetingElement.href.split('/').pop();
  180. getPreferencesForm(user);
  181. }
  182.  
  183.  
  184. (function() {
  185. 'use strict';
  186.  
  187. const footer = document.createElement('div');
  188. footer.style.width = '100%';
  189. footer.style.paddingTop = '5px';
  190. footer.style.paddingBottom = '5px';
  191. footer.style.display = 'flex';
  192. footer.style.justifyContent = 'center';
  193. footer.style.gap = '10px';
  194. footer.classList.add('footer');
  195.  
  196. var firstH1link = null;
  197. if (ENHANCED_SEEN_BUTTON && COLORED_TITLE_LINK) {
  198. // Turn title into a link
  199. const firstH1 = document.querySelector('h2.title.heading');
  200.  
  201. if (firstH1) {
  202. const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
  203. const titleLink = document.createElement('a');
  204. titleLink.href = window.location.href;
  205. if (title) {
  206. const titleClone = title.cloneNode(true);
  207. titleLink.appendChild(titleClone);
  208. title.parentNode.replaceChild(titleLink, title);
  209. }
  210.  
  211. firstH1link = titleLink;
  212. }
  213. }
  214.  
  215. const BTN_1 = ['button'];
  216. const BTN_2 = ['button', 'button--link'];
  217.  
  218. // Create Light/Dark Button
  219. const lightDarkButton = document.createElement('button');
  220. lightDarkButton.textContent = 'Light/Dark';
  221. lightDarkButton.classList.add(...['button']);
  222. lightDarkButton.addEventListener('click', toggleLightDark);
  223. footer.appendChild(lightDarkButton);
  224.  
  225. // Create Export Button
  226. const exportButton = document.createElement('button');
  227. exportButton.textContent = 'Export';
  228. exportButton.classList.add(...BTN_1);
  229. exportButton.addEventListener('click', exportToJson);
  230. footer.appendChild(exportButton);
  231.  
  232. // Create Import Button
  233. const importButton = document.createElement('button');
  234. importButton.textContent = 'Import';
  235. importButton.classList.add(...BTN_1);
  236.  
  237. // Create hidden file input
  238. const fileInput = document.createElement('input');
  239. fileInput.type = 'file';
  240. fileInput.accept = '.txt, .json';
  241. fileInput.style.display = 'none'; // Hide the input element
  242.  
  243. // Trigger file input on "Restore" button click
  244. importButton.addEventListener('click', () => {
  245. fileInput.click(); // Open the file dialog when the button is clicked
  246. });
  247.  
  248. // Listen for file selection and handle the import
  249. fileInput.addEventListener('change', importFromJson);
  250. footer.appendChild(importButton);
  251.  
  252. // Append footer to the page
  253. const ao3Footer = document.querySelector('body > div > div#footer');
  254. if (ao3Footer) {
  255. ao3Footer.insertAdjacentElement('beforebegin', footer);
  256. } else {
  257. document.body.appendChild(footer);
  258. }
  259.  
  260. const strip = /^\[?,?|,?\]?$/g;
  261. // Export function
  262. function exportToJson() {
  263. const export_lists = {
  264. username: localStorage.getItem('kudoshistory_username'),
  265. settings: localStorage.getItem('kudoshistory_settings'),
  266. kudosed: localStorage.getItem('kudoshistory_kudosed') || ',',
  267. bookmarked: localStorage.getItem('kudoshistory_bookmarked') || ',',
  268. skipped: localStorage.getItem('kudoshistory_skipped') || ',',
  269. seen: localStorage.getItem('kudoshistory_seen') || ',',
  270. checked: localStorage.getItem('kudoshistory_checked') || ','
  271. };
  272.  
  273. const pad = (num) => String(num).padStart(2, '0');
  274. const now = new Date();
  275. const year = now.getFullYear();
  276. const month = pad(now.getMonth() + 1);
  277. const day = pad(now.getDate());
  278. const hours = pad(now.getHours());
  279. const minutes = pad(now.getMinutes());
  280. const seconds = pad(now.getSeconds()); // Add seconds
  281. const username = export_lists.username || "none";
  282. var size = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
  283. .map(key => (String(export_lists[key]) || '').replace(strip, '').split(',').length);
  284.  
  285. var textToSave = JSON.stringify(export_lists, null, 2);
  286. var blob = new Blob([textToSave], {
  287. type: "text/plain"
  288. });
  289. var a = document.createElement('a');
  290. a.href = URL.createObjectURL(blob);
  291. a.download = `AO3_history_${year}_${month}_${day}_${hours}${minutes}${seconds} ${username}+${size}.txt`; //Include seconds
  292. document.body.appendChild(a);
  293. a.click();
  294. document.body.removeChild(a);
  295. }
  296.  
  297. // Import function
  298. function importFromJson(event) {
  299. var file = event.target.files[0];
  300. if (!file) return;
  301.  
  302. var reader = new FileReader();
  303. reader.onload = function(e) {
  304. try {
  305. var importedData = JSON.parse(e.target.result);
  306. if (!importedData.kudosed || !importedData.seen || !importedData.bookmarked || !importedData.skipped || !importedData.checked) {
  307. throw new Error("Missing kudosed/seen/bookmarked/skipped/checked data fields.");
  308. }
  309.  
  310. var notes = ""
  311. var sizes_before = ['kudoshistory_kudosed', 'kudoshistory_bookmarked', 'kudoshistory_skipped', 'kudoshistory_seen', 'kudoshistory_checked']
  312. .map(key => (String(localStorage.getItem(key)) || '').replace(strip, '').split(',').length);
  313. var sizes_after = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
  314. .map(key => (String(importedData[key]) || '').replace(strip, '').split(',').length);
  315.  
  316. localStorage.setItem('kudoshistory_kudosed', importedData.kudosed);
  317. localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
  318. localStorage.setItem('kudoshistory_skipped', importedData.skipped);
  319. localStorage.setItem('kudoshistory_seen', importedData.seen);
  320. localStorage.setItem('kudoshistory_checked', importedData.checked);
  321.  
  322. var diff = sizes_after.reduce((a, b) => a + b, 0) - sizes_before.reduce((a, b) => a + b, 0);
  323. diff = diff == 0 ? "no change" :
  324. diff > 0 ? "added +" + diff :
  325. "removed " + diff;
  326. notes += "\n- Entries: " + diff;
  327. notes += "\n " + sizes_before;
  328. notes += "\n " + sizes_after;
  329.  
  330. if (!importedData.username) {
  331. notes += "\n- Username: not present in file ";
  332. } else if (localStorage.getItem('kudoshistory_username') == "null" && importedData.username && importedData.username != "null") {
  333. localStorage.setItem('kudoshistory_username', importedData.username);
  334. notes += "\n- Username: updated to " + importedData.username;
  335. } else {
  336. notes += "\n- Username: no change"
  337. }
  338.  
  339. if (!importedData.settings) {
  340. notes += "\n- Settings: not present in file ";
  341. } else if (importedData.settings && importedData.settings != localStorage.getItem('kudoshistory_settings')) {
  342. const oldSettings = localStorage.getItem('kudoshistory_settings');
  343. localStorage.setItem('kudoshistory_settings', importedData.settings);
  344. notes += "\n- Settings: updated to";
  345. notes += "\n old: " + oldSettings;
  346. notes += "\n new: " + importedData.settings;
  347. } else {
  348. notes += "\n- Settings: no change"
  349. }
  350.  
  351. alert("[userscript:Extend AO3] Success" + notes);
  352. } catch (error) {
  353. alert("[userscript:Extend AO3] Error\nInvalid file format / missing data.");
  354. }
  355. };
  356. reader.readAsText(file);
  357. }
  358.  
  359.  
  360. // ==========================================
  361.  
  362. if (ENHANCED_SEEN_BUTTON) {
  363. let wasClicked = false;
  364.  
  365. // Step 1: Wait for the button to exist and click it if it shows "Seen"
  366. function waitForSeenButton() {
  367. let attempts = 0;
  368. const maxAttempts = 100; // Stop after ~5 seconds (100 * 50ms)
  369.  
  370. const buttonCheckInterval = setInterval(function() {
  371. attempts++;
  372. const seenButton = document.querySelector('.kh-seen-button a');
  373.  
  374. if (seenButton) {
  375. clearInterval(buttonCheckInterval);
  376. if (seenButton.textContent.includes('Seen ✓')) {
  377. if (ENHANCED_MARK_SEEN_ON_OPEN) {
  378. if (!IGNORE_EXTERNAL_LINKS || document.referrer.includes("archiveofourown.org")) {
  379. seenButton.click();
  380. wasClicked = true;
  381. }
  382. }
  383. } else {
  384. wasClicked = false;
  385. }
  386.  
  387. // Move to Step 2
  388. setupButtonObserver();
  389. } else if (attempts >= maxAttempts) {
  390. clearInterval(buttonCheckInterval);
  391. }
  392. }, 50);
  393. }
  394.  
  395. // Step 2: Monitor the button text and toggle it
  396. function setupButtonObserver() {
  397. toggleButtonText(true, wasClicked);
  398.  
  399. // Button to observe
  400. const targetNode = document.querySelector('.kh-seen-button');
  401. if (!targetNode) {
  402. return;
  403. }
  404.  
  405. const observer = new MutationObserver(function(mutations) {
  406. mutations.forEach(function(mutation) {
  407. if (mutation.type === 'childList' || mutation.type === 'characterData') {
  408. toggleButtonText(false, false);
  409. }
  410. });
  411. });
  412.  
  413. const config = {
  414. childList: true,
  415. characterData: true,
  416. subtree: true
  417. };
  418.  
  419. observer.observe(targetNode, config);
  420. }
  421.  
  422. function toggleButtonText(isFirst = false, wasClicked = false) {
  423. const buttonElement = document.querySelector('.kh-seen-button a');
  424. if (!buttonElement) return;
  425.  
  426. // Ignore changes we made ourselves
  427. if (buttonElement.textContent === "SEEN Now (click to unmark)" ||
  428. buttonElement.textContent === "Old SEEN (click to unmark)" ||
  429. buttonElement.textContent === "SEEN (click to unmark)" ||
  430. buttonElement.textContent === "NOT SEEN (click to mark)" ||
  431. buttonElement.textContent === "UNSEEN (click to mark)") {
  432. return;
  433. }
  434.  
  435. const state_seen = buttonElement.textContent.includes('Unseen ✗') ? true :
  436. buttonElement.textContent.includes('Seen ✓') ? false : null;
  437.  
  438. if (state_seen === null) {
  439. alert('[userscript:Extend AO3]\nUnknown text: ' + buttonElement.textContent);
  440. return;
  441. }
  442.  
  443. const GREEN = "#33cc70"; // "#33cc70";
  444. const GREEN_DARKER = "#00a13a"; // "#149b49";
  445. const RED = "#ff6d50";
  446.  
  447. buttonElement.textContent =
  448. state_seen ?
  449. (isFirst ?
  450. (wasClicked ? "SEEN Now (click to unmark)" : "Old SEEN (click to unmark)") :
  451. "SEEN (click to unmark)") :
  452. "UNSEEN (click to mark)";
  453.  
  454. const color = state_seen ? (isFirst && !wasClicked ? GREEN_DARKER : GREEN) : RED;
  455. buttonElement.style.backgroundColor = color;
  456. buttonElement.style.padding = "2px 6px";
  457. buttonElement.style.borderRadius = "3px";
  458. buttonElement.style.boxShadow = "none";
  459. buttonElement.style.backgroundImage = "none";
  460. buttonElement.style.color = getComputedStyle(buttonElement).color;
  461.  
  462. // Color title
  463. if (firstH1link) firstH1link.style.color = color;
  464.  
  465. // Blink on open Unseen -> Seen
  466. if (isFirst && wasClicked) {
  467. buttonElement.style.transition = "background-color 150ms ease";
  468. buttonElement.style.backgroundColor = GREEN;
  469. setTimeout(() => {
  470. buttonElement.style.backgroundColor = "#00e64b";
  471. }, 150);
  472. setTimeout(() => {
  473. buttonElement.style.transition = "background-color 200ms linear";
  474. buttonElement.style.backgroundColor = GREEN;
  475. }, 200);
  476. } else if (!isFirst) {
  477. buttonElement.style.transition = "none"; // Clear transition for subsequent calls
  478. buttonElement.style.backgroundColor = state_seen ? GREEN : RED;
  479. }
  480. }
  481.  
  482. // Start the process
  483. waitForSeenButton();
  484. }
  485. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址