#!/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(`]*>(?${regExpEscape(name)})`, 'i'); /** * Gets the ID of the named item in Eorzea Database. * @param {string} name * @returns {Promise} */ 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 `` 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} */ 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!');