Files
NightSpire/web-next/sql/db2/gen_store_catalog.py
T
Inna 24248bc603 store: el árbol de categorías, igual que el original (colores y sin fantasmas)
Investigadas las 750 categorías de la BD contra el HTML real del original. Los
ítems estaban perfectos (554 hojas, 3068 ítems, mismo contenido en cada una: no
faltaba ninguno). Todo lo roto era de categorías:

- 45 CATEGORÍAS FANTASMA, todas con nombre de código ("11-1", "23-10"). El seed
  dedujo el árbol de los códigos de las hojas ("11-1-1" => padre "11-1"), pero
  el original NO siempre pinta ese nivel: en 45 hojas el "-1-" del código no
  corresponde a nada y cuelgan directas de su raíz. Ahora se lee el anidamiento
  real (<ul>/<li>): 705 categorías (20 + 131 + 554) en vez de 750.
- El nombre de una categoría NO es texto plano, es HTML, y lo estábamos
  aplanando: 141 nombres perdían su color y 40 cabeceras el suyo. Hay dos
  mecanismos y los dos hacen falta: la clase en la CABECERA, que sustituye a
  first-brown para colorear la categoría entera ("Brujo" en color de brujo), y
  los <span class="no-toggle X"> dentro del nombre (clases y las etiquetas
  [Nivel 232 a 245] / (DPS) en otro tono).
- 21 nombres TRUNCADOS en Tokens de Armadura: guardábamos "Guerrero" donde el
  original dice "Guerrero, Sacerdote, Druida" (solo se quedó el primer span).

El HTML se trocea en el SERVIDOR (`parseCategoryName`) y al cliente le llegan
solo texto + nombre de clase, así que no hay HTML de la BD entrando en el DOM.
El texto sin color va suelto, NO envuelto en <span>: el tema tiene
`span { color: #fff }` y lo pondría blanco en vez de heredar el de la cabecera.

`name` pasa a VARCHAR(512): con el HTML, el más largo mide 198 y no cabía en 191.
El name_en se genera en el propio seed traduciendo solo el TEXTO y respetando
las etiquetas, así que sql/store_category_en.sql sobra y se borra.
gen_store_catalog.py regenera el seed entero (categorías del HTML, ítems de la
BD, que ya estaban verificados) y es reproducible byte a byte.

Verificado en el navegador contra el original: mismo árbol, 0 categorías con
nombre de código, "[Nivel 232 a 245]" en marrón tenue y los colores de clase
oficiales (Guerrero #C79C6E, Sacerdote blanco, Druida #FF7D0A). En inglés
también, conservando los colores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:19 +00:00

213 lines
8.5 KiB
Python

#!/usr/bin/env python
"""Regenera sql/store_catalog.sql desde el HTML real del store original.
USO: /root/NovaWoW/.venv/bin/python web-next/sql/db2/gen_store_catalog.py
(necesita /root/store1.html y la BD, para los ítems)
Por qué se regenera desde el HTML y no se parchea la BD: el seed anterior dedujo
el árbol de los CÓDIGOS de las hojas ("11-1-1" => padre "11-1"), y eso inventa
categorías fantasma. En el original, 45 hojas cuelgan DIRECTAS de su raíz: el
"-1-" del código no corresponde a ningún nivel pintado. Aquí se lee el
anidamiento de verdad (<ul>/<li>).
Además el nombre de una categoría NO es texto plano, lleva HTML:
- color en la cabecera: <span class="store-subcat warlock">Brujo</span>
- color dentro del nombre: <span class="no-toggle warrior">Guerrero</span>, ...
- etiquetas: Armas PvE <span class="no-toggle third-brown">[Nivel 232 a 245]</span>
Se conserva tal cual (columna `name`) + la clase de la cabecera (`css`). El
inglés (`name_en`) se genera traduciendo SOLO el texto y respetando las etiquetas.
Los ÍTEMS se vuelcan de la BD, no del HTML: ya están verificados contra el
original (554 hojas, 3068 ítems, mismo contenido en cada una).
"""
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from translate_categories import tr # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, '..', 'store_catalog.sql')
SRC = '/root/store1.html'
TOKEN = re.compile(
r'<span class="store-(cat|subcat|sub-subcat) ([a-z0-9\- ]+)">'
r'|<div class="item-list" id="([0-9\-]+)">'
)
SPAN_TAG = re.compile(r'</?span\b')
def span_inner(s, start):
"""Contenido de un <span>, respetando anidamiento."""
i = s.index('>', start) + 1
depth, j = 1, i
while True:
m = SPAN_TAG.search(s, j)
if not m:
return s[i:]
if m.group(0) == '<span':
depth += 1
else:
depth -= 1
if depth == 0:
return s[i:m.start()]
j = m.end()
def clean(name):
name = re.sub(r'^\s*<i class="fas fa-angle-right green-info"></i>\s*', '', name)
return re.sub(r'\s+', ' ', name).strip()
def parse_tree(html):
"""[(code, parent, name_html, css, depth)] en orden de documento."""
out, seen = [], set()
root = sub = pending = None
for m in TOKEN.finditer(html):
kind, cls, code = m.group(1), m.group(2), m.group(3)
if kind:
entry = (clean(span_inner(html, m.start())), cls)
if kind == 'cat':
root, sub, pending = entry, None, None
elif kind == 'subcat':
sub, pending = entry, None
else:
pending = entry
elif code and pending:
p = code.split('-')
if len(p) != 3:
continue
rc, sc = p[0], f'{p[0]}-{p[1]}'
if root and rc not in seen:
seen.add(rc); out.append((rc, None, root[0], root[1], 1))
# La subcategoría solo existe si el HTML la pinta de verdad.
if sub and sc not in seen:
seen.add(sc); out.append((sc, rc, sub[0], sub[1], 2))
if code not in seen:
seen.add(code)
out.append((code, sc if sub else rc, pending[0], pending[1], 3))
pending = None
return out
# Traducción respetando el HTML. Solo hay dos formas en los datos reales:
# A) "TEXTO <span class="no-toggle second|third-brown">(SUFIJO)</span>"
# B) "<span class="no-toggle CLASE">Clase</span>, <span ...>..."
TAG_A = re.compile(r'^(.*?)\s*<span class="no-toggle ([a-z\-]+)">([\(\[].*?[\)\]])</span>$')
SPAN_B = re.compile(r'<span class="no-toggle ([a-z\-]+)">([^<]+)</span>')
SUFIJO = re.compile(r'\s*([\(\[][^\)\]]*[\)\]])\s*$')
def tr_html(name):
"""Traduce el nombre conservando las etiquetas. None si no sabe."""
if '<' not in name:
return tr(name)
m = TAG_A.match(name)
if m:
# Se traduce la frase ENTERA (el traductor tiene reglas de frase: "Nivel
# de objeto 232 (DPS)"), y luego se vuelve a envolver el sufijo.
base, cls, _tag = m.groups()
full = tr(f'{base} {_tag}'.strip())
if not full:
return None
s = SUFIJO.search(full)
if not s:
return full
return f'{full[:s.start()].strip()} <span class="no-toggle {cls}">{s.group(1)}</span>'
if name.startswith('<span'):
# Lista de clases: se traduce cada una por separado.
def one(mm):
t = tr(mm.group(2))
return f'<span class="no-toggle {mm.group(1)}">{t or mm.group(2)}</span>'
return SPAN_B.sub(one, name)
return None
def esc(s):
return s.replace('\\', '\\\\').replace("'", "''")
def db_items():
"""Ítems desde la BD (ya verificados contra el original)."""
env = os.path.join(HERE, '..', '..', '.env.local')
cfg = dict(re.findall(r'^(\w+)=(.*)$', open(env, encoding='utf-8').read(), re.M))
q = ('SELECT category_code,item_id,name,IFNULL(icon,""),quantity,currency,price,'
'IFNULL(preview,""),sort FROM home_store_item ORDER BY sort,id')
out = subprocess.run(
['mysql', '-h', cfg.get('DB_HOST', '127.0.0.1'), '-u', cfg['DB_USER'],
f'-p{cfg["DB_PASSWORD"]}', '--default-character-set=utf8mb4', '-N', '--raw',
cfg.get('DB_NAME_DEFAULT', 'django_wow'), '-e', q],
capture_output=True, text=True, check=True)
return [l.split('\t') for l in out.stdout.splitlines() if l]
cats = parse_tree(open(SRC, encoding='utf-8', errors='replace').read())
items = db_items()
sin_tr = [c for c in cats if tr_html(c[2]) is None]
print(f'categorías: {len(cats)} | ítems: {len(items)} | sin traducir: {len(sin_tr)}', file=sys.stderr)
for c in sin_tr[:10]:
print(' sin traducir:', c[0], c[2][:60], file=sys.stderr)
with open(OUT, 'w', encoding='utf-8') as f:
f.write("""-- Catálogo de la tienda (categorías + ítems), extraído del sistema antiguo (BENNU).
-- NO editar a mano: regenerar con `sql/db2/gen_store_catalog.py` (ver su cabecera).
--
-- El nombre de una categoría es HTML a propósito: el original colorea los
-- nombres de clase y pone las etiquetas ([Nivel 232 a 245], (DPS)) en otro tono.
-- `css` es la clase de la CABECERA, que a veces sustituye a first-brown para
-- colorear la categoría entera (p.ej. "Brujo" en color de brujo).
SET NAMES utf8mb4;
DROP TABLE IF EXISTS home_store_item;
DROP TABLE IF EXISTS home_store_category;
CREATE TABLE home_store_category (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE,
parent_code VARCHAR(32) NULL,
-- 512 y no 191: los nombres llevan HTML y el más largo pasa de 191.
name VARCHAR(512) NOT NULL COMMENT 'HTML: puede llevar <span class="no-toggle X">',
name_en VARCHAR(512) DEFAULT NULL COMMENT 'igual que name, en inglés; NULL = usar name',
css VARCHAR(32) NOT NULL DEFAULT 'first-brown' COMMENT 'clase de la cabecera',
depth TINYINT NOT NULL,
sort INT NOT NULL,
KEY k_parent (parent_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE home_store_item (
id INT AUTO_INCREMENT PRIMARY KEY,
category_code VARCHAR(32) NOT NULL,
item_id INT NOT NULL,
name VARCHAR(191) NOT NULL,
icon VARCHAR(128) NULL,
quantity INT NOT NULL DEFAULT 1,
currency ENUM('pd','pv') NOT NULL DEFAULT 'pd',
price INT NOT NULL,
preview VARCHAR(64) NULL,
sort INT NOT NULL,
KEY k_cat (category_code),
KEY k_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
CH = 200
for i in range(0, len(cats), CH):
f.write('INSERT INTO home_store_category (code,parent_code,name,name_en,css,depth,sort) VALUES\n')
rows = []
for n, (code, parent, name, css, depth) in enumerate(cats[i:i + CH], start=i):
en = tr_html(name)
rows.append("('%s',%s,'%s',%s,'%s',%d,%d)" % (
esc(code), f"'{esc(parent)}'" if parent else 'NULL', esc(name),
f"'{esc(en)}'" if en else 'NULL', esc(css), depth, n))
f.write(',\n'.join(rows) + ';\n')
for i in range(0, len(items), CH):
f.write('INSERT INTO home_store_item (category_code,item_id,name,icon,quantity,currency,price,preview,sort) VALUES\n')
rows = []
for cc, iid, nm, ic, qty, cur, pr, pv, so in items[i:i + CH]:
rows.append("('%s',%s,'%s',%s,%s,'%s',%s,%s,%s)" % (
esc(cc), iid, esc(nm), f"'{esc(ic)}'" if ic else 'NULL', qty, cur, pr,
f"'{esc(pv)}'" if pv else 'NULL', so))
f.write(',\n'.join(rows) + ';\n')
print(f'escrito {OUT}', file=sys.stderr)