3bc583048b
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>
213 lines
8.5 KiB
Python
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)
|