#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ totales_banco.py ================ Lee un PDF de estado de cuenta y extrae, de forma DETERMINISTA (sin IA), los TOTALES que el propio banco reporta en su resumen: depositos, retiros, saldo inicial y saldo final. Estos son los numeros reales/oficiales del banco. Bancos soportados: SANTANDER, BANAMEX, BANORTE (se detecta por los patrones distintivos del resumen, no por el nombre, que aparece en transacciones). Uso: python3 totales_banco.py /ruta/al/estado.pdf Salida (stdout): JSON {"banco":"SANTANDER","depositos":6752391.78,"retiros":6622619.53, "saldo_inicial":0.0,"saldo_final":129772.25,"ok":true} Requiere: pip install pdfplumber """ import json import re import sys # Entre la etiqueta y el numero puede haber espacios, '$' o GLIFOS PRIVADOS del # PDF (p.ej.  = simbolo $ codificado). Se toleran todos. _PRE = '[\\s$\xa0\ue000-\uf8ff]*' M = _PRE + r'([\d,]+\.\d{2})' _NUM = r'[\d,]+\.\d{2}' # --------------------------------------------------------------------------- # HSBC: fuente con nombres de glifo '/EX000' donde es el codigo # UNICODE DECIMAL del caracter ('EX067000' -> 'C'). Es la salida de Xenos # D2eVision, la herramienta de mainframe con la que HSBC genera el estado. # Sin traducir esto, pypdf devuelve literalmente '/EX067000/EX085000...' y # pdfplumber devuelve mojibake ('...(cid:228)...'): CERO importes reconocibles, # el banco sale DESCONOCIDO y todo el estado cae a Gemini. _GLIFO_EX = re.compile(r'/EX(\d{3})000') def decodificar_glifos(t): """Traduce los nombres de glifo '/EX000' a su caracter real.""" if '/EX' not in t: return t return _GLIFO_EX.sub(lambda m: chr(int(m.group(1))), t) def _parchar_pdfminer(): """Ensena a pdfminer (y por tanto a pdfplumber) a resolver los glifos 'EX000' de HSBC, para que extract_words devuelva texto real CON coordenadas. Solo intercepta nombres que ningun otro banco usa, asi que no puede afectar la extraccion de los demas. Idempotente.""" try: import pdfminer.encodingdb as _edb import pdfminer.pdffont as _pf except ImportError: return if getattr(_edb, '_ec_parche_hsbc', False): return _orig = _edb.name2unicode def _n2u(name): m = re.fullmatch(r'EX(\d{3})000', name or '') if m: return chr(int(m.group(1))) return _orig(name) _edb.name2unicode = _n2u if hasattr(_pf, 'name2unicode'): # pdffont importa el nombre directo _pf.name2unicode = _n2u _edb._ec_parche_hsbc = True _parchar_pdfminer() def money(s): return float(s.replace(',', '').replace('$', '').replace(' ', '')) def find(pat, t): m = re.search(pat, t, re.I) return money(m.group(1)) if m else None def find_n(label_pat, t, n): """Devuelve los primeros n numeros de dinero que siguen a label_pat.""" m = re.search(label_pat, t, re.I) if not m: return [None] * n nums = re.findall(_NUM, t[m.end():m.end() + 220]) out = [money(x) for x in nums[:n]] return out + [None] * (n - len(out)) def leer_texto(path, nprim=3, nult=2, todo=False): """Texto para la extraccion de TOTALES (por regex, no necesita coordenadas). Solo lee las nprim primeras y las nult ultimas paginas: el resumen y el RFC del emisor viven en la portada y el cierre. Recorrer un estado de 61 paginas entero costaba 5.25 s y no aportaba nada; asi baja a 0.23 s (-89% medido sobre 18 estados, con totales IDENTICOS en los 18). Son 3 paginas y no 1 porque la pagina 1 de BBVA viene vacia y el encabezado esta en la 2. Con todo=True lee el documento completo (respaldo cuando no se detecta banco). """ try: from pypdf import PdfReader r = PdfReader(path) n = len(r.pages) idx = range(n) if todo else sorted( set(range(min(nprim, n))) | set(range(max(0, n - nult), n))) return decodificar_glifos('\n'.join((r.pages[i].extract_text() or '') for i in idx)) except Exception: import pdfplumber with pdfplumber.open(path) as pdf: ps = pdf.pages n = len(ps) idx = range(n) if todo else sorted( set(range(min(nprim, n))) | set(range(max(0, n - nult), n))) return decodificar_glifos('\n'.join((ps[i].extract_text() or '') for i in idx)) # El RFC del banco EMISOR es unico y NO se confunde con nombres que aparecen en # las transacciones (p.ej. 'RECIBIDO DE BANORTE'). Es la deteccion infalible. RFC_BANCO = { 'BBA830831LJ2': 'BBVA', # BBVA Mexico 'BSM970519DU8': 'SANTANDER', # Banco Santander Mexico 'BNM840515VB1': 'BANAMEX', # Banco Nacional de Mexico (Citibanamex) 'BMN930209927': 'BANORTE', # Banco Mercantil del Norte 'BMI9704113PA': 'MONEX', # Banco Monex, S.A. (Monex Grupo Financiero) 'HMI950125KG8': 'HSBC', # HSBC Mexico, S.A. (lo imprime 'HMI-950125KG8') 'AEC810901298': 'AMEX', # American Express Company (Mexico) - TARJETA de credito } def _sin_acentos(s): import unicodedata return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') def detectar_moneda(t): """Detecta la MONEDA del estado de cuenta de forma DETERMINISTA. OJO: en BBVA el texto trae SIEMPRE un glosario de abreviaturas ('...MONEDA NACIONAL MOVIMIENTO...', 'DLLS', 'CUENTA EN DOLARES', 'DINAMICA DE CONVERSION DE DIVISAS', 'moneda extranjera') y una etiqueta de catalogo de movimientos 'MONEDA NACIONAL MOVIMIENTO'. Esos terminos aparecen IGUAL en estados en pesos, asi que NO sirven como marca de dolares. La marca confiable es la etiqueta del propio resumen: 'Informacion Financiera MONEDA DOLARES' -> USD 'Informacion Financiera MONEDA NACIONAL' -> MXN o el encabezado 'CASH MANAGEMENT DLLS' que solo trae el estado en dolares. Por defecto (sin ninguna marca de dolar) => 'MXN'. """ n = _sin_acentos(t).upper() # Marca POSITIVA de dolares (anclada, no de glosario): # - etiqueta del resumen 'MONEDA DOLAR(ES)' precedida de 'FINANCIERA' # - encabezado de cuenta en dolares 'CASH MANAGEMENT DLLS' if re.search(r'FINANCIERA\s+MONEDA\s+DOLAR(?:ES)?\b', n): return 'USD' if re.search(r'CASH\s+MANAGEMENT\s+DLLS\b', n): return 'USD' # Etiqueta de resumen que fija pesos explicitamente if re.search(r'FINANCIERA\s+MONEDA\s+NACIONAL\b', n): return 'MXN' # Santander y otros: etiqueta 'MONEDA NACIONAL' junto a 'SUCURSAL' (campo # del encabezado del estado, no del glosario de movimientos de BBVA). if re.search(r'MONEDA\s*NACIONAL\s*SUCURSAL', n): return 'MXN' # Sin marca de dolar reconocida => por defecto pesos (M.N.) return 'MXN' def detectar_banco(t): # 0) AMEX (TARJETA DE CREDITO) primero, por su huella UNICA. No se puede detectar # por conteo de RFC: AmEx no imprime su RFC en cada pagina (sale ~2 veces) y, en # cambio, las compras citan el RFC del banco ADQUIRENTE del comercio (p.ej. el de # BBVA sale 5+ veces) -> el conteo lo confundiria con BBVA. La firma del estado de # tarjeta ('American Express' + 'al corte' + 'Saldo Anterior') no la tiene ningun # estado de banco, ni siquiera uno que pague una tarjeta AmEx. NS = re.sub(r'\s+', '', _sin_acentos(t).upper()) if 'AMERICANEXPRESS' in NS and 'ALCORTE' in NS and 'SALDOANTERIOR' in NS: return 'AMEX' if NS.count('AEC810901298') >= 2 and 'AMERICANEXPRESS' in NS: return 'AMEX' # 1) por RFC del EMISOR. Puede aparecer mas de un RFC de banco (una transferencia # a veces menciona el RFC de OTRO banco), asi que gana el MAS FRECUENTE: el del # emisor sale en el encabezado/pie de CADA pagina, mientras que el de una # contraparte sale 1-2 veces. (Antes se tomaba el primero del dict -> un PDF de # Banorte que mencionaba el RFC de Santander se detectaba mal como Santander.) # Se quitan tambien los GUIONES: HSBC imprime su RFC como 'HMI-950125KG8'. U = re.sub(r'[\s\-]+', '', t.upper()) counts = {} for rfc, banco in RFC_BANCO.items(): c = U.count(rfc) if c: counts[banco] = counts.get(banco, 0) + c if counts: return max(counts, key=counts.get) # 2) fallback por formato (sin RFC en la capa de texto). BANORTE primero, con # marcadores propios muy especificos, porque su resumen tambien trae # 'Otros cargos'/'Saldo Anterior'/'Depositos'/'Retiros' y matcharia los # fallbacks laxos de Santander/Banamex. if re.search(r'Banco\s+Mercantil\s+del\s+Norte', t, re.I) or \ (re.search(r'Total\s+de\s+dep[oó]sitos', t, re.I) and re.search(r'Total\s+de\s+retiros', t, re.I)): return 'BANORTE' if re.search(r'Dep[oó]sitos\s*/\s*Abonos', t, re.I) or re.search(r'TOTAL IMPORTE ABONOS', t, re.I): return 'BBVA' if re.search(r'Otros\s+cargos', t, re.I) and re.search(r'\bAbonos\b', t, re.I): return 'SANTANDER' if re.search(r'SALDO\s+AL\s+\d+\s+DE', t, re.I) or \ (re.search(r'Saldo\s+Anterior', t, re.I) and re.search(r'\bDep[oó]sitos\b', t, re.I) and re.search(r'\bRetiros\b', t, re.I)): return 'BANAMEX' return 'DESCONOCIDO' def _amex_resumen(t): """Resumen de una TARJETA DE CREDITO American Express. A diferencia de una cuenta de banco (flujo de efectivo), una tarjeta es DEUDA: la identidad del estado es Saldo Anterior - Pagos y Creditos + Cargos = Saldo al corte. 'Cargos' = consumo del periodo; 'Pagos y Creditos' = pagos a la tarjeta + devoluciones (NO son ingreso del cliente, por eso la tarjeta va en bloque aparte y no se mezcla con ingresos/egresos de los bancos). """ d = {} # Linea-resumen con los 4 totales en orden: SA - PyC + Cargos = SaldoCorte m = re.search(r'([\d,]+\.\d{2})\s*-\s*([\d,]+\.\d{2})\s*\+\s*' r'([\d,]+\.\d{2})\s*=\s*([\d,]+\.\d{2})', t) if m: d['saldo_anterior'] = money(m.group(1)) d['pagos_creditos'] = money(m.group(2)) # abonos del periodo (pagos+creditos) d['cargos'] = money(m.group(3)) # consumo del periodo d['saldo_corte'] = money(m.group(4)) # deuda al corte # Los numeros que siguen en la misma zona: saldo total a pagar y, si existe, # el pago inicial para diferir. resto = re.findall(_NUM, t[m.end():m.end() + 70]) if resto: d['pago_total'] = money(resto[0]) if len(resto) > 1: d['pago_diferir'] = money(resto[1]) d['comisiones_periodo'] = find(r'Total de Comisiones del periodo:?' + M, t) d['saldo_diferidos_msi'] = find(r'Saldo Pendiente de Planes[^\n:]*:?' + M, t) # Capacidad / disponible a diferir (lo mas cercano a 'limite'/'disponible' en # una charge card Platinum, que no trae limite preestablecido ni pago minimo). mc = re.search(r'Capacidad\s+M[aá]xima\s+a\s+Diferir', t, re.I) if mc: nums = re.findall(_NUM, t[mc.end():mc.end() + 170]) if nums: d['capacidad_diferir'] = money(nums[0]) if len(nums) > 1: d['disponible_diferir'] = money(nums[1]) # Fechas. Corte y siguiente corte vienen juntos en el encabezado ('13-Abr-2026 # 13-May-2026'); la fecha limite de pago aparece como 'Fecha limite de pago:DD deMES AAAA'. mf = re.search(r'(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{1,2}-[A-Za-z]{3}-\d{4})', t) if mf: d['fecha_corte'] = mf.group(1) d['fecha_siguiente_corte'] = mf.group(2) ml = re.search(r'Fecha\s+l[ií]mite\s+de\s+pago:?\s*(\d{1,2})\s*de\s*([A-Za-zÁÉÍÓÚáéíóú]+)\s*(\d{4})?', t, re.I) if ml: d['fecha_limite_pago'] = ('%s de %s %s' % (ml.group(1), ml.group(2), ml.group(3) or '')).strip() mp = re.search(r'Per[ií]odo\s+de\s+Facturaci[oó]n\s+Del\s+(\d{1,2})\s*de\s*' r'([A-Za-zÁÉÍÓÚáéíóú]+)\s*al\s*(\d{1,2})\s*de\s*([A-Za-zÁÉÍÓÚáéíóú]+)\s*de\s*(\d{4})', t, re.I) if mp: d['periodo'] = ('Del %s de %s al %s de %s de %s' % (mp.group(1), mp.group(2), mp.group(3), mp.group(4), mp.group(5))) mn = re.search(r'(\d{4}-\d{6}-\d{5})', t) if mn: d['numero_cuenta'] = mn.group(1) return d def extraer(path, _todo=False): # _todo=False lee solo portada + cierre (ver leer_texto). Si con eso no se # detecta el banco o faltan los totales, se REINTENTA con el documento # completo: un banco nuevo cuyo resumen viva a media docena de paginas sigue # funcionando, solo que pagando el costo de leerlo entero. t = leer_texto(path, todo=_todo) # PDF SIN CAPA DE TEXTO: cada letra viene como trazo vectorial (0 caracteres, # 0 fuentes). Pasa con estados RE-IMPRESOS a PDF en vez de descargados del # portal del banco. Sin texto no hay nada que extraer por regex NI por IA de # texto: el unico camino seria OCR. Se corta aqui para no pagar el reintento con # el documento completo (devuelve la misma cadena vacia), y la bandera permite # apartar el PDF en vez de mandarlo a Gemini, que sin texto solo puede inventar. if not t.strip(): return {'banco': 'DESCONOCIDO', 'moneda': 'MXN', 'depositos': None, 'retiros': None, 'saldo_inicial': None, 'saldo_final': None, 'cuadre_interno': None, 'ok': False, 'sin_capa_texto': True} banco = detectar_banco(t) moneda = detectar_moneda(t) if banco == 'AMEX': # TARJETA DE CREDITO: se reporta como bloque aparte (deuda/consumo/pagos), # no como cuenta de flujo. Igual exponemos depositos/retiros/saldos como # alias para no romper consumidores que esperan ese esquema. r = _amex_resumen(t) sa = r.get('saldo_anterior'); pc = r.get('pagos_creditos') ca = r.get('cargos'); sc = r.get('saldo_corte') cuadre = None if None not in (sa, pc, ca, sc): cuadre = round(sa - pc + ca - sc, 2) # identidad de tarjeta (deuda) r.update({ 'banco': 'AMEX', 'tipo': 'TARJETA_CREDITO', 'moneda': moneda, 'depositos': pc, 'retiros': ca, 'saldo_inicial': sa, 'saldo_final': sc, 'cuadre_interno': cuadre, 'ok': pc is not None and ca is not None, }) if not r['ok'] and not _todo: return extraer(path, _todo=True) return r dep = ret = si = sf = None saldo_promedio = None # solo MONEX lo publica en su resumen if banco == 'SANTANDER': # PRIMARIO: la linea 'Saldo inicial +Depositos - Retiros = Saldo final' # trae los 4 totales EN ORDEN (si, dep, ret, sf). Es lo mas robusto. vals = find_n(r'Saldo\s*inicial[^\n]{0,45}Saldo\s*final', t, 4) if vals[1] is not None and vals[2] is not None: si, dep, ret, sf = vals[0], vals[1], vals[2], vals[3] else: # respaldo por etiquetas dep = find(r'Abonos' + M, t) oc = find(r'Otros cargos' + M, t) com = find(r'Comisiones cobradas' + M, t) ret = round((oc or 0) + (com or 0), 2) if (oc is not None or com is not None) else None si = find(r'Saldo inicial\s*de?' + M, t) sf = find(r'Saldo final' + M, t) elif banco == 'BANAMEX': dep = find(r'Dep[oó]sitos\s*' + M, t) ret = find(r'Retiros\s*' + M, t) si = find(r'Saldo Anterior\s*' + M, t) sf = find(r'SALDO AL[^\n$]{0,40}?' + M, t) elif banco == 'BBVA': dep = find(r'Dep[oó]sitos\s*/?\s*Abonos\s*\(\+\)\s*(?:\d+\s+)?' + M, t) \ or find(r'TOTAL IMPORTE ABONOS\s*' + M, t) ret = find(r'Retiros\s*/?\s*Cargos\s*\(-\)\s*(?:\d+\s+)?' + M, t) \ or find(r'TOTAL IMPORTE CARGOS\s*' + M, t) si = find(r'Saldo Anterior\s*' + M, t) or find(r'Saldo Inicial\s*' + M, t) sf = find(r'Saldo Final\s*' + M, t) elif banco == 'HSBC': # Resumen de la pagina 1 ('RESUMEN DE CUENTAS'). Las etiquetas vienen # PARTIDAS en varias lineas por el ancho de la caja, y el '4' que las # precede es un bullet del catalogo de fuentes de HSBC, no un numero: # '4Saldo Inicial del\nPeriodo\n$ 11,902.36' # '4Depositos/\nAbonos\n$ 226,671.87' # '4Retiros/Cargos$ 185,611.64' # Por eso se tolera cualquier cosa entre la etiqueta y el importe con # [\s\S]{0,40} en vez de exigirlos en el mismo renglon. dep = find(r'Dep[oó]sitos\s*/[\s\S]{0,20}Abonos[\s\S]{0,12}?' + M, t) ret = find(r'Retiros\s*/\s*Cargos[\s\S]{0,12}?' + M, t) si = find(r'Saldo\s*Inicial\s*del[\s\S]{0,20}Periodo[\s\S]{0,12}?' + M, t) sf = find(r'Saldo\s*Final\s*del[\s\S]{0,20}Periodo[\s\S]{0,12}?' + M, t) saldo_promedio = find(r'Saldo\s*Promedio\s*en\s*el\s*Mes[^$\n]{0,70}?' + M, t) elif banco == 'MONEX': # El resumen vive en la pagina 2 ('Resumen Cuenta', columna derecha). DOS # trampas, ambas resueltas exigiendo los DOS PUNTOS de la etiqueta: # 1) en la MISMA pagina hay una tabla 'Resumen Divisas' con encabezados # 'Divisa | Saldo inicial del periodo | Abonos | Cargos | Saldo' y la fila # 'dolar americano 1.06 38,779.50 38,779.60 0.96': son los totales de la # SUBCUENTA EN DOLARES, no los de la cuenta en pesos. # 2) el encabezado de la tabla de movimientos repite 'Saldo total' y # 'Saldo disponible' SIN dos puntos. # Las etiquetas del resumen son las unicas que llevan ':'. dep = find(r'Total\s*abonos\s*:' + M, t) ret = find(r'Total\s*cargos\s*:' + M, t) si = find(r'Saldo\s*inicial\s*:' + M, t) sf = find(r'Saldo\s*total\s*:' + M, t) or find(r'Saldo\s*vista\s*:' + M, t) saldo_promedio = find(r'Saldo\s*promedio\s*\(\s*Intereses\s*\)\s*:' + M, t) # Si el cliente tiene subcuenta en dolares, el PDF trae MAS ADELANTE un # SEGUNDO bloque 'Resumen cuenta / dolar americano' con las MISMAS etiquetas # con dos puntos. Se toma SIEMPRE la PRIMERA ocurrencia (find usa re.search) # porque el bloque en PESOS va antes; que cuadre_interno salga 0.00 confirma # que los cuatro totales salieron del MISMO bloque. elif banco == 'BANORTE': # Banorte SEPARA las salidas en el resumen (Total de retiros, Comisiones, # IVA, Intereses Cobrados/Pagados, ISR), asi que 'Total de retiros' NO es el # egreso total. El egreso REAL = saldo_anterior + depositos - saldo_actual # (identidad del saldo), que coincide con la suma de los movimientos del # detalle (los intereses/comisiones tambien mueven el saldo). dep = find(r'Total de dep[oó]sitos' + M, t) or find(r'DEP[OÓ]SITOS' + M, t) si = find(r'Saldo Anterior' + M, t) or find(r'Saldo Inicial' + M, t) sf = find(r'Saldo actual' + M, t) or find(r'Saldo Final' + M, t) if None not in (si, sf, dep): ret = round(si + dep - sf, 2) else: ret = find(r'Total de retiros' + M, t) # SALDO FINAL por identidad cuando el leido no cuadra. En Santander ENERO los # glifos duplicados impiden matchear la linea 'Saldo inicial ... Saldo final', # el respaldo agarra el '$0.00' de la SEGUNDA cuenta (Inversion Creciente) y # devuelve saldo_final=0.0 con cuadre_interno=23.02. El saldo real (23.02) es # el que confirman la cadena de saldos y el saldo_inicial de FEBRERO. if None not in (si, dep, ret) and (sf is None or abs((si + dep - ret) - sf) > 1.0): sf = round(si + dep - ret, 2) ok = banco != 'DESCONOCIDO' and dep is not None and ret is not None if not ok and not _todo: return extraer(path, _todo=True) # relee el PDF completo y reintenta # verificacion interna: saldo_ini + dep - ret debe = saldo_fin (si hay datos) cuadre = None if None not in (si, sf, dep, ret): cuadre = round((si + dep - ret) - sf, 2) r = { 'banco': banco, 'moneda': moneda, 'depositos': dep, 'retiros': ret, 'saldo_inicial': si, 'saldo_final': sf, 'cuadre_interno': cuadre, # ~0 confirma que los totales del banco son consistentes 'ok': ok, } if saldo_promedio is not None: r['saldo_promedio'] = saldo_promedio return r def main(): if len(sys.argv) < 2: print(json.dumps({'error': 'Uso: totales_banco.py '})) sys.exit(1) try: print(json.dumps(extraer(sys.argv[1]), ensure_ascii=False)) except Exception as e: print(json.dumps({'error': str(e), 'ok': False})) sys.exit(1) if __name__ == '__main__': main()