Upload 3 files
Browse files- extraer_movimientos.py +406 -0
- fill_template.py +8 -3
- totales_banco.py +104 -4
extraer_movimientos.py
CHANGED
|
@@ -650,6 +650,376 @@ def _amex(pdf, mes_anio):
|
|
| 650 |
return movs
|
| 651 |
|
| 652 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
def extraer(path):
|
| 654 |
import pdfplumber
|
| 655 |
rep = tb.extraer(path)
|
|
@@ -690,6 +1060,10 @@ def extraer(path):
|
|
| 690 |
movs = _bbva(pdf)
|
| 691 |
elif banco == 'BANORTE':
|
| 692 |
movs = _banorte(pdf)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
elif bandas:
|
| 694 |
movs = _por_bandas(pdf, bandas) # Santander (bandas fijas)
|
| 695 |
else:
|
|
@@ -699,6 +1073,37 @@ def extraer(path):
|
|
| 699 |
rd, rr = rep['depositos'], rep['retiros']
|
| 700 |
cuadra = (rd is not None and rr is not None
|
| 701 |
and abs(suma_dep - rd) <= 1.0 and abs(suma_ret - rr) <= 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
# transacciones en el FORMATO que consume el flujo (igual que Gemini):
|
| 703 |
# fecha YYYY-MM-DD, descripcion, cargos(retiro), abonos(deposito), saldo, moneda
|
| 704 |
tx_flujo = []
|
|
@@ -715,6 +1120,7 @@ def extraer(path):
|
|
| 715 |
'banco': banco, 'moneda': moneda, 'ok': bool(cuadra), 'cuadra': bool(cuadra),
|
| 716 |
'suma_dep': suma_dep, 'suma_ret': suma_ret, 'rep_dep': rd, 'rep_ret': rr,
|
| 717 |
'saldo_inicial': rep.get('saldo_inicial'), 'saldo_final': rep.get('saldo_final'),
|
|
|
|
| 718 |
'n': len(movs), 'transacciones': tx_flujo,
|
| 719 |
}
|
| 720 |
|
|
|
|
| 650 |
return movs
|
| 651 |
|
| 652 |
|
| 653 |
+
# ---- MONEX -----------------------------------------------------------------
|
| 654 |
+
# La tabla de MONEX tiene OCHO columnas y CUATRO de ellas son numericas y
|
| 655 |
+
# CONTIGUAS: 'Movimiento garantia'(x1~533), 'Saldo en garantia'(x1~609),
|
| 656 |
+
# 'Saldo disponible'(x1~688) y 'Saldo total'(x1~764). Por eso las bandas se leen
|
| 657 |
+
# del ENCABEZADO de CADA pagina y no se fijan a mano: la subcuenta en dolares que
|
| 658 |
+
# MONEX imprime al final del mismo estado usa OTRAS coordenadas (Abonos x1~380 en
|
| 659 |
+
# vez de ~367, Cargos ~452 en vez de ~447) y encima parte el encabezado en DOS
|
| 660 |
+
# renglones.
|
| 661 |
+
_MX_FECHA = re.compile(r'^(\d{1,2})/([A-Za-z]{3})$')
|
| 662 |
+
# Holgura de banda. Los importes van alineados a la DERECHA exactamente al x1 del
|
| 663 |
+
# encabezado (medido en MARZO+ABRIL: las 722 celdas de dinero de las columnas
|
| 664 |
+
# Abonos/Cargos/'Saldo total' dan x1_monto - x1_encabezado = 0.0 pt EXACTO), y la
|
| 665 |
+
# columna numerica vecina mas cercana esta a 76 pt.
|
| 666 |
+
_MX_TOL = 8.0
|
| 667 |
+
# Hueco vertical minimo que SEPARA dos bloques de movimiento. Medido en
|
| 668 |
+
# MARZO+ABRIL: dentro del bloque 2.0-9.8 pt, entre bloques 16.8-19.4 pt.
|
| 669 |
+
_MX_SEP = 13.0
|
| 670 |
+
_MX_PERIODO = re.compile(
|
| 671 |
+
r'PERIODO\s*:?\s*Del\s+(\d{1,2})\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})\s+al\s+'
|
| 672 |
+
r'(\d{1,2})\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})', re.I)
|
| 673 |
+
# Ruido de plomeria del bloque: identifica la operacion ante el banco pero no
|
| 674 |
+
# describe el movimiento ni aporta el NOMBRE de la contraparte. Se quita SOLO la
|
| 675 |
+
# etiqueta con su valor numerico, nunca texto con nombres.
|
| 676 |
+
_MX_RUIDO = [
|
| 677 |
+
re.compile(r'Clave\s+de\s+Rastreo\s*:?\s*[\w/]*', re.I),
|
| 678 |
+
re.compile(r'Referencia\s+Numerica\s*:?\s*\d*', re.I),
|
| 679 |
+
re.compile(r'Cuenta\s+(?:beneficiaria|ordenante)\s*:?\s*\d*', re.I),
|
| 680 |
+
re.compile(r'Fecha\s+Confirmaci\w*\s*n?\s*de\s+[Ll]iquidaci\w*\s*n?\s*:?\s*'
|
| 681 |
+
r'[\d\-]*\s*[\d:]*', re.I),
|
| 682 |
+
re.compile(r'Dato\s+no\s+verificado\s+por\s+esta\s+institu\S*', re.I),
|
| 683 |
+
re.compile(r'Email:\S+', re.I),
|
| 684 |
+
re.compile(r'\b\d{15,}\b'),
|
| 685 |
+
]
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def _monex_periodo(pdf):
|
| 689 |
+
"""mes->anio del PERIODO de la hoja 1 ('PERIODO: Del 1 Marzo 2026 al 31 marzo
|
| 690 |
+
2026'): meses en ESPANOL y con mayuscula inconsistente, y sin anio en la fecha
|
| 691 |
+
de cada movimiento ('02/Mar'). Los meses FUERA del periodo se fechan hacia
|
| 692 |
+
ATRAS desde su inicio (igual que en BBVA), para que una operacion rezagada de
|
| 693 |
+
diciembre dentro del estado de enero no se vaya al anio SIGUIENTE."""
|
| 694 |
+
t = ' '.join((p.extract_text() or '') for p in pdf.pages[:3])
|
| 695 |
+
a = {}
|
| 696 |
+
y1 = None
|
| 697 |
+
m = _MX_PERIODO.search(t)
|
| 698 |
+
if m:
|
| 699 |
+
m1, y1 = _MESES_ES.get(_up(m.group(2))), int(m.group(3))
|
| 700 |
+
m2, y2 = _MESES_ES.get(_up(m.group(5))), int(m.group(6))
|
| 701 |
+
if m2:
|
| 702 |
+
a[m2] = y2
|
| 703 |
+
if m1:
|
| 704 |
+
a[m1] = y1
|
| 705 |
+
y, mth = y1, m1
|
| 706 |
+
for _ in range(11):
|
| 707 |
+
mth -= 1
|
| 708 |
+
if mth == 0:
|
| 709 |
+
mth, y = 12, y - 1
|
| 710 |
+
a.setdefault(mth, y)
|
| 711 |
+
else:
|
| 712 |
+
# respaldo: 'Peso Mexicano al 31 Marzo 2026' del resumen de la hoja 2
|
| 713 |
+
m2 = re.search(r'\bal\s+\d{1,2}\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})', t, re.I)
|
| 714 |
+
if m2 and _MESES_ES.get(_up(m2.group(1))):
|
| 715 |
+
y1 = int(m2.group(2))
|
| 716 |
+
a[_MESES_ES[_up(m2.group(1))]] = y1
|
| 717 |
+
ys = re.findall(r'\b(20\d{2})\b', t)
|
| 718 |
+
return a, (y1 or (int(ys[0]) if ys else 2025))
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def _monex_hdr(words):
|
| 722 |
+
"""Encabezado de la tabla de movimientos -> (bandas, zonas, top_fin), o None
|
| 723 |
+
si la pagina no es de la tabla.
|
| 724 |
+
|
| 725 |
+
Se exige la firma COMPLETA (Descripcion + Referencia + Abonos + Cargos +
|
| 726 |
+
'Saldo total'): la hoja del 'Resumen Divisas' tambien trae las palabras
|
| 727 |
+
'Abonos', 'Cargos' y 'Saldo total:', y con una puerta laxa se colaria.
|
| 728 |
+
|
| 729 |
+
Como se distingue 'Saldo total'(x1~764) de las otras tres columnas numericas
|
| 730 |
+
contiguas: por su ETIQUETA COMPLETA, no por su posicion. La etiqueta se
|
| 731 |
+
reconstruye a partir del par de palabras SALDO + TOTAL, aceptando las dos
|
| 732 |
+
formas en que MONEX la imprime -- CONTIGUAS en el mismo renglon
|
| 733 |
+
('Saldo'@723/745 + 'total'@747/764, hueco 1.9 pt) o APILADAS en dos renglones
|
| 734 |
+
con las cajas solapadas en x ('Saldo'@742/763 arriba, 'total'@746/763 abajo,
|
| 735 |
+
en la tabla en dolares). Al exigir que la 2a palabra sea literalmente 'total',
|
| 736 |
+
'Saldo disponible'(688), 'Saldo en garantia'(609) y 'Movimiento
|
| 737 |
+
garantia'(533) quedan descartadas aunque esten pegadas."""
|
| 738 |
+
anc = [w for w in words
|
| 739 |
+
if _up(w['text']).rstrip(':') == 'DESCRIPCION' and w['x0'] < 220]
|
| 740 |
+
if not anc:
|
| 741 |
+
return None
|
| 742 |
+
desc = anc[0]
|
| 743 |
+
hdr = [w for w in words if abs(w['top'] - desc['top']) <= 18]
|
| 744 |
+
|
| 745 |
+
def uno(nombre):
|
| 746 |
+
c = [w for w in hdr if _up(w['text']) == nombre]
|
| 747 |
+
return c[0] if c else None
|
| 748 |
+
|
| 749 |
+
ref, ab, ca = uno('REFERENCIA'), uno('ABONOS'), uno('CARGOS')
|
| 750 |
+
if not (ref and ab and ca):
|
| 751 |
+
return None
|
| 752 |
+
sal = None
|
| 753 |
+
for t in [w for w in hdr if _up(w['text']) == 'TOTAL']:
|
| 754 |
+
for s in [w for w in hdr if _up(w['text']) == 'SALDO']:
|
| 755 |
+
contiguo = abs(s['top'] - t['top']) <= 2 and 0 <= (t['x0'] - s['x1']) <= 3.5
|
| 756 |
+
apilado = (abs(s['top'] - t['top']) > 2
|
| 757 |
+
and min(s['x1'], t['x1']) > max(s['x0'], t['x0']))
|
| 758 |
+
if contiguo or apilado:
|
| 759 |
+
x1 = max(s['x1'], t['x1'])
|
| 760 |
+
sal = x1 if sal is None else max(sal, x1)
|
| 761 |
+
if sal is None:
|
| 762 |
+
return None
|
| 763 |
+
bandas = {'dep': ab['x1'], 'ret': ca['x1'], 'sal': sal}
|
| 764 |
+
# zonas en x: DESCRIPCION va del inicio de su encabezado al de 'Referencia';
|
| 765 |
+
# la FECHA vive a la izquierda de la descripcion ('Fechas Liquidacion
|
| 766 |
+
# (Pactada)', x0~30).
|
| 767 |
+
zonas = {'d0': desc['x0'] - 4, 'd1': ref['x0'] - 2, 'f1': desc['x0'] - 6}
|
| 768 |
+
return bandas, zonas, max(w['top'] for w in hdr) + 3
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def _monex(pdf):
|
| 772 |
+
"""MONEX: layout multilinea con la fila del IMPORTE en MEDIO del bloque de
|
| 773 |
+
descripcion (hay lineas de descripcion ANTES y DESPUES), la fecha EN LA MISMA
|
| 774 |
+
fila que los importes, y bloques que CRUZAN de pagina.
|
| 775 |
+
|
| 776 |
+
Tres trampas propias de MONEX:
|
| 777 |
+
1) Abonos Y Cargos se imprimen LOS DOS en cada fila (uno en '0.00'), asi que
|
| 778 |
+
la regla de los otros bancos ('trae deposito Y retiro a la vez -> es fila
|
| 779 |
+
de totales, ignorar') borraria TODOS los movimientos. Aqui un movimiento
|
| 780 |
+
real es abonos>0 XOR cargos>0. Las filas 0.00/0.00 (deposito de intereses
|
| 781 |
+
que el ISR netea el mismo dia) no mueven el saldo y no son movimiento.
|
| 782 |
+
2) El estado trae al final una SUBCUENTA EN DOLARES con la MISMA tabla y
|
| 783 |
+
otras coordenadas. Se corta la lectura al cerrar la cuenta en pesos.
|
| 784 |
+
3) La fecha viene 'DD/Mmm' SIN anio (ver _monex_periodo).
|
| 785 |
+
"""
|
| 786 |
+
mes_anio, anio_def = _monex_periodo(pdf)
|
| 787 |
+
filas = [] # (ipag, top, row, bandas, zonas)
|
| 788 |
+
for ip, page in enumerate(pdf.pages):
|
| 789 |
+
words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| 790 |
+
if not words:
|
| 791 |
+
continue
|
| 792 |
+
T = _up(' '.join(w['text'] for w in words))
|
| 793 |
+
# La cuenta en PESOS es la PRIMERA del estado. Un segundo bloque 'Resumen
|
| 794 |
+
# cuenta' o un titulo 'Movimientos de <mes> EN <divisa>' despues de haber
|
| 795 |
+
# empezado a leer movimientos = SUBCUENTA EN OTRA DIVISA (MONEX cierra el
|
| 796 |
+
# estado con 'dolar americano': misma tabla, importes en USD, y su propia
|
| 797 |
+
# cadena de saldos que arranca otra vez desde su saldo inicial). Sumarla
|
| 798 |
+
# contaminaria los totales en pesos.
|
| 799 |
+
if filas and (re.search(r'RESUMEN\s+CUENTA', T)
|
| 800 |
+
or re.search(r'MOVIMIENTOS\s+DE\s+\w+\s+EN\s+\S', T)):
|
| 801 |
+
break
|
| 802 |
+
h = _monex_hdr(words)
|
| 803 |
+
if not h:
|
| 804 |
+
continue
|
| 805 |
+
bandas, zonas, hdr_fin = h
|
| 806 |
+
pw = {}
|
| 807 |
+
for w in words:
|
| 808 |
+
if w['top'] > hdr_fin:
|
| 809 |
+
pw.setdefault(round(w['top'], 1), []).append(w)
|
| 810 |
+
fin_tabla = False
|
| 811 |
+
for top in sorted(pw):
|
| 812 |
+
row = sorted(pw[top], key=lambda x: x['x0'])
|
| 813 |
+
# Etiquetas de la propia tabla, impresas en la ZONA DE COLUMNAS
|
| 814 |
+
# NUMERICAS (a la derecha de la descripcion): no son movimiento ni
|
| 815 |
+
# descripcion. 'Saldo final:' CIERRA la tabla de la cuenta en pesos.
|
| 816 |
+
etiq = _up(' '.join(w['text'] for w in row
|
| 817 |
+
if w['x0'] >= zonas['d1'] and not MONEY.match(w['text'])))
|
| 818 |
+
if etiq.startswith(('SALDO FINAL', 'TOTAL')):
|
| 819 |
+
fin_tabla = True
|
| 820 |
+
break
|
| 821 |
+
if etiq.startswith('SALDO INICIAL') or etiq.startswith('HOJA'):
|
| 822 |
+
continue # fila de apertura y pie de pagina 'Hoja N de M'
|
| 823 |
+
filas.append((ip, top, row, bandas, zonas))
|
| 824 |
+
if fin_tabla:
|
| 825 |
+
break
|
| 826 |
+
|
| 827 |
+
# 1) filas de IMPORTE: fecha 'DD/Mmm' en la columna de fechas + monto en la
|
| 828 |
+
# banda Abonos o Cargos. Se guarda tambien el 'Saldo total' de la fila.
|
| 829 |
+
imp = {}
|
| 830 |
+
for i, (_ip, _top, row, bandas, zonas) in enumerate(filas):
|
| 831 |
+
fk = None
|
| 832 |
+
for w in row:
|
| 833 |
+
if w['x0'] < zonas['f1']:
|
| 834 |
+
fk = fk or _MX_FECHA.match(w['text'])
|
| 835 |
+
if not fk or _up(fk.group(2)) not in _MESES:
|
| 836 |
+
continue
|
| 837 |
+
d = r = s = None
|
| 838 |
+
for w in row:
|
| 839 |
+
if not MONEY.match(w['text']):
|
| 840 |
+
continue
|
| 841 |
+
v = _num(w['text'])
|
| 842 |
+
if abs(w['x1'] - bandas['dep']) <= _MX_TOL: d = v
|
| 843 |
+
elif abs(w['x1'] - bandas['ret']) <= _MX_TOL: r = v
|
| 844 |
+
elif abs(w['x1'] - bandas['sal']) <= _MX_TOL: s = v
|
| 845 |
+
if d is None and r is None:
|
| 846 |
+
continue
|
| 847 |
+
imp[i] = (fk, d or 0.0, r or 0.0, s)
|
| 848 |
+
|
| 849 |
+
# 2) reparto de las lineas de DESCRIPCION entre filas de importe consecutivas.
|
| 850 |
+
# El corte va en el HUECO VERTICAL MAS GRANDE que hay entre las dos: dentro
|
| 851 |
+
# de un bloque el interlineado es 9.8 pt (y 2-7 pt alrededor de la fila del
|
| 852 |
+
# importe, que MONEX centra en el bloque) y entre bloques 16.8-19.4 pt
|
| 853 |
+
# (medido en los 160 pares de filas de importe que caen en la misma hoja de
|
| 854 |
+
# MARZO+ABRIL: intra-bloque max 9.8, separador min 16.8).
|
| 855 |
+
# El bloque CRUZA de pagina (medido: el bloque de MARZO hoja 3 top=554
|
| 856 |
+
# sigue con 10 renglones al inicio de la hoja 4), asi que un salto de
|
| 857 |
+
# pagina NO es corte por si mismo. Pero cuando el bloque siguiente ARRANCA
|
| 858 |
+
# justo en la primera fila de la hoja no hay hueco separador que encontrar:
|
| 859 |
+
# si el mayor hueco de las dos hojas se queda por debajo de _MX_SEP, el
|
| 860 |
+
# corte ES el salto de pagina (sin esto, el ultimo movimiento de la hoja se
|
| 861 |
+
# quedaba con la primera linea del bloque siguiente).
|
| 862 |
+
idx = sorted(imp)
|
| 863 |
+
corte = {}
|
| 864 |
+
for a, b in zip(idx, idx[1:]):
|
| 865 |
+
mejor = (-1.0, None)
|
| 866 |
+
salto = None
|
| 867 |
+
for j in range(a, b):
|
| 868 |
+
if filas[j][0] != filas[j + 1][0]:
|
| 869 |
+
salto = j # ultima fila antes de cambiar de hoja
|
| 870 |
+
continue
|
| 871 |
+
hueco = filas[j + 1][1] - filas[j][1]
|
| 872 |
+
if hueco > mejor[0]:
|
| 873 |
+
mejor = (hueco, j)
|
| 874 |
+
if mejor[0] >= _MX_SEP or salto is None:
|
| 875 |
+
corte[a] = mejor[1] if mejor[1] is not None else a
|
| 876 |
+
else:
|
| 877 |
+
corte[a] = salto
|
| 878 |
+
|
| 879 |
+
movs = []
|
| 880 |
+
for k, i in enumerate(idx):
|
| 881 |
+
ini = corte[idx[k - 1]] + 1 if k else 0
|
| 882 |
+
fin = corte[i] if k + 1 < len(idx) else len(filas) - 1
|
| 883 |
+
fk, d, r, s = imp[i]
|
| 884 |
+
if (d > 0) == (r > 0):
|
| 885 |
+
continue # 0.00/0.00 (intereses neteados por el ISR el mismo dia)
|
| 886 |
+
# La descripcion sale de la COLUMNA DE DESCRIPCION (x0 entre el inicio de
|
| 887 |
+
# su encabezado y el de 'Referencia', ~104-240). No hace falta filtrar
|
| 888 |
+
# tokens con formato de dinero: la columna numerica mas a la izquierda
|
| 889 |
+
# (Abonos) arranca en x0>=325, asi que nada de ahi cae en la banda, y en
|
| 890 |
+
# cambio si aparecen importes que SON parte del texto ('Compra de divisas
|
| 891 |
+
# 5.80 USD a un tipo de cambio de 18.1235').
|
| 892 |
+
desc = []
|
| 893 |
+
for j in range(ini, fin + 1):
|
| 894 |
+
z = filas[j][4]
|
| 895 |
+
for w in filas[j][2]:
|
| 896 |
+
if z['d0'] <= w['x0'] < z['d1']:
|
| 897 |
+
desc.append(w['text'])
|
| 898 |
+
txt = re.sub(r'\s{2,}', ' ', ' '.join(desc))
|
| 899 |
+
for rx in _MX_RUIDO:
|
| 900 |
+
txt = rx.sub(' ', txt)
|
| 901 |
+
txt = re.sub(r'\s{2,}', ' ', txt).strip(' ,;:')
|
| 902 |
+
mnum = _MESES[_up(fk.group(2))]
|
| 903 |
+
movs.append({
|
| 904 |
+
'fecha': '%02d-%s-%d' % (int(fk.group(1)), _up(fk.group(2)),
|
| 905 |
+
mes_anio.get(mnum, anio_def)),
|
| 906 |
+
'concepto': txt[:220],
|
| 907 |
+
'deposito': d if d > 0 else None,
|
| 908 |
+
'retiro': r if r > 0 else None,
|
| 909 |
+
'saldo': s,
|
| 910 |
+
})
|
| 911 |
+
return movs
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
# ---- HSBC -------------------------------------------------------------------
|
| 915 |
+
# HSBC imprime DOS veces los mismos importes: en 'DETALLE MOVIMIENTOS CUENTA DE
|
| 916 |
+
# CHEQUES' (la contabilidad real) y otra vez en las tablas informativas
|
| 917 |
+
# "Informacion SPEI's Enviados/Recibidos durante el periodo", que repiten cada
|
| 918 |
+
# transferencia con su 'Monto del pago'. Leer las dos DUPLICA los importes: es
|
| 919 |
+
# exactamente lo que hacia Gemini. Aqui se extrae SOLO el DETALLE, cortando la
|
| 920 |
+
# seccion en cuanto aparece cualquier otro bloque.
|
| 921 |
+
_HSBC_PERIODO = re.compile(
|
| 922 |
+
r'Per[ií]odo\s*del\s*(\d{2})/(\d{2})/(\d{4})\s*al\s*(\d{2})/(\d{2})/(\d{4})', re.I)
|
| 923 |
+
# Encabezado de la tabla de movimientos. Coordenadas medidas identicas en los 6
|
| 924 |
+
# estados: Retiro/Cargo x1=397.0, Deposito/Abono x1=481.4, Saldo x1=549.1. Las
|
| 925 |
+
# etiquetas van CENTRADAS sobre su columna y los importes ALINEADOS A LA DERECHA,
|
| 926 |
+
# asi que el offset etiqueta->importe no es constante (+4 en cargo, -9 en abono,
|
| 927 |
+
# +16 en saldo): de ahi la tolerancia amplia. Las columnas estan a 68-84 pt entre
|
| 928 |
+
# si, asi que +-22 no puede solaparlas.
|
| 929 |
+
_HSBC_TOL = 22.0
|
| 930 |
+
# Todo lo que CIERRA la seccion de movimientos.
|
| 931 |
+
# Encabezados de OTRAS secciones. Se ancla al INICIO de la fila y solo se evalua
|
| 932 |
+
# en filas que NO son movimiento: la palabra 'SPEI' aparece dentro de la
|
| 933 |
+
# descripcion de muchos movimientos legitimos ('CGO SPEI NOMINA 15FEB26'), asi
|
| 934 |
+
# que buscarla en cualquier parte cortaba la tabla en el primer movimiento SPEI.
|
| 935 |
+
_HSBC_FIN = re.compile(
|
| 936 |
+
r'^(Informaci[oó]n|Emitido\s+por|RESUMEN|CIFRAS\s+EXPRESADAS|Estimado|'
|
| 937 |
+
r'Otros\s+Datos|Tus\s+Movimientos|Comisiones\s+Cobradas)', re.I)
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
def _hsbc_periodo(pdf):
|
| 941 |
+
"""(mes, anio) del periodo del estado. El detalle solo trae el DIA."""
|
| 942 |
+
t = ''
|
| 943 |
+
for pg in pdf.pages[:2]:
|
| 944 |
+
t += (pg.extract_text() or '')
|
| 945 |
+
m = _HSBC_PERIODO.search(t)
|
| 946 |
+
if m:
|
| 947 |
+
return int(m.group(2)), int(m.group(3))
|
| 948 |
+
return None, None
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
def _hsbc_hdr(filas, tops):
|
| 952 |
+
"""(top, bandas) del encabezado del DETALLE en esta pagina, o None."""
|
| 953 |
+
for top in tops:
|
| 954 |
+
tx = {w['text']: w for w in filas[top]}
|
| 955 |
+
if 'Retiro/Cargo' in tx and 'Depósito/Abono' in tx:
|
| 956 |
+
sal = None
|
| 957 |
+
for w in filas[top]:
|
| 958 |
+
if w['text'] == 'Saldo' and w['x1'] > 500:
|
| 959 |
+
sal = w['x1']
|
| 960 |
+
b = {'ret': tx['Retiro/Cargo']['x1'], 'dep': tx['Depósito/Abono']['x1']}
|
| 961 |
+
if sal is not None:
|
| 962 |
+
b['sal'] = sal
|
| 963 |
+
return top, b
|
| 964 |
+
return None
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
def _hsbc(pdf):
|
| 968 |
+
"""HSBC: una fila = un movimiento. La columna 'Dia' trae SOLO el dia (el mes
|
| 969 |
+
y el anio salen del periodo). Columnas Retiro/Cargo, Deposito/Abono y Saldo
|
| 970 |
+
alineadas a la derecha. Las lineas siguientes sin dia son continuacion de la
|
| 971 |
+
descripcion o de la referencia."""
|
| 972 |
+
mes, anio = _hsbc_periodo(pdf)
|
| 973 |
+
movs = []
|
| 974 |
+
for page in pdf.pages:
|
| 975 |
+
words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| 976 |
+
filas = {}
|
| 977 |
+
for w in words:
|
| 978 |
+
filas.setdefault(round(w['top']), []).append(w)
|
| 979 |
+
tops = sorted(filas)
|
| 980 |
+
h = _hsbc_hdr(filas, tops)
|
| 981 |
+
if not h:
|
| 982 |
+
continue
|
| 983 |
+
hdr_top, banda = h
|
| 984 |
+
|
| 985 |
+
def col(x1):
|
| 986 |
+
for nom in ('ret', 'dep', 'sal'):
|
| 987 |
+
if nom in banda and abs(x1 - banda[nom]) <= _HSBC_TOL:
|
| 988 |
+
return nom
|
| 989 |
+
return None
|
| 990 |
+
|
| 991 |
+
for top in tops:
|
| 992 |
+
if top <= hdr_top + 2:
|
| 993 |
+
continue
|
| 994 |
+
row = sorted(filas[top], key=lambda z: z['x0'])
|
| 995 |
+
txt = ' '.join(w['text'] for w in row)
|
| 996 |
+
ret = dep = sal = None
|
| 997 |
+
for w in row:
|
| 998 |
+
if MONEY.match(w['text']):
|
| 999 |
+
c = col(w['x1'])
|
| 1000 |
+
if c == 'ret': ret = _num(w['text'])
|
| 1001 |
+
elif c == 'dep': dep = _num(w['text'])
|
| 1002 |
+
elif c == 'sal': sal = _num(w['text'])
|
| 1003 |
+
# descripcion: entre la columna del dia y la de Referencia/Serial
|
| 1004 |
+
desc = ' '.join(w['text'] for w in row
|
| 1005 |
+
if 60 < w['x0'] < 310 and not MONEY.match(w['text']))
|
| 1006 |
+
# dia: primer token, 1-2 digitos, en la columna de la izquierda
|
| 1007 |
+
md = re.fullmatch(r'(\d{1,2})', row[0]['text'])
|
| 1008 |
+
es_mov = bool(md) and row[0]['x1'] <= 60 and (ret is not None or dep is not None)
|
| 1009 |
+
if es_mov:
|
| 1010 |
+
fecha = ('%02d-%s-%d' % (int(md.group(1)), _MES_ABBR[mes], anio)
|
| 1011 |
+
if mes else '')
|
| 1012 |
+
movs.append({'fecha': fecha, 'concepto': desc.strip(),
|
| 1013 |
+
'deposito': dep, 'retiro': ret, 'saldo': sal})
|
| 1014 |
+
elif _HSBC_FIN.match(txt.strip()):
|
| 1015 |
+
break # arranca otra seccion: se cierra el DETALLE
|
| 1016 |
+
elif movs and desc.strip():
|
| 1017 |
+
movs[-1]['concepto'] = (movs[-1]['concepto'] + ' ' + desc.strip()).strip()
|
| 1018 |
+
for m in movs:
|
| 1019 |
+
m['concepto'] = m['concepto'][:220]
|
| 1020 |
+
return movs
|
| 1021 |
+
|
| 1022 |
+
|
| 1023 |
def extraer(path):
|
| 1024 |
import pdfplumber
|
| 1025 |
rep = tb.extraer(path)
|
|
|
|
| 1060 |
movs = _bbva(pdf)
|
| 1061 |
elif banco == 'BANORTE':
|
| 1062 |
movs = _banorte(pdf)
|
| 1063 |
+
elif banco == 'MONEX':
|
| 1064 |
+
movs = _monex(pdf)
|
| 1065 |
+
elif banco == 'HSBC':
|
| 1066 |
+
movs = _hsbc(pdf)
|
| 1067 |
elif bandas:
|
| 1068 |
movs = _por_bandas(pdf, bandas) # Santander (bandas fijas)
|
| 1069 |
else:
|
|
|
|
| 1073 |
rd, rr = rep['depositos'], rep['retiros']
|
| 1074 |
cuadra = (rd is not None and rr is not None
|
| 1075 |
and abs(suma_dep - rd) <= 1.0 and abs(suma_ret - rr) <= 1.0)
|
| 1076 |
+
|
| 1077 |
+
# CUADRE POR CADENA DE SALDOS (respaldo). Algunos bancos NETEAN en sus totales
|
| 1078 |
+
# de cabecera un par de movimientos que si movieron el saldo. Caso medido:
|
| 1079 |
+
# HSBC FEBRERO trae la comision de administracion (599.00 + IVA 95.84) y la
|
| 1080 |
+
# BONIFICACION de la del mes anterior (599.00 + 95.84); HSBC las cancela entre
|
| 1081 |
+
# si y no las cuenta ni en 'Depositos/Abonos' ni en 'Retiros/Cargos', asi que
|
| 1082 |
+
# nuestra suma queda +694.84 de CADA lado. Los 34 movimientos son reales: la
|
| 1083 |
+
# cadena de saldos impresa los confirma uno por uno.
|
| 1084 |
+
#
|
| 1085 |
+
# Solo se acepta con las TRES condiciones a la vez, que juntas son mas fuertes
|
| 1086 |
+
# que el cuadre de sumas: (1) el banco imprime saldo en >=90% de los
|
| 1087 |
+
# movimientos y TODOS coinciden con el acumulado, (2) el saldo final calculado
|
| 1088 |
+
# cae exacto en el que reporta el banco, y (3) la diferencia es la MISMA en
|
| 1089 |
+
# depositos y en retiros (neteo, no dinero perdido ni inventado). Un movimiento
|
| 1090 |
+
# duplicado rompe la cadena y uno faltante rompe el saldo final, asi que esto
|
| 1091 |
+
# no puede enmascarar los defectos que la compuerta existe para atrapar.
|
| 1092 |
+
cuadra_saldo = False
|
| 1093 |
+
si0, sf0 = rep.get('saldo_inicial'), rep.get('saldo_final')
|
| 1094 |
+
if (not cuadra and rd is not None and rr is not None
|
| 1095 |
+
and si0 is not None and sf0 is not None and movs):
|
| 1096 |
+
con_saldo = [m for m in movs if m.get('saldo') is not None]
|
| 1097 |
+
if len(con_saldo) >= 0.9 * len(movs):
|
| 1098 |
+
acc, mal = si0, 0
|
| 1099 |
+
for m in movs:
|
| 1100 |
+
acc = round(acc + (m['deposito'] or 0) - (m['retiro'] or 0), 2)
|
| 1101 |
+
if m.get('saldo') is not None and abs(acc - m['saldo']) > 0.01:
|
| 1102 |
+
mal += 1
|
| 1103 |
+
simetrico = abs((suma_dep - rd) - (suma_ret - rr)) <= 0.01
|
| 1104 |
+
if mal == 0 and abs(acc - sf0) <= 0.01 and simetrico:
|
| 1105 |
+
cuadra_saldo = True
|
| 1106 |
+
cuadra = cuadra or cuadra_saldo
|
| 1107 |
# transacciones en el FORMATO que consume el flujo (igual que Gemini):
|
| 1108 |
# fecha YYYY-MM-DD, descripcion, cargos(retiro), abonos(deposito), saldo, moneda
|
| 1109 |
tx_flujo = []
|
|
|
|
| 1120 |
'banco': banco, 'moneda': moneda, 'ok': bool(cuadra), 'cuadra': bool(cuadra),
|
| 1121 |
'suma_dep': suma_dep, 'suma_ret': suma_ret, 'rep_dep': rd, 'rep_ret': rr,
|
| 1122 |
'saldo_inicial': rep.get('saldo_inicial'), 'saldo_final': rep.get('saldo_final'),
|
| 1123 |
+
'cuadra_por_saldo': bool(cuadra_saldo),
|
| 1124 |
'n': len(movs), 'transacciones': tx_flujo,
|
| 1125 |
}
|
| 1126 |
|
fill_template.py
CHANGED
|
@@ -20,9 +20,15 @@ MESES_ES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
|
| 20 |
|
| 21 |
# Comisiones por SPEI / transferencia. NO se descartan: solo se MARCAN (bandera
|
| 22 |
# en la columna N de 08_EC_Raw) para la comparativa de # de movimientos.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
COMISION_RE = re.compile(
|
| 24 |
-
r'(COMIS\w*|\bCOM\b\.?|
|
| 25 |
-
r'|(SPEI|TRANSFER\w*|\bTRANSF\b|\bTEF\b)[\s\S]{0,50}(COMIS\w*|\bCOM\b\.?)',
|
| 26 |
re.IGNORECASE)
|
| 27 |
|
| 28 |
# Filas de saldos en 03_EstadosCuenta (V3.8.3: se insertaron 4 renglones en la
|
|
@@ -1220,4 +1226,3 @@ def main():
|
|
| 1220 |
|
| 1221 |
if __name__ == '__main__':
|
| 1222 |
main()
|
| 1223 |
-
|
|
|
|
| 20 |
|
| 21 |
# Comisiones por SPEI / transferencia. NO se descartan: solo se MARCAN (bandera
|
| 22 |
# en la columna N de 08_EC_Raw) para la comparativa de # de movimientos.
|
| 23 |
+
# OJO: 'CGO' y 'CARGO' NO son marcas de comision, son la palabra que muchos bancos
|
| 24 |
+
# usan para el movimiento mismo. Con ellas dentro, HSBC marcaba 107 de 208
|
| 25 |
+
# movimientos como 'comision por SPEI' (son transferencias reales: 'CGO SPEI FACT
|
| 26 |
+
# 2440' de $174,000, 'CGO SPEI FACT DTEFACT1466' de $1,832,928) y SANTANDER 12 mas
|
| 27 |
+
# ('CARGO TRANSFERENCIA ENLACE' de $150,000). Una comision real dice COMISION/COM/
|
| 28 |
+
# CUOTA de forma explicita. La bandera es informativa (columna N), no mueve dinero.
|
| 29 |
COMISION_RE = re.compile(
|
| 30 |
+
r'(COMIS\w*|\bCOM\b\.?|CUOTA)[\s\S]{0,50}(SPEI|TRANSFER\w*|\bTRANSF\b|\bTEF\b)'
|
| 31 |
+
r'|(SPEI|TRANSFER\w*|\bTRANSF\b|\bTEF\b)[\s\S]{0,50}(COMIS\w*|\bCOM\b\.?|CUOTA)',
|
| 32 |
re.IGNORECASE)
|
| 33 |
|
| 34 |
# Filas de saldos en 03_EstadosCuenta (V3.8.3: se insertaron 4 renglones en la
|
|
|
|
| 1226 |
|
| 1227 |
if __name__ == '__main__':
|
| 1228 |
main()
|
|
|
totales_banco.py
CHANGED
|
@@ -29,6 +29,52 @@ M = _PRE + r'([\d,]+\.\d{2})'
|
|
| 29 |
_NUM = r'[\d,]+\.\d{2}'
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def money(s):
|
| 33 |
return float(s.replace(',', '').replace('$', '').replace(' ', ''))
|
| 34 |
|
|
@@ -64,7 +110,7 @@ def leer_texto(path, nprim=3, nult=2, todo=False):
|
|
| 64 |
n = len(r.pages)
|
| 65 |
idx = range(n) if todo else sorted(
|
| 66 |
set(range(min(nprim, n))) | set(range(max(0, n - nult), n)))
|
| 67 |
-
return '\n'.join((r.pages[i].extract_text() or '') for i in idx)
|
| 68 |
except Exception:
|
| 69 |
import pdfplumber
|
| 70 |
with pdfplumber.open(path) as pdf:
|
|
@@ -72,7 +118,7 @@ def leer_texto(path, nprim=3, nult=2, todo=False):
|
|
| 72 |
n = len(ps)
|
| 73 |
idx = range(n) if todo else sorted(
|
| 74 |
set(range(min(nprim, n))) | set(range(max(0, n - nult), n)))
|
| 75 |
-
return '\n'.join((ps[i].extract_text() or '') for i in idx)
|
| 76 |
|
| 77 |
|
| 78 |
# El RFC del banco EMISOR es unico y NO se confunde con nombres que aparecen en
|
|
@@ -82,6 +128,8 @@ RFC_BANCO = {
|
|
| 82 |
'BSM970519DU8': 'SANTANDER', # Banco Santander Mexico
|
| 83 |
'BNM840515VB1': 'BANAMEX', # Banco Nacional de Mexico (Citibanamex)
|
| 84 |
'BMN930209927': 'BANORTE', # Banco Mercantil del Norte
|
|
|
|
|
|
|
| 85 |
'AEC810901298': 'AMEX', # American Express Company (Mexico) - TARJETA de credito
|
| 86 |
}
|
| 87 |
|
|
@@ -142,7 +190,8 @@ def detectar_banco(t):
|
|
| 142 |
# emisor sale en el encabezado/pie de CADA pagina, mientras que el de una
|
| 143 |
# contraparte sale 1-2 veces. (Antes se tomaba el primero del dict -> un PDF de
|
| 144 |
# Banorte que mencionaba el RFC de Santander se detectaba mal como Santander.)
|
| 145 |
-
|
|
|
|
| 146 |
counts = {}
|
| 147 |
for rfc, banco in RFC_BANCO.items():
|
| 148 |
c = U.count(rfc)
|
|
@@ -232,6 +281,17 @@ def extraer(path, _todo=False):
|
|
| 232 |
# completo: un banco nuevo cuyo resumen viva a media docena de paginas sigue
|
| 233 |
# funcionando, solo que pagando el costo de leerlo entero.
|
| 234 |
t = leer_texto(path, todo=_todo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
banco = detectar_banco(t)
|
| 236 |
moneda = detectar_moneda(t)
|
| 237 |
|
|
@@ -256,6 +316,7 @@ def extraer(path, _todo=False):
|
|
| 256 |
return r
|
| 257 |
|
| 258 |
dep = ret = si = sf = None
|
|
|
|
| 259 |
|
| 260 |
if banco == 'SANTANDER':
|
| 261 |
# PRIMARIO: la linea 'Saldo inicial +Depositos - Retiros = Saldo final'
|
|
@@ -285,6 +346,42 @@ def extraer(path, _todo=False):
|
|
| 285 |
si = find(r'Saldo Anterior\s*' + M, t) or find(r'Saldo Inicial\s*' + M, t)
|
| 286 |
sf = find(r'Saldo Final\s*' + M, t)
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
elif banco == 'BANORTE':
|
| 289 |
# Banorte SEPARA las salidas en el resumen (Total de retiros, Comisiones,
|
| 290 |
# IVA, Intereses Cobrados/Pagados, ISR), asi que 'Total de retiros' NO es el
|
|
@@ -315,7 +412,7 @@ def extraer(path, _todo=False):
|
|
| 315 |
cuadre = None
|
| 316 |
if None not in (si, sf, dep, ret):
|
| 317 |
cuadre = round((si + dep - ret) - sf, 2)
|
| 318 |
-
|
| 319 |
'banco': banco,
|
| 320 |
'moneda': moneda,
|
| 321 |
'depositos': dep,
|
|
@@ -325,6 +422,9 @@ def extraer(path, _todo=False):
|
|
| 325 |
'cuadre_interno': cuadre, # ~0 confirma que los totales del banco son consistentes
|
| 326 |
'ok': ok,
|
| 327 |
}
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
def main():
|
|
|
|
| 29 |
_NUM = r'[\d,]+\.\d{2}'
|
| 30 |
|
| 31 |
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# HSBC: fuente con nombres de glifo '/EX<ddd>000' donde <ddd> es el codigo
|
| 34 |
+
# UNICODE DECIMAL del caracter ('EX067000' -> 'C'). Es la salida de Xenos
|
| 35 |
+
# D2eVision, la herramienta de mainframe con la que HSBC genera el estado.
|
| 36 |
+
# Sin traducir esto, pypdf devuelve literalmente '/EX067000/EX085000...' y
|
| 37 |
+
# pdfplumber devuelve mojibake ('...(cid:228)...'): CERO importes reconocibles,
|
| 38 |
+
# el banco sale DESCONOCIDO y todo el estado cae a Gemini.
|
| 39 |
+
_GLIFO_EX = re.compile(r'/EX(\d{3})000')
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def decodificar_glifos(t):
|
| 43 |
+
"""Traduce los nombres de glifo '/EX<ddd>000' a su caracter real."""
|
| 44 |
+
if '/EX' not in t:
|
| 45 |
+
return t
|
| 46 |
+
return _GLIFO_EX.sub(lambda m: chr(int(m.group(1))), t)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _parchar_pdfminer():
|
| 50 |
+
"""Ensena a pdfminer (y por tanto a pdfplumber) a resolver los glifos
|
| 51 |
+
'EX<ddd>000' de HSBC, para que extract_words devuelva texto real CON
|
| 52 |
+
coordenadas. Solo intercepta nombres que ningun otro banco usa, asi que no
|
| 53 |
+
puede afectar la extraccion de los demas. Idempotente."""
|
| 54 |
+
try:
|
| 55 |
+
import pdfminer.encodingdb as _edb
|
| 56 |
+
import pdfminer.pdffont as _pf
|
| 57 |
+
except ImportError:
|
| 58 |
+
return
|
| 59 |
+
if getattr(_edb, '_ec_parche_hsbc', False):
|
| 60 |
+
return
|
| 61 |
+
_orig = _edb.name2unicode
|
| 62 |
+
|
| 63 |
+
def _n2u(name):
|
| 64 |
+
m = re.fullmatch(r'EX(\d{3})000', name or '')
|
| 65 |
+
if m:
|
| 66 |
+
return chr(int(m.group(1)))
|
| 67 |
+
return _orig(name)
|
| 68 |
+
|
| 69 |
+
_edb.name2unicode = _n2u
|
| 70 |
+
if hasattr(_pf, 'name2unicode'): # pdffont importa el nombre directo
|
| 71 |
+
_pf.name2unicode = _n2u
|
| 72 |
+
_edb._ec_parche_hsbc = True
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_parchar_pdfminer()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def money(s):
|
| 79 |
return float(s.replace(',', '').replace('$', '').replace(' ', ''))
|
| 80 |
|
|
|
|
| 110 |
n = len(r.pages)
|
| 111 |
idx = range(n) if todo else sorted(
|
| 112 |
set(range(min(nprim, n))) | set(range(max(0, n - nult), n)))
|
| 113 |
+
return decodificar_glifos('\n'.join((r.pages[i].extract_text() or '') for i in idx))
|
| 114 |
except Exception:
|
| 115 |
import pdfplumber
|
| 116 |
with pdfplumber.open(path) as pdf:
|
|
|
|
| 118 |
n = len(ps)
|
| 119 |
idx = range(n) if todo else sorted(
|
| 120 |
set(range(min(nprim, n))) | set(range(max(0, n - nult), n)))
|
| 121 |
+
return decodificar_glifos('\n'.join((ps[i].extract_text() or '') for i in idx))
|
| 122 |
|
| 123 |
|
| 124 |
# El RFC del banco EMISOR es unico y NO se confunde con nombres que aparecen en
|
|
|
|
| 128 |
'BSM970519DU8': 'SANTANDER', # Banco Santander Mexico
|
| 129 |
'BNM840515VB1': 'BANAMEX', # Banco Nacional de Mexico (Citibanamex)
|
| 130 |
'BMN930209927': 'BANORTE', # Banco Mercantil del Norte
|
| 131 |
+
'BMI9704113PA': 'MONEX', # Banco Monex, S.A. (Monex Grupo Financiero)
|
| 132 |
+
'HMI950125KG8': 'HSBC', # HSBC Mexico, S.A. (lo imprime 'HMI-950125KG8')
|
| 133 |
'AEC810901298': 'AMEX', # American Express Company (Mexico) - TARJETA de credito
|
| 134 |
}
|
| 135 |
|
|
|
|
| 190 |
# emisor sale en el encabezado/pie de CADA pagina, mientras que el de una
|
| 191 |
# contraparte sale 1-2 veces. (Antes se tomaba el primero del dict -> un PDF de
|
| 192 |
# Banorte que mencionaba el RFC de Santander se detectaba mal como Santander.)
|
| 193 |
+
# Se quitan tambien los GUIONES: HSBC imprime su RFC como 'HMI-950125KG8'.
|
| 194 |
+
U = re.sub(r'[\s\-]+', '', t.upper())
|
| 195 |
counts = {}
|
| 196 |
for rfc, banco in RFC_BANCO.items():
|
| 197 |
c = U.count(rfc)
|
|
|
|
| 281 |
# completo: un banco nuevo cuyo resumen viva a media docena de paginas sigue
|
| 282 |
# funcionando, solo que pagando el costo de leerlo entero.
|
| 283 |
t = leer_texto(path, todo=_todo)
|
| 284 |
+
# PDF SIN CAPA DE TEXTO: cada letra viene como trazo vectorial (0 caracteres,
|
| 285 |
+
# 0 fuentes). Pasa con estados RE-IMPRESOS a PDF en vez de descargados del
|
| 286 |
+
# portal del banco. Sin texto no hay nada que extraer por regex NI por IA de
|
| 287 |
+
# texto: el unico camino seria OCR. Se corta aqui para no pagar el reintento con
|
| 288 |
+
# el documento completo (devuelve la misma cadena vacia), y la bandera permite
|
| 289 |
+
# apartar el PDF en vez de mandarlo a Gemini, que sin texto solo puede inventar.
|
| 290 |
+
if not t.strip():
|
| 291 |
+
return {'banco': 'DESCONOCIDO', 'moneda': 'MXN',
|
| 292 |
+
'depositos': None, 'retiros': None, 'saldo_inicial': None,
|
| 293 |
+
'saldo_final': None, 'cuadre_interno': None, 'ok': False,
|
| 294 |
+
'sin_capa_texto': True}
|
| 295 |
banco = detectar_banco(t)
|
| 296 |
moneda = detectar_moneda(t)
|
| 297 |
|
|
|
|
| 316 |
return r
|
| 317 |
|
| 318 |
dep = ret = si = sf = None
|
| 319 |
+
saldo_promedio = None # solo MONEX lo publica en su resumen
|
| 320 |
|
| 321 |
if banco == 'SANTANDER':
|
| 322 |
# PRIMARIO: la linea 'Saldo inicial +Depositos - Retiros = Saldo final'
|
|
|
|
| 346 |
si = find(r'Saldo Anterior\s*' + M, t) or find(r'Saldo Inicial\s*' + M, t)
|
| 347 |
sf = find(r'Saldo Final\s*' + M, t)
|
| 348 |
|
| 349 |
+
elif banco == 'HSBC':
|
| 350 |
+
# Resumen de la pagina 1 ('RESUMEN DE CUENTAS'). Las etiquetas vienen
|
| 351 |
+
# PARTIDAS en varias lineas por el ancho de la caja, y el '4' que las
|
| 352 |
+
# precede es un bullet del catalogo de fuentes de HSBC, no un numero:
|
| 353 |
+
# '4Saldo Inicial del\nPeriodo\n$ 11,902.36'
|
| 354 |
+
# '4Depositos/\nAbonos\n$ 226,671.87'
|
| 355 |
+
# '4Retiros/Cargos$ 185,611.64'
|
| 356 |
+
# Por eso se tolera cualquier cosa entre la etiqueta y el importe con
|
| 357 |
+
# [\s\S]{0,40} en vez de exigirlos en el mismo renglon.
|
| 358 |
+
dep = find(r'Dep[oó]sitos\s*/[\s\S]{0,20}Abonos[\s\S]{0,12}?' + M, t)
|
| 359 |
+
ret = find(r'Retiros\s*/\s*Cargos[\s\S]{0,12}?' + M, t)
|
| 360 |
+
si = find(r'Saldo\s*Inicial\s*del[\s\S]{0,20}Periodo[\s\S]{0,12}?' + M, t)
|
| 361 |
+
sf = find(r'Saldo\s*Final\s*del[\s\S]{0,20}Periodo[\s\S]{0,12}?' + M, t)
|
| 362 |
+
saldo_promedio = find(r'Saldo\s*Promedio\s*en\s*el\s*Mes[^$\n]{0,70}?' + M, t)
|
| 363 |
+
|
| 364 |
+
elif banco == 'MONEX':
|
| 365 |
+
# El resumen vive en la pagina 2 ('Resumen Cuenta', columna derecha). DOS
|
| 366 |
+
# trampas, ambas resueltas exigiendo los DOS PUNTOS de la etiqueta:
|
| 367 |
+
# 1) en la MISMA pagina hay una tabla 'Resumen Divisas' con encabezados
|
| 368 |
+
# 'Divisa | Saldo inicial del periodo | Abonos | Cargos | Saldo' y la fila
|
| 369 |
+
# 'dolar americano 1.06 38,779.50 38,779.60 0.96': son los totales de la
|
| 370 |
+
# SUBCUENTA EN DOLARES, no los de la cuenta en pesos.
|
| 371 |
+
# 2) el encabezado de la tabla de movimientos repite 'Saldo total' y
|
| 372 |
+
# 'Saldo disponible' SIN dos puntos.
|
| 373 |
+
# Las etiquetas del resumen son las unicas que llevan ':'.
|
| 374 |
+
dep = find(r'Total\s*abonos\s*:' + M, t)
|
| 375 |
+
ret = find(r'Total\s*cargos\s*:' + M, t)
|
| 376 |
+
si = find(r'Saldo\s*inicial\s*:' + M, t)
|
| 377 |
+
sf = find(r'Saldo\s*total\s*:' + M, t) or find(r'Saldo\s*vista\s*:' + M, t)
|
| 378 |
+
saldo_promedio = find(r'Saldo\s*promedio\s*\(\s*Intereses\s*\)\s*:' + M, t)
|
| 379 |
+
# Si el cliente tiene subcuenta en dolares, el PDF trae MAS ADELANTE un
|
| 380 |
+
# SEGUNDO bloque 'Resumen cuenta / dolar americano' con las MISMAS etiquetas
|
| 381 |
+
# con dos puntos. Se toma SIEMPRE la PRIMERA ocurrencia (find usa re.search)
|
| 382 |
+
# porque el bloque en PESOS va antes; que cuadre_interno salga 0.00 confirma
|
| 383 |
+
# que los cuatro totales salieron del MISMO bloque.
|
| 384 |
+
|
| 385 |
elif banco == 'BANORTE':
|
| 386 |
# Banorte SEPARA las salidas en el resumen (Total de retiros, Comisiones,
|
| 387 |
# IVA, Intereses Cobrados/Pagados, ISR), asi que 'Total de retiros' NO es el
|
|
|
|
| 412 |
cuadre = None
|
| 413 |
if None not in (si, sf, dep, ret):
|
| 414 |
cuadre = round((si + dep - ret) - sf, 2)
|
| 415 |
+
r = {
|
| 416 |
'banco': banco,
|
| 417 |
'moneda': moneda,
|
| 418 |
'depositos': dep,
|
|
|
|
| 422 |
'cuadre_interno': cuadre, # ~0 confirma que los totales del banco son consistentes
|
| 423 |
'ok': ok,
|
| 424 |
}
|
| 425 |
+
if saldo_promedio is not None:
|
| 426 |
+
r['saldo_promedio'] = saldo_promedio
|
| 427 |
+
return r
|
| 428 |
|
| 429 |
|
| 430 |
def main():
|