Files
NightSpire/web-next/sql/db2/gen_store_catalog.py
T
Inna 3bc583048b Renombrar rutas /root/NovaWoW -> /root/NightSpire en los scripts DB2
El directorio del proyecto se renombró, así que las rutas absolutas de
sql/db2/*.py y la cabecera de sql/item_data.sql apuntaban a un sitio que ya no
existe.

Fuera del repo (no versionado, se anota aquí): el directorio pasó de
/root/NovaWoW a /root/NightSpire y las unidades systemd de novawow-next /
novawow-dpoints-reconcile a nightspire-*. De paso, nightspire-next.service ya no
declara After/Wants de novawow.service, que era el Django borrado y ya no
existía.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:55:41 +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/NightSpire/.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)