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" + } +}