Greasy Fork is available in English.

OSRS Wiki Auto-Categorizer with UI, Adaptive Speed, Duplicate Checker

Adds listed pages to a category upon request with UI, CSRF token, adaptive speed, duplicate checker, and highlighted links option.

  1. // ==UserScript==
  2. // @name OSRS Wiki Auto-Categorizer with UI, Adaptive Speed, Duplicate Checker
  3. // @namespace typpi.online
  4. // @version 4.5
  5. // @description Adds listed pages to a category upon request with UI, CSRF token, adaptive speed, duplicate checker, and highlighted links option.
  6. // @author Nick2bad4u
  7. // @match https://oldschool.runescape.wiki/*
  8. // @match https://runescape.wiki/*
  9. // @match https://*.runescape.wiki/*
  10. // @match https://api.runescape.wiki/*
  11. // @match https://classic.runescape.wiki/*
  12. // @match *://*.runescape.wiki/*
  13. // @grant GM_xmlhttpRequest
  14. // @icon https://www.google.com/s2/favicons?sz=64&domain=oldschool.runescape.wiki
  15. // @license UnLicense
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20. const versionNumber = '4.2';
  21. let categoryName = '';
  22. let pageLinks = [];
  23. let selectedLinks = [];
  24. let currentIndex = 0;
  25. let csrfToken = '';
  26. let isCancelled = false;
  27. let isRunning = false;
  28. let requestInterval = 500;
  29. const maxInterval = 5000;
  30. const excludedPrefixes = [
  31. 'Template:',
  32. 'File:',
  33. 'Category:',
  34. 'Module:',
  35. 'RuneScape:',
  36. 'Update:',
  37. 'Exchange:',
  38. 'RuneScape:',
  39. 'User:',
  40. 'Help:',
  41. ];
  42. let actionLog = []; // Track actions for summary
  43.  
  44. function addButtonAndProgressBar() {
  45. console.log(
  46. 'Adding button and progress bar to the UI.',
  47. );
  48. const container =
  49. document.createElement('div');
  50. container.id = 'categorize-ui';
  51. container.style = `position: fixed; bottom: 20px; right: 20px; z-index: 1000;
  52. background-color: #2b2b2b; color: #ffffff; padding: 12px;
  53. border: 1px solid #595959; border-radius: 8px; font-family: Arial, sans-serif; width: 250px;`;
  54.  
  55. const startButton =
  56. document.createElement('button');
  57. startButton.textContent =
  58. 'Start Categorizing';
  59. startButton.style = `background-color: #4caf50; color: #fff; border: none;
  60. padding: 6px 12px; border-radius: 5px; cursor: pointer;`;
  61. startButton.onclick = promptCategoryName;
  62. container.appendChild(startButton);
  63.  
  64. const cancelButton =
  65. document.createElement('button');
  66. cancelButton.textContent = 'Cancel';
  67. cancelButton.style = `background-color: #d9534f; color: #fff; border: none;
  68. padding: 6px 12px; border-radius: 5px; cursor: pointer; margin-left: 10px;`;
  69. cancelButton.onclick = cancelCategorization;
  70. container.appendChild(cancelButton);
  71.  
  72. const progressBarContainer =
  73. document.createElement('div');
  74. progressBarContainer.style = `width: 100%; margin-top: 10px; background-color: #3d3d3d;
  75. height: 20px; border-radius: 5px; position: relative;`;
  76. progressBarContainer.id =
  77. 'progress-bar-container';
  78.  
  79. const progressBar =
  80. document.createElement('div');
  81. progressBar.style = `width: 0%; height: 100%; background-color: #4caf50; border-radius: 5px;`;
  82. progressBar.id = 'progress-bar';
  83. progressBarContainer.appendChild(progressBar);
  84.  
  85. const progressText =
  86. document.createElement('span');
  87. progressText.id = 'progress-text';
  88. progressText.style = `position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  89. font-size: 12px; color: #ffffff; white-space: nowrap; overflow: visible; text-align: center;`;
  90. progressBarContainer.appendChild(
  91. progressText,
  92. );
  93.  
  94. container.appendChild(progressBarContainer);
  95. document.body.appendChild(container);
  96. }
  97.  
  98. function promptCategoryName() {
  99. categoryName = prompt(
  100. "Enter the category name you'd like to add:",
  101. );
  102. console.log(
  103. 'Category name entered:',
  104. categoryName,
  105. );
  106. if (!categoryName) {
  107. alert('Category name is required.');
  108. return;
  109. }
  110.  
  111. getPageLinks();
  112. if (pageLinks.length === 0) {
  113. alert('No pages found to categorize.');
  114. console.log(
  115. 'No pages found after filtering.',
  116. );
  117. return;
  118. }
  119.  
  120. displayPageSelectionPopup();
  121. }
  122.  
  123. // Function to check for highlighted text
  124. function getHighlightedText() {
  125. const selection = globalThis.getSelection();
  126. if (selection.rangeCount > 0) {
  127. const container =
  128. document.createElement('div');
  129. for (
  130. let i = 0;
  131. i < selection.rangeCount;
  132. i++
  133. ) {
  134. container.appendChild(
  135. selection.getRangeAt(i).cloneContents(),
  136. );
  137. }
  138. return container.innerHTML;
  139. }
  140. return '';
  141. }
  142.  
  143. // Modify getPageLinks to consider highlighted text
  144. function getPageLinks() {
  145. let contextElement = document.querySelector(
  146. '#mw-content-text',
  147. );
  148. const highlightedText = getHighlightedText();
  149.  
  150. if (highlightedText) {
  151. // Create a temporary container to process the highlighted text
  152. const tempContainer =
  153. document.createElement('div');
  154. tempContainer.innerHTML = highlightedText;
  155. contextElement = tempContainer;
  156. }
  157.  
  158. pageLinks = Array.from(
  159. new Set(
  160. Array.from(
  161. contextElement.querySelectorAll('a'),
  162. )
  163. .map((link) =>
  164. link.getAttribute('href'),
  165. )
  166. .filter(
  167. (href) =>
  168. href && href.startsWith('/w/'),
  169. )
  170. .map((href) =>
  171. decodeURIComponent(
  172. href.replace('/w/', ''),
  173. ),
  174. )
  175. .filter(
  176. (page) =>
  177. !excludedPrefixes.some((prefix) =>
  178. page.startsWith(prefix),
  179. ) &&
  180. !page.includes('?') &&
  181. !page.includes('/') &&
  182. !page.includes('#'),
  183. ),
  184. ),
  185. );
  186.  
  187. console.log(
  188. 'Filtered unique page links:',
  189. pageLinks,
  190. );
  191. }
  192.  
  193. function displayPageSelectionPopup() {
  194. console.log(
  195. 'Displaying page selection popup.',
  196. );
  197. const popup = document.createElement('div');
  198. popup.style = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  199. z-index: 1001; background-color: #2b2b2b; padding: 15px; color: white;
  200. border-radius: 8px; max-height: 80vh; overflow-y: auto; border: 1px solid #595959;`;
  201.  
  202. const title = document.createElement('h3');
  203. title.textContent =
  204. 'Select Pages to Categorize';
  205. title.style = `margin: 0 0 10px; font-family: Arial, sans-serif;`;
  206. popup.appendChild(title);
  207.  
  208. const listContainer =
  209. document.createElement('div');
  210. listContainer.style = `max-height: 300px; overflow-y: auto;`;
  211.  
  212. // Declare lastChecked outside the event listener to keep track of the last clicked checkbox
  213. let lastChecked = null;
  214.  
  215. pageLinks.forEach((link) => {
  216. const listItem =
  217. document.createElement('div');
  218. const checkbox =
  219. document.createElement('input');
  220. checkbox.type = 'checkbox';
  221. checkbox.checked = true;
  222. checkbox.value = link;
  223. listItem.appendChild(checkbox);
  224. listItem.appendChild(
  225. document.createTextNode(` ${link}`),
  226. );
  227. listContainer.appendChild(listItem);
  228.  
  229. checkbox.addEventListener(
  230. 'click',
  231. function (e) {
  232. if (e.shiftKey && lastChecked) {
  233. let inBetween = false;
  234. listContainer
  235. .querySelectorAll(
  236. 'input[type="checkbox"]',
  237. )
  238. .forEach((checkbox) => {
  239. if (
  240. checkbox === this ||
  241. checkbox === lastChecked
  242. ) {
  243. inBetween = !inBetween;
  244. }
  245. if (inBetween) {
  246. checkbox.checked = this.checked;
  247. }
  248. });
  249. }
  250. lastChecked = this;
  251. },
  252. );
  253. });
  254. popup.appendChild(listContainer);
  255.  
  256. const buttonContainer =
  257. document.createElement('div');
  258. buttonContainer.style = `margin-top: 10px; display: flex; justify-content: space-between;`;
  259.  
  260. let allSelected = true;
  261. const selectAllButton =
  262. document.createElement('button');
  263. selectAllButton.textContent = 'Select All';
  264. selectAllButton.style = `padding: 5px 10px; background-color: #5bc0de; border: none;
  265. color: white; cursor: pointer; border-radius: 5px;`;
  266. selectAllButton.onclick = () => {
  267. listContainer
  268. .querySelectorAll(
  269. 'input[type="checkbox"]',
  270. )
  271. .forEach((checkbox) => {
  272. checkbox.checked = allSelected;
  273. });
  274. selectAllButton.textContent = allSelected
  275. ? 'Deselect All'
  276. : 'Select All';
  277. allSelected = !allSelected;
  278. console.log(
  279. allSelected
  280. ? 'Select All clicked: all checkboxes selected.'
  281. : 'Deselect All clicked: all checkboxes deselected.',
  282. );
  283. };
  284. buttonContainer.appendChild(selectAllButton);
  285.  
  286. const confirmButton =
  287. document.createElement('button');
  288. confirmButton.textContent =
  289. 'Confirm Selection';
  290. confirmButton.style = `padding: 5px 10px; background-color: #4caf50;
  291. border: none; color: white; cursor: pointer; border-radius: 5px;`;
  292. confirmButton.onclick = () => {
  293. selectedLinks = Array.from(
  294. listContainer.querySelectorAll(
  295. 'input:checked',
  296. ),
  297. ).map((input) => input.value);
  298. console.log(
  299. 'Confirmed selected links:',
  300. selectedLinks,
  301. );
  302. document.body.removeChild(popup);
  303. if (selectedLinks.length > 0) {
  304. startCategorization();
  305. } else {
  306. alert('No pages selected.');
  307. }
  308. };
  309.  
  310. const cancelPopupButton =
  311. document.createElement('button');
  312. cancelPopupButton.textContent = 'Cancel';
  313. cancelPopupButton.style = `padding: 5px 10px; background-color: #d9534f;
  314. border: none; color: white; cursor: pointer; border-radius: 5px;`;
  315. cancelPopupButton.onclick = () => {
  316. console.log('Popup canceled.');
  317. document.body.removeChild(popup);
  318. };
  319.  
  320. buttonContainer.appendChild(confirmButton);
  321. buttonContainer.appendChild(
  322. cancelPopupButton,
  323. );
  324. popup.appendChild(buttonContainer);
  325.  
  326. document.body.appendChild(popup);
  327. }
  328.  
  329. function startCategorization() {
  330. console.log(
  331. 'Starting categorization process.',
  332. );
  333. isCancelled = false;
  334. isRunning = true;
  335. currentIndex = 0;
  336. document.getElementById(
  337. 'progress-bar-container',
  338. ).style.display = 'block';
  339. fetchCsrfToken(() => processNextPage());
  340. }
  341.  
  342. function processNextPage() {
  343. if (
  344. isCancelled ||
  345. currentIndex >= selectedLinks.length
  346. ) {
  347. console.log(
  348. 'Categorization ended. Reason:',
  349. isCancelled ? 'Cancelled' : 'Completed',
  350. );
  351. isRunning = false;
  352. if (!isCancelled) {
  353. displayCompletionSummary(); // Show summary popup
  354. }
  355. resetUI();
  356. return;
  357. }
  358.  
  359. const pageTitle = selectedLinks[currentIndex];
  360. updateProgressBar(`Processing: ${pageTitle}`);
  361. console.log(`Processing page: ${pageTitle}`);
  362. addCategoryToPage(pageTitle, () => {
  363. currentIndex++;
  364. updateProgressBar(
  365. `Processed: ${pageTitle}`,
  366. );
  367. setTimeout(
  368. processNextPage,
  369. requestInterval,
  370. );
  371. });
  372. }
  373.  
  374. function addCategoryToPage(
  375. pageTitle,
  376. callback,
  377. ) {
  378. const categories = []; // Collects all categories from paginated responses
  379.  
  380. // Function to standardize category names for comparison
  381. function standardizeCategoryName(name) {
  382. return name
  383. .replace(/^Category:/, '') // Remove prefix "Category:"
  384. .replace(/\s+/g, '_') // Replace spaces with underscores
  385. .toLowerCase(); // Convert to lowercase for case-insensitive comparison
  386. }
  387.  
  388. // Recursive function to handle pagination
  389. function fetchCategories(clcontinue) {
  390. const apiUrl = `https://oldschool.runescape.wiki/api.php?action=query&prop=categories&titles=${encodeURIComponent(pageTitle)}&format=json${clcontinue ? `&clcontinue=${clcontinue}` : ''}`;
  391. console.log(
  392. `Checking categories for page: ${pageTitle}${clcontinue ? ` with clcontinue: ${clcontinue}` : ''}`,
  393. );
  394.  
  395. GM_xmlhttpRequest({
  396. method: 'GET',
  397. url: apiUrl,
  398. onload(response) {
  399. const responseJson = JSON.parse(
  400. response.responseText,
  401. );
  402. const page =
  403. responseJson.query.pages[
  404. Object.keys(
  405. responseJson.query.pages,
  406. )[0]
  407. ];
  408.  
  409. // Append the categories from this response to the categories list
  410. if (page.categories) {
  411. categories.push(
  412. ...page.categories.map(
  413. (cat) => cat.title,
  414. ),
  415. );
  416. }
  417.  
  418. // Check if more categories need to be fetched (pagination)
  419. if (
  420. responseJson.continue &&
  421. responseJson.continue.clcontinue
  422. ) {
  423. fetchCategories(
  424. responseJson.continue.clcontinue,
  425. ); // Fetch next page
  426. } else {
  427. // All categories have been fetched
  428. console.log(
  429. `All categories for '${pageTitle}':`,
  430. categories,
  431. );
  432.  
  433. // Standardize target category name
  434. const standardizedCategoryName =
  435. standardizeCategoryName(
  436. `Category:${categoryName}`,
  437. );
  438.  
  439. // Check if page is already categorized
  440. const alreadyCategorized =
  441. categories.some((cat) => {
  442. return (
  443. standardizeCategoryName(cat) ===
  444. standardizedCategoryName
  445. );
  446. });
  447.  
  448. if (alreadyCategorized) {
  449. console.log(
  450. `Page '${pageTitle}' is already in the category.`,
  451. );
  452. actionLog.push(
  453. `Skipped: '${pageTitle}' already in '${categoryName}'`,
  454. );
  455. callback();
  456. } else {
  457. const editUrl =
  458. 'https://oldschool.runescape.wiki/api.php';
  459. const formData =
  460. new URLSearchParams();
  461. formData.append('action', 'edit');
  462. formData.append('title', pageTitle);
  463. formData.append(
  464. 'appendtext',
  465. `\n[[Category:${categoryName}]]`,
  466. );
  467. formData.append('token', csrfToken);
  468. formData.append('format', 'json');
  469.  
  470. GM_xmlhttpRequest({
  471. method: 'POST',
  472. url: editUrl,
  473. headers: {
  474. 'Content-Type':
  475. 'application/x-www-form-urlencoded',
  476. },
  477. data: formData.toString(),
  478. onload(response) {
  479. if (response.status === 200) {
  480. actionLog.push(
  481. `Added: '${pageTitle}' to '${categoryName}'`,
  482. );
  483. console.log(
  484. `Successfully added '${pageTitle}' to category '${categoryName}'.`,
  485. );
  486. callback();
  487. } else {
  488. console.log(
  489. `Failed to add '${pageTitle}' to category.`,
  490. );
  491. callback();
  492. }
  493. },
  494. });
  495. }
  496. }
  497. },
  498. });
  499. }
  500.  
  501. // Start fetching categories (pagination will handle all pages)
  502. fetchCategories();
  503. }
  504.  
  505. function fetchCsrfToken(callback) {
  506. const apiUrl =
  507. 'https://oldschool.runescape.wiki/api.php?action=query&meta=tokens&type=csrf&format=json';
  508. GM_xmlhttpRequest({
  509. method: 'GET',
  510. url: apiUrl,
  511. onload(response) {
  512. const responseJson = JSON.parse(
  513. response.responseText,
  514. );
  515. csrfToken =
  516. responseJson.query.tokens.csrftoken;
  517. console.log(
  518. 'CSRF token fetched:',
  519. csrfToken,
  520. );
  521. callback();
  522. },
  523. });
  524. }
  525.  
  526. function updateProgressBar(status) {
  527. const progressBar = document.getElementById(
  528. 'progress-bar',
  529. );
  530. const progressText = document.getElementById(
  531. 'progress-text',
  532. );
  533. const progress =
  534. (currentIndex / selectedLinks.length) * 100;
  535. progressBar.style.width = `${progress}%`;
  536. progressText.textContent = `${Math.round(progress)}% - ${status}`;
  537. }
  538.  
  539. function displayCompletionSummary() {
  540. console.log('Displaying completion summary.');
  541. const summaryPopup =
  542. document.createElement('div');
  543. summaryPopup.style = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  544. z-index: 1001; background-color: #2b2b2b; padding: 15px; color: white;
  545. border-radius: 8px; max-height: 80vh; overflow-y: auto; border: 1px solid #595959;`;
  546.  
  547. const title = document.createElement('h3');
  548. title.textContent = 'Categorization Summary';
  549. title.style = `margin: 0 0 10px; font-family: Arial, sans-serif;`;
  550. summaryPopup.appendChild(title);
  551.  
  552. const logList = document.createElement('ul');
  553. logList.style =
  554. 'max-height: 300px; overflow-y: auto;';
  555.  
  556. actionLog.forEach((entry) => {
  557. const listItem =
  558. document.createElement('li');
  559. listItem.textContent = entry;
  560. logList.appendChild(listItem);
  561. });
  562.  
  563. summaryPopup.appendChild(logList);
  564.  
  565. const closeButton =
  566. document.createElement('button');
  567. closeButton.textContent = 'Close';
  568. closeButton.style = `margin-top: 10px; padding: 5px 10px; background-color: #4caf50;
  569. border: none; color: white; cursor: pointer; border-radius: 5px;`;
  570. closeButton.onclick = () => {
  571. document.body.removeChild(summaryPopup);
  572. actionLog = [];
  573. };
  574.  
  575. summaryPopup.appendChild(closeButton);
  576. document.body.appendChild(summaryPopup);
  577. }
  578.  
  579. function resetUI() {
  580. document.getElementById(
  581. 'progress-bar',
  582. ).style.width = '0%';
  583. document.getElementById(
  584. 'progress-text',
  585. ).textContent = '';
  586. document.getElementById(
  587. 'progress-bar-container',
  588. ).style.display = 'none';
  589. isRunning = false;
  590. }
  591.  
  592. function cancelCategorization() {
  593. console.log(
  594. 'Categorization cancelled by user.',
  595. );
  596. isCancelled = true;
  597. }
  598.  
  599. addButtonAndProgressBar();
  600. })();