From 64eb4da8941fd4999de4d72cd3e36437671844c9 Mon Sep 17 00:00:00 2001
From: ewin <git@ewin.moe>
Date: Thu, 27 Mar 2025 09:55:19 -0400
Subject: [PATCH] Initial commit

---
 .gitignore        |   1 +
 edb-id-bot.mjs    | 161 ++++++++++++++++++++++++++++++++++++++++++++++
 package-lock.json |  59 +++++++++++++++++
 package.json      |   7 ++
 4 files changed, 228 insertions(+)
 create mode 100644 .gitignore
 create mode 100755 edb-id-bot.mjs
 create mode 100644 package-lock.json
 create mode 100644 package.json

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/edb-id-bot.mjs b/edb-id-bot.mjs
new file mode 100755
index 0000000..86427bb
--- /dev/null
+++ b/edb-id-bot.mjs
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+
+import {execSync} from 'node:child_process';
+import makeFetchCookie from 'fetch-cookie';
+
+const fetchWithCookies = makeFetchCookie(fetch);
+
+/**
+ * @see https://stackoverflow.com/a/6969486
+ * @param {string}
+ * @returns {string}
+ */
+const regExpEscape = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/**
+ * Creates a regular expression that matches a link to the named item and
+ * captures its EDB ID from the matched link's `href` attribute.
+ * @param {string} name
+ * @returns {RegExp}
+ */
+const itemLinkRegExp = name => new RegExp(`<a href="/lodestone/playguide/db/item/(?<id>[a-z0-9]+)[^"]+"[^>]*>(?<name>${regExpEscape(name)})</a>`, 'i');
+
+/**
+ * Gets the ID of the named item in Eorzea Database.
+ * @param {string} name
+ * @returns {Promise<string | undefined>}
+ */
+async function findItemID (name) {
+	// execute a search for the item's name
+	const searchURL = `https://na.finalfantasyxiv.com/lodestone/playguide/db/item/?q=${encodeURIComponent(name)}`;
+	const response = await fetchWithCookies(searchURL);
+	const body = await response.text();
+	// find an `<a>` in the HTML response whose text exactly matches the name
+	const match = body.match(itemLinkRegExp(name));
+	// return the ID parsed from the URL in the `href` attribute
+	return match?.groups.id;
+}
+
+/**
+ * Gets the current contents of the named item's wiki page and returns the
+ * contents with the infobox updated to use the given EDB item ID.
+ * @param {string} name
+ * @returns {Promise<string>}
+ */
+async function getWikiPageContents (name) {
+	const response = await fetchWithCookies(`https://ffxiv.consolegameswiki.com/mediawiki/index.php?action=raw&title=${encodeURIComponent(name)}`);
+	const rawContents = await response.text();
+	return rawContents;
+}
+
+/**
+ * Matches an empty `id-edb` infobox parameter which can just have a value
+ * inserted after it.
+ */
+const existingEmptyEDBIDParameter = /^(\s*\|\s*id-edb\s*=)\s*$/m;
+
+/**
+ * Matches an `id-gt` infobox parameter above which we can insert an `id-edb`
+ * parameter above. Whitespace in capture groups so the inserted parameter can
+ * use the exact same formatting.
+ */
+const existingGTIDParameter = /^(\s*)\|(\s*)id-gt(\s*)=(\s*).*$/m;
+
+/**
+ * Inserts the `id-edb` item infobox parameter into the given page contents.
+ * @param {string} pageContent
+ * @param {string} edbID
+ * @returns {string}
+ */
+function insertInfoboxEDBID (pageContent, edbID) {
+	if (pageContent.includes(`edb-id = ${edbID}`)) {
+		console.log('what????', pageContent);
+		throw new Error('Page seems to already contain its own EDB ID??');
+	}
+	if (pageContent.match(existingEmptyEDBIDParameter)) {
+		console.log('Page has existing empty `id-edb` parameter');
+		return pageContent.replace(existingEmptyEDBIDParameter, `$1 ${edbID}`);
+	}
+	if (pageContent.match(existingGTIDParameter)) {
+		console.log('Page has `id-gt` parameter, inserting `id-edb` above that');
+		return pageContent.replace(existingGTIDParameter, `$1|$2id-edb$3=$4${edbID}\n$&`);
+	}
+	console.log('bad', pageContent);
+	throw new Error('Dunno how to insert the parameter into this page');
+}
+
+const wikiAPI = 'https://ffxiv.consolegameswiki.com/mediawiki/api.php';
+
+/**
+ * Gets the list of wiki pages from "Category:Missing EDB ID".
+ * @returns {Promise<{pageid: number; title: string}[]>}
+ */
+async function getItemPagesWithNoEDBID () {
+	const response = await fetchWithCookies(`https://ffxiv.consolegameswiki.com/mediawiki/api.php?${new URLSearchParams({
+		action: 'query',
+		list: 'categorymembers',
+		cmlimit: 500,
+		cmtitle: 'Category:Missing EDB ID',
+		format: 'json',
+	})}`);
+	const body = await response.json();
+	if (body.error) {
+		throw new Error(`[${body.error.code}] ${body.error.info}`);
+	}
+	return body.query.categorymembers;
+}
+
+/** terrible terrible terrible string diff helper for debugging */
+function diff (a, b) {
+	// base64 input strings before passing to shell to avoid escaping issues
+	// https://stackoverflow.com/a/60221847
+	// also use `|| true` to not throw an error when `diff` returns non-zero
+	execSync(`bash -c '
+		diff --color -u <(echo ${btoa(a)} | base64 -d) <(echo ${btoa(b)} | base64 -d)
+	' || true`, {
+		// display result directly in terminal
+		stdio: 'inherit',
+	});
+}
+
+/**
+ * Given an item name, looks up its EDB ID and edits its wiki page to include
+ * that ID in the item infobox if it doesn't already.
+ */
+async function processItem (name) {
+	console.log('Page:', name);
+	const edbID = await findItemID(name);
+	if (!edbID) {
+		console.log('No EDB ID found for this item, skipping');
+		return;
+	}
+	console.log('EDB ID:', edbID, `(https://na.finalfantasyxiv.com/lodestone/playguide/db/item/${encodeURIComponent(edbID)})`);
+
+	let updatedText;
+	try {
+		const originalText = await getWikiPageContents(name);
+		updatedText = insertInfoboxEDBID(originalText, edbID);
+		diff(originalText, updatedText);
+	} catch (error) {
+		console.log(error);
+		console.log('not doing anything with this item');
+		return;
+	}
+
+	// TODO: actually submit wiki edit
+}
+
+const itemPagesWithoutEDBIDs = await getItemPagesWithNoEDBID();
+console.log('Looking up EDB IDs of', itemPagesWithoutEDBIDs.length, 'items\n');
+
+for (const {title} of itemPagesWithoutEDBIDs) {
+	await processItem(title);
+
+	console.log();
+
+	// this runs serially with an artificial delay between requests to decrease
+	// the chance of sqenix sending ninjas to my house
+	await new Promise(resolve => setTimeout(resolve, 5000));
+}
+
+console.log('done!');
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..5872b59
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,59 @@
+{
+  "name": "ffxiv-wiki-edb-id-script",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ffxiv-wiki-edb-id-script",
+      "dependencies": {
+        "fetch-cookie": "^3.1.0"
+      }
+    },
+    "node_modules/fetch-cookie": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.1.0.tgz",
+      "integrity": "sha512-s/XhhreJpqH0ftkGVcQt8JE9bqk+zRn4jF5mPJXWZeQMCI5odV9K+wEWYbnzFPHgQZlvPSMjS4n4yawWE8RINw==",
+      "license": "Unlicense",
+      "dependencies": {
+        "set-cookie-parser": "^2.4.8",
+        "tough-cookie": "^5.0.0"
+      }
+    },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+      "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+      "license": "MIT"
+    },
+    "node_modules/tldts": {
+      "version": "6.1.85",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.85.tgz",
+      "integrity": "sha512-gBdZ1RjCSevRPFix/hpaUWeak2/RNUZB4/8frF1r5uYMHjFptkiT0JXIebWvgI/0ZHXvxaUDDJshiA0j6GdL3w==",
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^6.1.85"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "6.1.85",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.85.tgz",
+      "integrity": "sha512-DTjUVvxckL1fIoPSb3KE7ISNtkWSawZdpfxGxwiIrZoO6EbHVDXXUIlIuWympPaeS+BLGyggozX/HTMsRAdsoA==",
+      "license": "MIT"
+    },
+    "node_modules/tough-cookie": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+      "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^6.1.32"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0018801
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "ffxiv-wiki-edb-id-script",
+  "private": true,
+  "dependencies": {
+    "fetch-cookie": "^3.1.0"
+  }
+}