# -*- coding: utf-8 -*- """Maludocs — la tecnología de conversión de la Biblioteca de Maluworld. Convierte el texto de una página en HECHOS (rótulo → valor, con su entidad y su sección) y en TÉRMINOS normalizados. No depende de nada: solo la biblioteca estándar. La Biblioteca la usa al convertir un documento (indice.py); la cata previa la usa sobre cualquier PDF (maludocs_cata.py). Lo que lee bien: una entidad por página en la primera línea; «Rótulo» y debajo «valor con unidad»; «Rótulo: valor»; secciones en MAYÚSCULAS; tablas aplanadas con cabecera y filas; listas de una línea por ítem con segmentos « · »; códigos («Código 1726268»). Lo que no lee: números dentro de una frase, datos solo en imágenes. """ import math import re import unicodedata # ---------------------------------------------------------------- normalizar STOP = set("""a al algo ante antes aqui asi cada como con contra cual cuales cuando de del desde donde dos el ella ellas ellos en entre era es esa esas ese eso esos esta estan este esto estos fue ha hay la las le les lo los mas me mi mis muy nos o os otra otro para pero por que quien se ser si sin sobre su sus te tu tus un una unas uno unos y ya yo cuanto cuantos cuanta cuantas dime dame quiero saber sabes tiene tienen esta estan hay son es van va viene vienen trae traen lleva llevan entran entra""".split()) UNIDADES = {"kg", "kgs", "g", "gr", "cm", "mm", "m", "m3", "micras", "micra", "mils", "pulg", "und", "unid", "unidades", "u", "usd", "l", "lt", "litros", "litro", "ml", "soles", "rollos", "cajas", "camadas", "pct", "s", "un", "pzas", "piezas", "docenas", "kilos"} NUM = r"\d+(?:[.,]\d+)*" NUMEROS_EN_PALABRAS = {"diez": "10", "quince": "15", "veinte": "20", "veinticinco": "25", "treinta": "30", "cuarenta": "40", "cincuenta": "50", "sesenta": "60", "setenta": "70", "ochenta": "80", "noventa": "90", "cien": "100", "ciento": "100", "doscientos": "200", "quinientos": "500", "mil": "1000"} # Preguntas que piden cuentas, comparaciones o listas: nunca se contestan con un hecho suelto. _ANALITICA = re.compile( r"\b(promedio|mediana|media|total(es)?|suma|sumar|todos|todas|comparar|comparacion|subio|" r"bajo|variacion|respecto|diferencia|mayor|menor|caro|barato|quienes|cuales|cuantos " r"(clientes|productos|categorias|documentos|paginas|marcas|modelos)|cuantas (categorias|marcas|paginas)|" r"lista(me|r)?\b.*\b(clientes|productos|modelos)|que (clientes|productos|categorias|modelos)|ranking|top|" r"que (le |les )?(vendemos|vende|compra|compran|tiene|tienen|hay|ofrecemos)|que hay (en|para)|" r"tiene(n)? \w+ en (la )?lista|tenemos \w+ (en|para)|hay \w+ (en|para)|" r"(dos|tres|cuatro|cinco|seis|diez) (pallets?|cajas|paquetes|rollos|contenedores|camadas))\b") def sin_tildes(t): return "".join(c for c in unicodedata.normalize("NFD", t) if unicodedata.category(c) != "Mn") def _raiz(p): if len(p) >= 6 and p.endswith("es"): return p[:-2] if len(p) >= 5 and p.endswith("s"): return p[:-1] return p _UNIDADES_PAL = {"un": 1, "uno": 1, "una": 1, "dos": 2, "tres": 3, "cuatro": 4, "cinco": 5, "seis": 6, "siete": 7, "ocho": 8, "nueve": 9} _DECENAS_PAL = {"diez": 10, "quince": 15, "veinte": 20, "treinta": 30, "cuarenta": 40, "cincuenta": 50, "sesenta": 60, "setenta": 70, "ochenta": 80, "noventa": 90} _COMPUESTO = re.compile(r"\b(?:(ciento|doscientos|trescientos|quinientos)\s+)?(veinte|treinta|cuarenta|cincuenta|" r"sesenta|setenta|ochenta|noventa)(?:\s+y\s+(un|uno|una|dos|tres|cuatro|cinco|seis|siete|ocho|nueve))?\b") def _numeros_en_palabras(t): """«cuarenta y cinco» → 45, «ciento cuarenta» → 140, «veinte» → 20.""" def repl(m): c, d, u = m.groups() n = {"ciento": 100, "doscientos": 200, "trescientos": 300, "quinientos": 500}.get(c, 0) + _DECENAS_PAL[d] + _UNIDADES_PAL.get(u, 0) return str(n) t = _COMPUESTO.sub(repl, t) return re.sub(r"\b(" + "|".join(NUMEROS_EN_PALABRAS) + r")\b", lambda m: NUMEROS_EN_PALABRAS[m.group(1)], t) def tokens(texto, con_stop=False): """Términos normalizados: palabras sin tildes con raíz simple, números con decimales, medidas «45x55» como un solo término (además de sus partes) y «cien» como «100».""" t = _numeros_en_palabras(sin_tildes(texto.lower())).replace("×", "x").replace("’", "'") t = re.sub(r"(?<=\d),(?=\d)", ".", t) # 0,140 → 0.140 t = t.replace("s/", " soles ").replace("$", " usd ").replace("%", " pct ") t = re.sub(r"(\d+(?:\.\d+)?)\s*(?:x|por)\s*(?=\d)", r"\1_", t) # 45 x 55 / 45 por 55 → 45_55 t = re.sub(r"(?<=[a-zñ])(?=\d)|(?<=\d)(?=[a-zñ])", " ", t) # x100unid → x 100 unid salida = [] for tok in re.findall(r"[a-zñ]+|\d+(?:\.\d+)?(?:_\d+(?:\.\d+)?)+|\d+(?:\.\d+)?", t): if "_" in tok: partes = tok.split("_") salida.append("x".join(partes)) salida.extend(partes) continue if tok[0].isdigit(): salida.append(tok) if re.fullmatch(r"[1-9]\d{0,2}(?:\.\d{3})+", tok): # 7.176 → 7176 salida.append(tok.replace(".", "")) continue if tok in NUMEROS_EN_PALABRAS: salida.append(NUMEROS_EN_PALABRAS[tok]) continue if tok == "x" or (not con_stop and tok in STOP) or len(tok) < 2: continue salida.append(_raiz(tok)) return salida # Sinónimos para que «¿cuánto pesa?» encuentre «Peso». Se aplican a la pregunta, con peso menor. _SIN = { "peso": "kg pesa kilo", "pesa": "peso kg", "pesan": "peso kg", "kg": "peso kilo", "kilo": "kg peso", "precio": "cuesta vale fob usd soles cobra cobramos vende vendemos costo", "cuesta": "precio", "vale": "precio", "cobra": "precio", "cobramos": "precio", "vende": "precio", "vendemos": "precio", "vendemo": "precio", "costo": "precio", "espesor": "calibre micras mils micra", "calibre": "espesor micras mils", "micraje": "espesor micras", "grosor": "espesor micras", "unidades": "und unid paquete rollo", "unidad": "und unid paquete", "paquete": "unidades und unid", "medida": "ancho largo alto cm tamano dimension", "medidas": "ancho largo alto cm tamano", "tamano": "medida ancho largo", "dimension": "medida ancho largo alto", "dimensiones": "medida ancho largo alto", "alto": "altura", "altura": "alto", "mide": "medida ancho largo alto", "pallet": "paleta tarima paletizado camada", "paleta": "pallet", "tarima": "pallet", "camada": "pallet cama", "caja": "master", "master": "caja", "material": "polietileno resina reciclado", "litros": "lt l litro", "lt": "litros", "litro": "litros", "codigo": "cod sku", "sku": "codigo", "contenedor": "container", "container": "contenedor", "capacidad": "unidades rollos cajas", "micra": "espesor calibre", "micras": "espesor calibre", "mils": "espesor calibre", "gramaje": "peso espesor", } _FUERTES = {"pesa": "peso", "pesan": "peso", "cuesta": "precio", "vale": "precio", "cobra": "precio", "cobramos": "precio", "vende": "precio", "vendemo": "precio", "vendemos": "precio", "costo": "precio", "mide": "medida", "miden": "medida"} _UNIDAD_CANON = {"litro": "litro", "litros": "litro", "lt": "litro", "lts": "litro", "l": "litro", "unidad": "unidad", "unidades": "unidad", "unid": "unidad", "und": "unidad", "un": "unidad", "u": "unidad", "par": "unidad", "pares": "unidad", "pzas": "unidad", "piezas": "unidad", "cm": "cm", "mm": "cm", "m": "cm", "pulg": "cm", "kg": "kg", "kgs": "kg", "g": "kg", "gr": "kg", "micras": "espesor", "micra": "espesor", "mils": "espesor", "mic": "espesor"} def pares_numero_unidad(texto): """{(número, unidad)} del texto: «140 LT X 100 UNID» → {(140, litro), (100, unidad)}; «45 x 55 cm» → {(45, dim), (55, dim)}; «bolsa de 50 litros por 10» → {(50, litro), (10, None)}. Sirve para que el 50 de «50 litros» no calce con el 50 de «50×70».""" t = _numeros_en_palabras(sin_tildes(texto.lower())).replace("×", " x ") t = re.sub(r"(?<=\d),(?=\d)", ".", t) t = re.sub(r"(?<=[a-zñ])(?=\d)|(?<=\d)(?=[a-zñ])", " ", t) toks = re.findall(r"[a-zñ/]+|\d+(?:\.\d+)?", t) pares = set() for i, tok in enumerate(toks): if not tok[0].isdigit(): continue n = tok.rstrip("0").rstrip(".") if "." in tok else tok sig = toks[i + 1] if i + 1 < len(toks) else "" sig2 = toks[i + 2] if i + 2 < len(toks) else "" ant = toks[i - 1] if i > 0 else "" if sig in _UNIDAD_CANON: pares.add((n, _UNIDAD_CANON[sig])) elif (sig in ("x", "por") and sig2[:1].isdigit() and not (i + 3 < len(toks) and toks[i + 3] in _UNIDAD_CANON and _UNIDAD_CANON[toks[i + 3]] == "unidad")) \ or (ant in ("x", "por") and i >= 2 and toks[i - 2][:1].isdigit() and sig not in _UNIDAD_CANON): pares.add((n, "dim")) else: pares.add((n, None)) return pares def calzan_numeros(q_pares, ent_pares): """Cuántos números sueltos de la pregunta están en la entidad con una unidad compatible.""" ent_por_n = {} for n, u in ent_pares: ent_por_n.setdefault(n, set()).add(u) ok = 0 for n, u in q_pares: if u == "dim": continue us = ent_por_n.get(n) if not us: continue if u is None or u in us or None in us: ok += 1 return ok def expandir(q_tokens, con_origen=False): """[(término, peso)]: los de la pregunta valen 1 (y «pesa» vale como «peso»), los sinónimos 0.5. Con con_origen=True devuelve además {término: palabra de la pregunta de la que salió}.""" vistos = {t: 1.0 for t in q_tokens} origen = {t: t for t in q_tokens} for t in list(q_tokens): if t in _FUERTES: vistos[_FUERTES[t]] = 1.0 origen.setdefault(_FUERTES[t], t) for t in list(vistos): for s in _SIN.get(t, "").split(): if vistos.setdefault(_raiz(s), 0.5) == 0.5: origen.setdefault(_raiz(s), origen.get(t, t)) return (list(vistos.items()), origen) if con_origen else list(vistos.items()) _CUALITATIVA = re.compile(r"^\s*(como|por que|explica|explicame|describe|para que|que significa|que es|" r"en que consiste|cual es la diferencia|que diferencia)\b") def es_analitica(pregunta): """Cuentas, comparaciones, listas o explicaciones: van al modelo, no a un hecho.""" t = re.sub(r"[¿?¡!.,;:]", " ", sin_tildes(pregunta.lower())) t = re.sub(r"\s+", " ", t).strip() return bool(_ANALITICA.search(t) or _CUALITATIVA.search(t)) # ---------------------------------------------------------------- hechos por reglas _VAL_TOK = re.compile(r"^(?:usd|s/|\$)?" + NUM + r"$|^(?:usd|s/|\$)$") _UNID_RE = r"(?:%|kgs?|gr?|cm|mm|m3|m³|micras|mils|pulg|unidades|unid|und|u|litros|lt|l|soles|usd)" _PRODUCTO = re.compile(r"^(bolsa|bolsas|guante|guantes|esponja|esponjas|pa[ñn]o|pa[ñn]os|trapeador|" r"mopa|escoba|escobilla|cepillo|fibra|rollo|manga|film|saco|sacos)\b", re.I) _LETRAS = r"[A-Za-zÁÉÍÓÚÑáéíóúñ]" def es_valor(linea): """«0.140 kg», «45 × 55 cm 17.72 × 21.65 pulg», «$ 0.634», «86 %», «7.176»: una línea hecha solo de números, unidades y separadores, con al menos un número.""" if " · " in linea: return False l = sin_tildes(linea.lower()).replace("×", " x ").replace("%", " pct ").replace('"', " pulg ").replace("''", " pulg ") l = re.sub(r"(?<=\d)(?=[a-z])|(?<=[a-z])(?=\d)", " ", l) # «8g», «x0.8», «45cm» (el PDF pega letras y cifras) toks = [t for t in re.split(r"[\s·]+", l) if t] if not toks or not any(re.match(r"^(?:usd|s/|\$)?\d", t) for t in toks): return False return all(_VAL_TOK.match(t) or t in UNIDADES or t == "x" for t in toks) def es_rotulo(linea): """«Peso bruto», «PESO DEL PAQUETE», «USD por paquete»: corto, con letras, sin números, sin barras de título («MALUPLAST · Documento interno»), y que no termine en un cabo suelto («de»).""" l = linea.strip() return 2 <= len(l) <= 60 and not re.search(r"\d", l) and re.search(_LETRAS + "{2,}", l) \ and not l.endswith((".", ":")) and len(l.split()) <= 7 and " · " not in l \ and (l[:1].isupper() or l[:1] in "¿(«") \ and not re.search(r"\b(de|del|la|el|los|las|y|o|en|con|por|para|a)$", l.lower()) def _valor_num(txt): m = re.search(NUM, txt) if not m: return None s = m.group(0).replace(",", ".") try: if re.fullmatch(r"[1-9]\d{0,2}(?:\.\d{3})+", s): # 7.176 / 1.179 → miles; 0.605 no return float(s.replace(".", "")) partes = s.split(".") return float(partes[0] + "." + "".join(partes[1:])) if len(partes) > 1 else float(s) except ValueError: return None def _unidad(txt): if txt.strip().startswith("$") or "usd" in txt.lower(): return "usd" if "s/" in txt.lower(): return "soles" m = re.search(r"(?:" + NUM + r")\s*([a-zA-Z³/%]+)", sin_tildes(txt)) u = (m.group(1).lower() if m else "").replace("%", "pct") return u if u in UNIDADES or u in ("m³", "pct") else "" def entidad_de(limpias): """De qué habla la página: la línea con más pinta de producto (empieza con «bolsa», «guante»… y trae medidas o unidades); si no, la primera con medidas y letras; si no, el título (primera línea con palabras).""" titulo = next((l for l in limpias if 3 <= len(l) <= 90 and re.search(_LETRAS + "{3,}", l)), "") mejor, mejor_p = "", 0 for i, s in enumerate(limpias): if len(s) > 90 or s.endswith((".", ",", ";")) or not (s[:1].isupper() or s[:1].isdigit()): continue # una frase cortada no es una entidad if not re.search(_LETRAS + "{4,}", s) and not (re.search(r"\d+\s*[x×]\s*\d+", s) and re.search(_LETRAS + "{2,}", s)): continue p = 0 if re.search(r"\d+\s*[x×]\s*\d+", s): p += 2 if _PRODUCTO.match(s): p += 3 if re.search(r"\d+\s*(?:unidades|unid|und|un|u)\b", s, re.I) or re.search(r"c[oó]digo", s, re.I): p += 1 if i < 3: p += 1 if re.search(r"\bpallet\b|\bcaja de\b|\bcamada", s, re.I): p -= 2 if p > mejor_p and p >= 3: mejor, mejor_p = s, p return (mejor or titulo), titulo def _codigos(texto): """Los códigos que nombran a la página: «Código 1726268», «MODELO M01-01-0610x0610-080-NEG». Entran como parte de la entidad, para que preguntar por el código lleve a sus datos.""" return re.findall(r"(?:c[oó]digo|cod\.?|sku|modelo|ref(?:erencia)?)\s*(?:cencosud|erp|interno|de planta)?\s*:?\s*" r"(\d[\d-]{4,}|[A-Za-z]{1,4}\d[\w-]{3,})", texto, re.I) def _es_fila(s): return bool(re.search(r"\d+\s*[x×]\s*\d+", s) and re.search(_LETRAS + "{2,}", s)) or bool(_PRODUCTO.match(s)) def tabla_columnar(limpias): """Tablas aplanadas por el PDF: cabeceras (a veces partidas en dos líneas) y luego, por cada fila, la clave («45 x 55 cm · 20 un») seguida de sus valores, uno por línea. Devuelve [(indice_de_linea_del_valor, entidad, etiqueta, valor)].""" n = len(limpias) filas = [] # (indice_clave, k_valores) i = 0 while i < n: if _es_fila(limpias[i]) and i + 1 < n and es_valor(limpias[i + 1]): j = i + 1 while j < n and es_valor(limpias[j]): j += 1 if j - i - 1 >= 3: filas.append((i, j - i - 1)) i = j continue i += 1 if len(filas) < 2: return [] k = filas[0][1] filas = [f for f in filas if f[1] == k] if len(filas) < 2: return [] # cabeceras: hacia atrás desde la primera fila, solo rótulos o unidades entre paréntesis ini = filas[0][0] cab = [] j = ini - 1 while j >= 0 and (es_rotulo(limpias[j]) or re.fullmatch(r"\(.{1,12}\)", limpias[j]) or re.fullmatch(r"[^\d]{2,40}:\s*[^\d]{2,40}", limpias[j])): cab.append(limpias[j]) j -= 1 cab.reverse() unidas = [] for c in cab: if unidas and (c[0].islower() or c.startswith("(")): unidas[-1] = unidas[-1] + " " + c else: unidas.append(c) if len(unidas) == k + 1: unidas = unidas[1:] if len(unidas) != k: return [] salida = [] for ic, _ in filas: ent = limpias[ic] for m, etiqueta in enumerate(unidas): salida.append((ic + 1 + m, ent, etiqueta, limpias[ic + 1 + m])) return salida def _reunir(limpias): """El PDF parte las líneas largas: «... · sin marca · 1» / «unidades por empaque · precio ...». Si la línea anterior es de segmentos y esta empieza en minúscula, con «·», con moneda o con «equivale», es su continuación.""" salida = [] for s in limpias: prev = salida[-1] if salida else "" nuevo_item = re.match(r"^\d+\.\s+\S", s) # «2. ESCOBA ...» ultimo_seg = prev.rsplit(" · ", 1)[-1] if " · " in prev else "" seg_incompleto = bool(ultimo_seg) and not re.search(r"\d", ultimo_seg) # «... · espesor en micras» if prev and (" · " in prev or prev.endswith("·")) and not nuevo_item and ( s[:1].islower() or s.startswith(("·", "(")) or re.match(r"^(S/|\$|USD)\s*\d", s) or re.search(r"(S/|\$|USD|·|\bde|\bpor|\ba|\bx|×)$", prev) or (seg_incompleto and s[:1].isdigit()) or (re.search(r"\d\s*$", ultimo_seg) and re.match(r"^(und|unid|unidades|cm|mm|pulg|mils|micras|g|gr|kg|lt|litros|x|×)\b", s, re.I)) or re.match(r"^\d+(?:[.,]\d+)?\s*(unidades|unid|und|litros|por|equivale|micras|mils|pulg|cm|mm|kg|g|gr|%)\b", s, re.I)): salida[-1] = prev.rstrip() + " " + s else: salida.append(s) return salida _CONTEO = re.compile(r"\b(por|cantidad|unidad|unidades|und|caja|cajas|rollo|rollos|camada|camadas|nivel|niveles|" r"numero|n[úu]mero|total|paquete|paquetes|pallet|pallets|bolsa|bolsas|pieza|piezas|par|pares|" r"grilla|filas|columnas|dias|d[ií]as|semanas|meses|a[ñn]os|horas|minutos|veces|sku|items?)\b", re.I) _MEDIDA = re.compile(r"\b(medida|medidas|ancho|largo|alto|altura|tama[ñn]o|formato|dimension|dimensiones|grilla|descripcion|descripci[oó]n|" r"bolsa|caja|pallet|producto|rollo|tarima)\b", re.I) def _es_debil(etiqueta, valor): """Un dato que no se contesta por código: número pelado sin unidad ni moneda cuyo rótulo no habla de cantidades («El material: 02», «Resina: 2», «HDPE: 0.91»), o una medida suelta cuyo rótulo no habla de medidas («ESTADO: 45 × 55»). Se indexa igual, pero el modelo decide.""" v = sin_tildes(valor.lower()).replace("×", "x") if re.search(r"(s/|\$|usd|soles)", v): return False if re.search(r"\d\s*(?:%|³|(?!x\b)[a-z]{1,8}\b)", v) or re.search(r"\b(?!x\b)[a-z]{2,}", v): # trae unidad o texto return False if re.search(r"\d\s*x\s*\d", v): return not _MEDIDA.search(etiqueta) return not _CONTEO.search(etiqueta) def hechos_de_pagina(texto, numero=0): """([hecho], entidad, titulo). Cada hecho: entidad, seccion, etiqueta, valor_txt, contexto, origen. `numero` es el número de página: un valor igual a él sin unidad es el pie de página, no un dato.""" limpias = _reunir([l.strip() for l in texto.split("\n") if l.strip()]) entidad, titulo = entidad_de(limpias) crudos = [] # (indice_de_linea, dict) def agrega(i, ent, etiqueta, valor, contexto, origen="regla"): etiqueta = re.sub(r"\s{2,}", " ", etiqueta.strip(" :·-—")).strip() valor = re.sub(r"\s{2,}", " ", valor.strip()).strip().rstrip(".") if origen != "codigo": # un código se cita tal cual valor = re.sub(r"(?<=\d)(?=[a-zA-Z%])|(?<=\b[xX])(?=\d)", " ", valor) # «8g» → «8 g», «x0.8» → «x 0.8» if not etiqueta or not valor or len(valor) > 120: return if numero and valor == str(numero) and origen not in ("segmento", "codigo"): return # el número de página del pie if origen in ("regla", "tabla", "segmento") and _es_debil(etiqueta, valor): origen = "debil" crudos.append((i, {"entidad": ent.strip()[:300], "etiqueta": etiqueta[:300], "valor_txt": valor[:200], "contexto": contexto.strip()[:600], "origen": origen})) usadas = set() # líneas que sirvieron de rótulo (no son secciones) consumidas = set() # líneas ya leídas como tabla for fila in tabla_columnar(limpias): i, ent, etiqueta, valor = fila agrega(i, ent, etiqueta, valor, f"{etiqueta} / {valor}", origen="tabla") consumidas.add(i) for k, s in enumerate(limpias): if k in consumidas: continue # a) «producto · atributo valor · atributo valor» (una línea, varios segmentos) if " · " in s and re.search(r"\d", s): segs = [x.strip() for x in s.split(" · ") if x.strip()] ent = re.sub(r"^\d+\.\s*", "", segs[0]) if len(segs) >= 3 and not re.match(r"^\d", ent) and re.search(_LETRAS + "{3,}", ent): for seg in segs[1:]: mq = re.match(r"^(color|colores|material|marca|presentaci[oó]n|observaci[oó]n|observaciones|acabado|" r"impresi[oó]n|sello|amarre|perfume|empaque|uso|categor[ií]a|tipo)\s*:?\s+(\S.+)$", seg, re.I) if mq and not re.search(r"\d", mq.group(1)): # «color Negro», «observaciones amarre fácil» agrega(k, ent, mq.group(1), mq.group(2), seg, origen="segmento") continue mm = re.match(r"^([^\d]*?)\s*((?:S/|\$|USD)?\s*\d.*)$", seg) if mm and re.search(_LETRAS + "{3,}", mm.group(1)) and es_valor(mm.group(2)): agrega(k, ent, mm.group(1).strip(" :"), mm.group(2).strip(), seg, origen="segmento") continue m = re.search(r"(?:S/\s*|\$\s*|USD\s*)?" + NUM + r"(?:\s*" + _UNID_RE + r"\b)?", seg, re.I) if not m: continue antes, despues = seg[:m.start()].strip(" :"), seg[m.end():].strip(" :") etiqueta = antes if len(tokens(antes)) >= 2 else (antes + " " + despues).strip() if re.search(_LETRAS + "{3,}", etiqueta): agrega(k, ent, etiqueta, m.group(0), seg, origen="segmento") continue # b) «Código 1726268», «CÓDIGO CENCOSUD 2019836», «MODELO M01-01-0610x0610-080-NEG» m = re.search(r"\b(c[oó]digo|cod\.?|sku|modelo|ref(?:erencia)?)\b([^\d\n]{0,25}?)\s*" r"(\d[\d-]{3,}|[A-Za-z]{1,4}\d[\w-]{3,})", s, re.I) if m: agrega(k, entidad, (m.group(1) + " " + m.group(2)).strip(), m.group(3), s, origen="codigo") if es_valor(s.replace(m.group(0), "")): continue # c) «Rótulo: valor» en la misma línea m = re.match(r"^([^:\d]{2,60}):\s*(.+)$", s) if m and es_rotulo(m.group(1)) and es_valor(m.group(2)): agrega(k, entidad, m.group(1), m.group(2), s) usadas.add(k) continue # d) «Rótulo valor» separados por dos o más espacios m = re.match(r"^(.{2,60}?)\s{2,}(.+)$", s) if m and es_rotulo(m.group(1)) and es_valor(m.group(2)): agrega(k, entidad, m.group(1), m.group(2), s) usadas.add(k) continue # e) «Rótulo» en una línea y «valor» en la siguiente (lo más común en los PDF de diseño) if es_valor(s) and k > 0 and es_rotulo(limpias[k - 1]): prev = limpias[k - 1] etiqueta, contexto = prev, prev + " / " + s usadas.add(k - 1) if k > 1 and es_rotulo(limpias[k - 2]) and limpias[k - 2].isupper() and not prev.isupper() \ and re.search(r"\b(usd|soles|s/|kg|g|cm|mm|pct|%|unidades|und|unid|litros|lt)\b", sin_tildes(prev.lower())): # «PRECIO FOB CALLAO» / «USD por paquete» / «$ 0.634»: el rótulo real es el de arriba etiqueta, contexto = limpias[k - 2] + " · " + prev, limpias[k - 2] + " / " + prev + " / " + s usadas.add(k - 2) agrega(k, entidad, etiqueta, s, contexto) continue # f) «RÓTULO EN MAYÚSCULAS» y debajo un texto corto que no es otro rótulo (material, color) if k > 0 and 3 <= len(s) <= 90 and not es_valor(s) and not s.isupper() and not re.search(r"\d", s): prev = limpias[k - 1] cualitativo = re.fullmatch(r"(COLOR|COLORES|MATERIAL|MARCA|ACABADO|TIPO|PRESENTACI[OÓ]N|CATEGOR[IÍ]A|ESTADO|USO|SELLO|AMARRE|PERFUME|IMPRESI[OÓ]N|OBSERVACIONES)", sin_tildes(prev).upper()) # «MARCA / Super Bag / SUBTIPO»: un texto corto encajado entre dos rótulos en mayúsculas es un valor encajado = len(s.split()) <= 3 and (k + 1 == len(limpias) or (es_rotulo(limpias[k + 1]) and limpias[k + 1].isupper())) if es_rotulo(prev) and prev.isupper() and len(prev) >= 4 and len(prev.split()) <= 3 \ and (not es_rotulo(s) or len(s.split()) >= 4 or cualitativo or encajado) and len(s.split()) <= 8: agrega(k, entidad, prev, s, prev + " / " + s, origen="regla_txt") usadas.add(k - 1) # sección de cada hecho: el último rótulo EN MAYÚSCULAS anterior que no fue rótulo de un dato hechos, seccion, secciones = [], "", {} for k, s in enumerate(limpias): if k not in usadas and es_rotulo(s) and s.isupper() and len(s) >= 4: seccion = s secciones[k] = seccion for k, h in crudos: h["seccion"] = "" if h["origen"] == "segmento" else secciones.get(k, "") hechos.append(h) return hechos, entidad, titulo