Abraham03 commited on
Commit
693f83d
·
verified ·
1 Parent(s): 3fa08a9

Upload 3 files

Browse files
Files changed (3) hide show
  1. extraer_movimientos.py +406 -0
  2. fill_template.py +8 -3
  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\.?|\bCGO\b\.?|CARGO|CUOTA)[\s\S]{0,50}(SPEI|TRANSFER\w*|\bTRANSF\b|\bTEF\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
- U = re.sub(r'\s+', '', t.upper())
 
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
- return {
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():