diff --git a/scripts/error-codes/generate.mjs b/scripts/error-codes/generate.mjs
index 26e3b9d2..3df826fa 100644
--- a/scripts/error-codes/generate.mjs
+++ b/scripts/error-codes/generate.mjs
@@ -112,13 +112,24 @@ async function fetchPage(url) {
// ── HTML helpers ─────────────────────────────────────────────────────────────
-const decode = (s) => s
- .replace(/
/gi, " ")
- .replace(/<\/(p|li|div)>/gi, " ")
- .replace(/<[^>]+>/g, "")
- .replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
- .replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
- .replace(/\s+/g, " ").trim();
+const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'" };
+
+/**
+ * Cell markup → plain text. Tags are stripped until none remain (a nested "ipt>" cannot
+ * survive), and entities are decoded in ONE pass so "<" yields "<", never "<".
+ */
+const decode = (s) => {
+ let text = s.replace(/
/gi, " ").replace(/<\/(p|li|div)>/gi, " ");
+ let previous;
+ do {
+ previous = text;
+ text = text.replace(/<[^>]*>/g, "");
+ } while (text !== previous);
+ return text
+ .replace(/&(nbsp|amp|lt|gt|quot|#39|#x27);/g, (_, name) => ENTITIES[name])
+ .replace(/\s+/g, " ")
+ .trim();
+};
/** Every of a page as rows of decoded cell strings, in document order. */
function parseTables(html) {