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>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
#!/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)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Traduce los nombres de home_store_category al inglés -> sql/store_category_en.sql.
|
||||
"""Traducción al inglés de los nombres de categoría de la tienda.
|
||||
|
||||
USO: mysql ... -N -e 'SELECT DISTINCT name FROM home_store_category' \\
|
||||
| /root/NovaWoW/.venv/bin/python sql/db2/translate_categories.py
|
||||
Es una LIBRERÍA: la usa `gen_store_catalog.py` para el `name_en` del seed.
|
||||
Ejecutándolo se comprueban traducciones sueltas (nombres por stdin).
|
||||
|
||||
Prioridad: términos OFICIALES sacados de los DB2 del cliente (ChrClasses,
|
||||
ItemSet, ItemSubClass) y de los nombres bilingües de home_item_data. Solo se
|
||||
@@ -163,40 +163,13 @@ def tr(name):
|
||||
return None # sin traducir
|
||||
|
||||
|
||||
def esc(s):
|
||||
return s.replace('\\', '\\\\').replace("'", "''")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Modo comprobación: pásale nombres por stdin y dice cómo los traduce.
|
||||
# El seed lo genera `gen_store_catalog.py`, que importa `tr` de aquí.
|
||||
names = [l.rstrip('\n') for l in sys.stdin if l.strip()]
|
||||
if not names:
|
||||
sys.exit('Pásame los nombres por stdin:\n'
|
||||
" mysql ... -N -e 'SELECT DISTINCT name FROM home_store_category' | "
|
||||
'python translate_categories.py')
|
||||
out, missing = {}, []
|
||||
sys.exit('Uso: ... | python translate_categories.py (comprueba traducciones)\n'
|
||||
'Para regenerar el catálogo: python gen_store_catalog.py')
|
||||
for n in names:
|
||||
t = tr(n)
|
||||
(missing.append(n) if t is None else out.__setitem__(n, t))
|
||||
if missing:
|
||||
print('SIN TRADUCIR (%d):' % len(missing), file=sys.stderr)
|
||||
for m in missing:
|
||||
print(' ', m, file=sys.stderr)
|
||||
bad = {k: v for k, v in out.items() if not v or 'None' in str(v)}
|
||||
if bad:
|
||||
sys.exit(f'ERROR: traducciones corruptas: {bad}')
|
||||
|
||||
with open(OUT, 'w', encoding='utf-8') as f:
|
||||
f.write("""-- Nombres en inglés de las categorías de la tienda. NO editar a mano:
|
||||
-- regenerar con sql/db2/translate_categories.py (ver cabecera del script).
|
||||
-- Los términos del JUEGO salen de los DB2 del cliente (ChrClasses, ItemSet,
|
||||
-- ItemSubClass): así casan con lo que ve un jugador en inglés. Solo se traduce
|
||||
-- a mano lo que la tienda se inventó y no existe en el juego.
|
||||
|
||||
ALTER TABLE `home_store_category`
|
||||
ADD COLUMN `name_en` varchar(191) DEFAULT NULL COMMENT 'nombre en inglés; NULL = usar name' AFTER `name`;
|
||||
|
||||
""")
|
||||
for k, v in sorted(out.items()):
|
||||
f.write("UPDATE `home_store_category` SET `name_en` = '%s' WHERE `name` = '%s';\n"
|
||||
% (esc(v), esc(k)))
|
||||
print(f'escritas {len(out)} categorías en {OUT}', file=sys.stderr)
|
||||
print('%-52s -> %s' % (n, t if t else '*** SIN TRADUCIR ***'))
|
||||
|
||||
Reference in New Issue
Block a user