diff --git a/bin/validate-links.mts b/bin/validate-links.mts index 5661426..ebe6f0c 100644 --- a/bin/validate-links.mts +++ b/bin/validate-links.mts @@ -1,34 +1,20 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: who gives a fuck bro */ import { hash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { exit } from 'node:process'; +import { closest } from 'fastest-levenshtein'; import { Glob } from 'glob'; import type { TaskTier } from '@/types.js'; +const IMAGE_SEARCH_BASE_URL = + 'https://oldschool.runescape.wiki/?title=Special%3ASearch&profile=advanced&fulltext=1&ns6=1&search='; const CACHE_DIR = './.cache/links'; const IMAGE_CONTENT_TYPE_REGEX = /^image\/(png|gif)/; const HTML_CONTENT_TYPE_REGEX = /^text\/html/; const HTTP_STATUS_OK = 200; -function cacheKey(link: string): string { - return hash('sha1', link); -} - -async function validateLink(link: string, contentTypeRegex: RegExp): Promise { - if (cachedKeys.has(cacheKey(link))) { - return true; - } - - const res = await fetch(link, { method: 'HEAD' }); - const contentType = res.headers.get('Content-Type'); - - if (res.status !== HTTP_STATUS_OK || !contentType?.match(contentTypeRegex)) { - return false; - } - - await writeFile(`${CACHE_DIR}/${cacheKey(link)}`, ''); - return true; -} +const shouldFix = process.argv.slice(2).includes('--fix'); // create cache directory if needed await mkdir(CACHE_DIR, { recursive: true }); @@ -68,8 +54,18 @@ for (const link of wikiLinks) { console.log(`Checking ${imageLinks.size} image links...`); for (const link of imageLinks) { if (!(await validateLink(link, IMAGE_CONTENT_TYPE_REGEX))) { - hasErrors = true; console.error(`- Invalid link: ${link}`); + + if (shouldFix) { + const fixedLink = await proposeImageLinkFix(link); + if (fixedLink) { + replaceLink(link, fixedLink); + console.log(`Fixed with: ${fixedLink}`); + continue; + } + } + + hasErrors = true; } } @@ -77,3 +73,47 @@ if (hasErrors) { console.error('Invalid links found!'); exit(1); } + +function cacheKey(link: string): string { + return hash('sha1', link); +} + +async function validateLink(link: string, contentTypeRegex: RegExp): Promise { + if (cachedKeys.has(cacheKey(link))) { + return true; + } + + const res = await fetch(link, { method: 'HEAD' }); + const contentType = res.headers.get('Content-Type'); + + if (res.status !== HTTP_STATUS_OK || !contentType?.match(contentTypeRegex)) { + return false; + } + + await writeFile(`${CACHE_DIR}/${cacheKey(link)}`, ''); + return true; +} + +async function proposeImageLinkFix(link: string): Promise { + const baseName = link.replace(/^.+?\/\d+px-(.+?)\.\w+$/, '$1'); + const searchTerm = baseName.replaceAll('_', ' '); + + const res = await fetch(`${IMAGE_SEARCH_BASE_URL}${searchTerm}`, { method: 'GET' }); + const body = await res.text(); + + const matchedLinks = [...body.matchAll(/ link.replaceAll(baseName, match[1])); + if (suggestedLinks.length === 0) { + return null; + } + + return closest(link, suggestedLinks); +} + +async function replaceLink(oldLink: string, newLink: string) { + for await (const tierFile of tierWalker) { + const tierText = (await readFile(tierFile)).toString(); + + await writeFile(tierFile, tierText.replaceAll(oldLink, newLink)); + } +} diff --git a/package-lock.json b/package-lock.json index e7458d3..b294d73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/node": "^26.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "fastest-levenshtein": "^1.0.16", "fracturedjsonjs": "^4.1.1", "glob": "^11.1.0", "json-schema-to-typescript": "^15.0.4", @@ -931,6 +932,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/package.json b/package.json index 2dcbe38..3c0f8c4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/node": "^26.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "fastest-levenshtein": "^1.0.16", "fracturedjsonjs": "^4.1.1", "glob": "^11.1.0", "json-schema-to-typescript": "^15.0.4", diff --git a/tiers/easy.json b/tiers/easy.json index e3eaa6e..8d195bb 100644 --- a/tiers/easy.json +++ b/tiers/easy.json @@ -2114,7 +2114,7 @@ "shortName": "Tempoross", "tip": "Can be received directly from Tempoross' reward pool or by combining the Angler outfit pieces with 1,200 Spirit flakes, if you have one. The Dragon harpoon does not count.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2130,7 +2130,7 @@ "shortName": "Tempoross", "tip": "Can be received directly from Tempoross' reward pool or by combining the Angler outfit pieces with 1,200 Spirit flakes, if you have one. The Dragon harpoon does not count.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2146,7 +2146,7 @@ "shortName": "Tempoross", "tip": "Can be received directly from Tempoross' reward pool or by combining the Angler outfit pieces with 1,200 Spirit flakes, if you have one. The Dragon harpoon does not count.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2162,7 +2162,7 @@ "shortName": "Tempoross", "tip": "Can be received directly from Tempoross' reward pool or by combining the Angler outfit pieces with 1,200 Spirit flakes, if you have one. The Dragon harpoon does not count.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", diff --git a/tiers/elite.json b/tiers/elite.json index 18ef8a3..2d4f67c 100644 --- a/tiers/elite.json +++ b/tiers/elite.json @@ -3488,7 +3488,7 @@ "shortName": "Doom", "tip": "Giant, insect-like, demonic boss found in the Tonali Cavern after completion of The Final Dawn quest.", "wikiLink": "https://oldschool.runescape.wiki/w/Doom_of_Mokhaiotl", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_ayak_detail.png/320px-Eye_of_ayak_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_Ayak_detail.png/320px-Eye_of_Ayak_detail.png", "displayItemId": 31115, "verification": { "method": "collection-log", @@ -3502,7 +3502,7 @@ "shortName": "Doom", "tip": "Giant, insect-like, demonic boss found in the Tonali Cavern after completion of The Final Dawn quest.", "wikiLink": "https://oldschool.runescape.wiki/w/Doom_of_Mokhaiotl", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_ayak_detail.png/320px-Eye_of_ayak_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_Ayak_detail.png/320px-Eye_of_Ayak_detail.png", "displayItemId": 31115, "verification": { "method": "collection-log", diff --git a/tiers/hard.json b/tiers/hard.json index 8669dc0..58361a4 100644 --- a/tiers/hard.json +++ b/tiers/hard.json @@ -2689,7 +2689,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2913,7 +2913,7 @@ "shortName": "ToA", "tip": "Can be received as a reward from the Tombs of Amascut raid", "wikiLink": "https://oldschool.runescape.wiki/w/Tombs_of_Amascut", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_elidinis_detail.png/320px-Thread_of_elidinis_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_Elidinis_detail.png/320px-Thread_of_Elidinis_detail.png", "displayItemId": 27279, "verification": { "method": "collection-log", @@ -2930,7 +2930,7 @@ "shortName": "ToA", "tip": "Can be received as a reward from the Tombs of Amascut raid", "wikiLink": "https://oldschool.runescape.wiki/w/Tombs_of_Amascut", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_elidinis_detail.png/320px-Thread_of_elidinis_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_Elidinis_detail.png/320px-Thread_of_Elidinis_detail.png", "displayItemId": 27279, "verification": { "method": "collection-log", @@ -2947,7 +2947,7 @@ "shortName": "ToA", "tip": "Can be received as a reward from the Tombs of Amascut raid", "wikiLink": "https://oldschool.runescape.wiki/w/Tombs_of_Amascut", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_elidinis_detail.png/320px-Thread_of_elidinis_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Thread_of_Elidinis_detail.png/320px-Thread_of_Elidinis_detail.png", "displayItemId": 27279, "verification": { "method": "collection-log", @@ -3392,7 +3392,7 @@ "shortName": "Doom", "tip": "Giant, insect-like, demonic boss found in the Tonali Cavern after completion of The Final Dawn quest.", "wikiLink": "https://oldschool.runescape.wiki/w/Doom_of_Mokhaiotl", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_ayak_detail.png/320px-Eye_of_ayak_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_Ayak_detail.png/320px-Eye_of_Ayak_detail.png", "displayItemId": 31115, "verification": { "method": "collection-log", @@ -3406,7 +3406,7 @@ "shortName": "Doom", "tip": "Giant, insect-like, demonic boss found in the Tonali Cavern after completion of The Final Dawn quest.", "wikiLink": "https://oldschool.runescape.wiki/w/Doom_of_Mokhaiotl", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_ayak_detail.png/320px-Eye_of_ayak_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_Ayak_detail.png/320px-Eye_of_Ayak_detail.png", "displayItemId": 31115, "verification": { "method": "collection-log", diff --git a/tiers/master.json b/tiers/master.json index 9e0fa9a..c8efee1 100644 --- a/tiers/master.json +++ b/tiers/master.json @@ -3077,7 +3077,7 @@ "name": "Get 1 unique from Doom of Mokhaiotl", "tip": "Giant, insect-like, demonic boss found in the Tonali Cavern after completion of The Final Dawn quest.", "wikiLink": "https://oldschool.runescape.wiki/w/Doom_of_Mokhaiotl", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_ayak_detail.png/320px-Eye_of_ayak_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Eye_of_Ayak_detail.png/320px-Eye_of_Ayak_detail.png", "displayItemId": 31115, "verification": { "method": "collection-log", diff --git a/tiers/medium.json b/tiers/medium.json index 3d0cb79..0f44c9d 100644 --- a/tiers/medium.json +++ b/tiers/medium.json @@ -1850,7 +1850,7 @@ "shortName": "Mask of Ranul", "tip": "Can be received as a drop from Undead Druids.", "wikiLink": "https://oldschool.runescape.wiki/w/Mask_of_ranul", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Mask_of_ranul_detail.png/320px-Mask_of_ranul_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Mask_of_Ranul_detail.png/320px-Mask_of_Ranul_detail.png", "displayItemId": 23522, "verification": { "method": "collection-log", @@ -2004,7 +2004,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2020,7 +2020,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2036,7 +2036,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2066,7 +2066,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2082,7 +2082,7 @@ "shortName": "Tempoross", "tip": "Can be received as a reward from the Tempoross Skilling boss.", "wikiLink": "https://oldschool.runescape.wiki/w/Tempoross", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_water_(empty)_detail.png/320px-Tome_of_water_(empty)_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Tome_of_Water_(empty)_detail.png/320px-Tome_of_Water_(empty)_detail.png", "displayItemId": 25574, "verification": { "method": "collection-log", @@ -2924,7 +2924,7 @@ "shortName": "Medallion Deep", "tip": "Assembled from eight fragments, which are found on different islands and through different methods. Check the wiki for more information.", "wikiLink": "https://oldschool.runescape.wiki/w/Medallion_of_the_deep", - "imageLink": "https://oldschool.runescape.wiki/images/thumb/Medallion_of_the_deep_detail.png/320px-Medallion_of_the_deep_detail.png", + "imageLink": "https://oldschool.runescape.wiki/images/thumb/Medallion_of_the_Deep_detail.png/320px-Medallion_of_the_Deep_detail.png", "displayItemId": 32386, "verification": { "method": "collection-log",