Add independent bank identity detection and APGB parser
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .base import BaseParser, StatementMeta, amount, extract_text, finalize, norm
|
||||
from .common import date_iso, find, infer_mode
|
||||
|
||||
|
||||
class AndhraPradeshGrameenaBankParser(BaseParser):
|
||||
bank_name = "Andhra Pradesh Grameena Bank"
|
||||
parser_name = "AndhraPradeshGrameenaBankParser"
|
||||
|
||||
_DATE_LINE = re.compile(r"^\s*(\d{2}-\d{2}-\d{4})\s+(.*)$")
|
||||
_BALANCE = re.compile(r"(-?[0-9,]+\.\d{2})\s*(Cr|Dr)\s*$", re.I)
|
||||
_MONEY = re.compile(r"(?<![A-Za-z0-9])([0-9,]+\.\d{2})(?![A-Za-z0-9])")
|
||||
|
||||
@classmethod
|
||||
def detect(cls, text: str) -> float:
|
||||
upper = (text or "").upper()
|
||||
score = 0.0
|
||||
if "ANDHRA PRADESH GRAMEENA BANK" in upper:
|
||||
score += 0.78
|
||||
if "COREBANKING.APGB.IN" in upper or "APGB.IN" in upper:
|
||||
score += 0.14
|
||||
if "UBIN0CG" in upper:
|
||||
score += 0.06
|
||||
if "STATEMENT OF ACCOUNT" in upper and "PAGE TOTAL" in upper and "GRAND TOTAL" in upper:
|
||||
score += 0.02
|
||||
return min(score, 0.999)
|
||||
|
||||
@staticmethod
|
||||
def _signed_balance(value: str, marker: str) -> float | None:
|
||||
parsed = amount(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
return -abs(parsed) if marker.upper() == "DR" else abs(parsed)
|
||||
|
||||
@staticmethod
|
||||
def _reference(narration: str) -> str:
|
||||
for pattern in (
|
||||
r"\bCMP\d{12,}\b",
|
||||
r"\bNACH[A-Z0-9/\-]+\b",
|
||||
r"\b\d{12,18}\b",
|
||||
):
|
||||
match = re.search(pattern, narration, re.I)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return ""
|
||||
|
||||
def parse(self, path, text=None):
|
||||
pdf_path = Path(path)
|
||||
text = text or extract_text(pdf_path)
|
||||
meta = StatementMeta(
|
||||
bank_name=self.bank_name,
|
||||
source_file=pdf_path.name,
|
||||
parser_name=self.parser_name,
|
||||
confidence="High",
|
||||
)
|
||||
|
||||
meta.customer_name = norm(find(r"Name\s*:\s*([^\n]+?)(?:\s+A/C\s+NO|$)", text, flags=re.I))
|
||||
meta.account_number = find(r"A/C\s*NO\s*:\s*([0-9]+)", text, flags=re.I)
|
||||
meta.customer_id = find(r"Cust\s*ID\s*:\s*([0-9]+)", text, flags=re.I)
|
||||
meta.ifsc = find(r"IFSC\s*CODE\s*:\s*([A-Z0-9]+)", text, flags=re.I)
|
||||
period = re.search(r"STATEMENT\s+OF\s+ACCOUNT\s+(\d{2}-\d{2}-\d{4})\s+to\s+(\d{2}-\d{2}-\d{4})", text, re.I)
|
||||
if period:
|
||||
meta.period_from = date_iso(period.group(1))
|
||||
meta.period_to = date_iso(period.group(2))
|
||||
|
||||
bf = re.search(r"\bB/F\s+([0-9,]+\.\d{2})\s*(Cr|Dr)", text, re.I)
|
||||
if bf:
|
||||
meta.opening_balance = self._signed_balance(bf.group(1), bf.group(2))
|
||||
|
||||
grand_total = re.search(
|
||||
r"Grand\s+Total\s*:\s*([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s*(Cr|Dr)",
|
||||
text,
|
||||
re.I,
|
||||
)
|
||||
if grand_total:
|
||||
meta.total_debit = amount(grand_total.group(1))
|
||||
meta.total_credit = amount(grand_total.group(2))
|
||||
meta.closing_balance = self._signed_balance(grand_total.group(3), grand_total.group(4))
|
||||
|
||||
rows: list[dict] = []
|
||||
current: dict | None = None
|
||||
previous_balance = meta.opening_balance
|
||||
|
||||
for page_no, page_text in enumerate(re.split(r"\f", text), start=1):
|
||||
for raw_line in page_text.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
match = self._DATE_LINE.match(stripped)
|
||||
if match:
|
||||
if current is not None:
|
||||
rows.append(current)
|
||||
previous_balance = current["balance"]
|
||||
current = None
|
||||
|
||||
txn_date, remainder = match.groups()
|
||||
balance_match = self._BALANCE.search(remainder)
|
||||
if not balance_match:
|
||||
continue
|
||||
balance_value = self._signed_balance(balance_match.group(1), balance_match.group(2))
|
||||
before_balance = remainder[:balance_match.start()].rstrip()
|
||||
money_matches = list(self._MONEY.finditer(before_balance))
|
||||
narration_end = money_matches[0].start() if money_matches else len(before_balance)
|
||||
narration = norm(before_balance[:narration_end])
|
||||
|
||||
debit = credit = None
|
||||
if previous_balance is not None and balance_value is not None:
|
||||
delta = round(balance_value - previous_balance, 2)
|
||||
if delta < -0.009:
|
||||
debit = abs(delta)
|
||||
elif delta > 0.009:
|
||||
credit = delta
|
||||
elif money_matches:
|
||||
# The statement prints debit before credit. The common
|
||||
# running-balance validator will independently verify it.
|
||||
debit = amount(money_matches[-1].group(1))
|
||||
|
||||
current = {
|
||||
"transaction_date": txn_date,
|
||||
"value_date": txn_date,
|
||||
"narration": narration,
|
||||
"reference_no": self._reference(narration),
|
||||
"debit": debit,
|
||||
"credit": credit,
|
||||
"balance": balance_value,
|
||||
"source_page": page_no,
|
||||
"mode": infer_mode(narration),
|
||||
}
|
||||
continue
|
||||
|
||||
if current is not None:
|
||||
continuation = norm(stripped)
|
||||
upper = continuation.upper()
|
||||
if not continuation:
|
||||
continue
|
||||
if (
|
||||
upper.startswith((
|
||||
"PAGE TOTAL", "GRAND TOTAL", "TRANSACTION DETAILS", "HTTPS://",
|
||||
"UNLESS THE", "BRANCH MANAGER", "****END", "MUCH MORE",
|
||||
))
|
||||
or set(continuation) <= {"-"}
|
||||
or "STATEMENT OF ACCOUNT,IT WILL BE TAKEN" in upper
|
||||
):
|
||||
continue
|
||||
current["narration"] = norm(f"{current['narration']} {continuation}")
|
||||
current["reference_no"] = current["reference_no"] or self._reference(current["narration"])
|
||||
|
||||
# Do not carry an unfinished row into footer text on another page;
|
||||
# append it at the page boundary while preserving its source page.
|
||||
if current is not None:
|
||||
rows.append(current)
|
||||
previous_balance = current["balance"]
|
||||
current = None
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
result = finalize(df, meta)
|
||||
if result.empty:
|
||||
raise ValueError(
|
||||
"Andhra Pradesh Grameena Bank format was identified, but no transaction rows could be extracted."
|
||||
)
|
||||
return meta, result
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BankIdentity:
|
||||
name: str
|
||||
category: str
|
||||
aliases: tuple[str, ...] = ()
|
||||
ifsc_prefixes: tuple[str, ...] = ()
|
||||
domains: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BankIdentityMatch:
|
||||
name: str
|
||||
category: str
|
||||
confidence: float
|
||||
evidence: tuple[str, ...]
|
||||
|
||||
|
||||
# Commercial-bank identity registry. Parsing is intentionally independent of
|
||||
# this list: templates extract transactions; this registry only assigns the
|
||||
# institution name. The list covers current public/private banks, SFBs,
|
||||
# payments banks, RRBs after the 2025 One-State-One-RRB amalgamation, major
|
||||
# foreign banks operating in India, and legacy names that still appear in PDFs.
|
||||
BANK_IDENTITIES: tuple[BankIdentity, ...] = (
|
||||
# Public sector banks
|
||||
BankIdentity("State Bank of India", "Public Sector Bank", ("STATE BANK OF INDIA", "SBI"), ("SBIN",), ("sbi.co.in", "onlinesbi.sbi")),
|
||||
BankIdentity("Bank of Baroda", "Public Sector Bank", ("BANK OF BARODA", "BOB"), ("BARB",), ("bankofbaroda.in",)),
|
||||
BankIdentity("Bank of India", "Public Sector Bank", ("BANK OF INDIA",), ("BKID",), ("bankofindia.co.in",)),
|
||||
BankIdentity("Bank of Maharashtra", "Public Sector Bank", ("BANK OF MAHARASHTRA",), ("MAHB",), ("bankofmaharashtra.in",)),
|
||||
BankIdentity("Canara Bank", "Public Sector Bank", ("CANARA BANK",), ("CNRB",), ("canarabank.com",)),
|
||||
BankIdentity("Central Bank of India", "Public Sector Bank", ("CENTRAL BANK OF INDIA",), ("CBIN",), ("centralbankofindia.co.in",)),
|
||||
BankIdentity("Indian Bank", "Public Sector Bank", ("INDIAN BANK",), ("IDIB",), ("indianbank.in",)),
|
||||
BankIdentity("Indian Overseas Bank", "Public Sector Bank", ("INDIAN OVERSEAS BANK", "IOB"), ("IOBA",), ("iob.in",)),
|
||||
BankIdentity("Punjab & Sind Bank", "Public Sector Bank", ("PUNJAB & SIND BANK", "PUNJAB AND SIND BANK"), ("PSIB",), ("punjabandsindbank.co.in",)),
|
||||
BankIdentity("Punjab National Bank", "Public Sector Bank", ("PUNJAB NATIONAL BANK", "PNB"), ("PUNB",), ("pnbindia.in",)),
|
||||
BankIdentity("UCO Bank", "Public Sector Bank", ("UCO BANK",), ("UCBA",), ("ucobank.com",)),
|
||||
BankIdentity("Union Bank of India", "Public Sector Bank", ("UNION BANK OF INDIA",), ("UBIN",), ("unionbankofindia.co.in",)),
|
||||
|
||||
# Indian private sector banks
|
||||
BankIdentity("Axis Bank", "Private Sector Bank", ("AXIS BANK",), ("UTIB",), ("axisbank.com",)),
|
||||
BankIdentity("Bandhan Bank", "Private Sector Bank", ("BANDHAN BANK",), ("BDBL",), ("bandhanbank.com",)),
|
||||
BankIdentity("CSB Bank", "Private Sector Bank", ("CSB BANK", "CATHOLIC SYRIAN BANK"), ("CSBK",), ("csb.co.in",)),
|
||||
BankIdentity("City Union Bank", "Private Sector Bank", ("CITY UNION BANK",), ("CIUB",), ("cityunionbank.com",)),
|
||||
BankIdentity("DCB Bank", "Private Sector Bank", ("DCB BANK", "DEVELOPMENT CREDIT BANK"), ("DCBL",), ("dcbbank.com",)),
|
||||
BankIdentity("Dhanlaxmi Bank", "Private Sector Bank", ("DHANLAXMI BANK", "DHANALAKSHMI BANK"), ("DLXB",), ("dhanbank.com",)),
|
||||
BankIdentity("Federal Bank", "Private Sector Bank", ("FEDERAL BANK",), ("FDRL",), ("federalbank.co.in",)),
|
||||
BankIdentity("HDFC Bank", "Private Sector Bank", ("HDFC BANK",), ("HDFC",), ("hdfcbank.com",)),
|
||||
BankIdentity("ICICI Bank", "Private Sector Bank", ("ICICI BANK",), ("ICIC",), ("icicibank.com",)),
|
||||
BankIdentity("IDBI Bank", "Private Sector Bank", ("IDBI BANK",), ("IBKL",), ("idbibank.in",)),
|
||||
BankIdentity("IDFC FIRST Bank", "Private Sector Bank", ("IDFC FIRST BANK", "IDFC BANK"), ("IDFB",), ("idfcfirstbank.com",)),
|
||||
BankIdentity("IndusInd Bank", "Private Sector Bank", ("INDUSIND BANK",), ("INDB",), ("indusind.com",)),
|
||||
BankIdentity("Jammu & Kashmir Bank", "Private Sector Bank", ("JAMMU & KASHMIR BANK", "JAMMU AND KASHMIR BANK", "J&K BANK"), ("JAKA",), ("jkb.bank.in", "jkbank.com")),
|
||||
BankIdentity("Karnataka Bank", "Private Sector Bank", ("KARNATAKA BANK",), ("KARB",), ("karnatakabank.com",)),
|
||||
BankIdentity("Karur Vysya Bank", "Private Sector Bank", ("KARUR VYSYA BANK", "KVB"), ("KVBL",), ("kvb.co.in",)),
|
||||
BankIdentity("Kotak Mahindra Bank", "Private Sector Bank", ("KOTAK MAHINDRA BANK", "KOTAK BANK"), ("KKBK",), ("kotak.com",)),
|
||||
BankIdentity("Nainital Bank", "Private Sector Bank", ("NAINITAL BANK",), ("NTBL",), ("nainitalbank.co.in",)),
|
||||
BankIdentity("RBL Bank", "Private Sector Bank", ("RBL BANK", "RATNAKAR BANK"), ("RATN",), ("rblbank.com",)),
|
||||
BankIdentity("South Indian Bank", "Private Sector Bank", ("SOUTH INDIAN BANK",), ("SIBL",), ("southindianbank.com",)),
|
||||
BankIdentity("Tamilnad Mercantile Bank", "Private Sector Bank", ("TAMILNAD MERCANTILE BANK", "TMB"), ("TMBL",), ("tmb.in",)),
|
||||
BankIdentity("YES Bank", "Private Sector Bank", ("YES BANK",), ("YESB",), ("yesbank.in",)),
|
||||
|
||||
# Small finance banks
|
||||
BankIdentity("AU Small Finance Bank", "Small Finance Bank", ("AU SMALL FINANCE BANK",), ("AUBL",), ("aubank.in",)),
|
||||
BankIdentity("Capital Small Finance Bank", "Small Finance Bank", ("CAPITAL SMALL FINANCE BANK",), ("CLBL",), ("capitalbank.co.in",)),
|
||||
BankIdentity("Equitas Small Finance Bank", "Small Finance Bank", ("EQUITAS SMALL FINANCE BANK",), ("ESFB",), ("equitasbank.com",)),
|
||||
BankIdentity("ESAF Small Finance Bank", "Small Finance Bank", ("ESAF SMALL FINANCE BANK",), ("ESMF",), ("esafbank.com",)),
|
||||
BankIdentity("Jana Small Finance Bank", "Small Finance Bank", ("JANA SMALL FINANCE BANK",), ("JSFB",), ("janabank.com",)),
|
||||
BankIdentity("Shivalik Small Finance Bank", "Small Finance Bank", ("SHIVALIK SMALL FINANCE BANK",), ("SMCB",), ("shivalikbank.com",)),
|
||||
BankIdentity("slice Small Finance Bank", "Small Finance Bank", ("SLICE SMALL FINANCE BANK", "NORTH EAST SMALL FINANCE BANK"), ("NESF",), ("slice.bank",)),
|
||||
BankIdentity("Suryoday Small Finance Bank", "Small Finance Bank", ("SURYODAY SMALL FINANCE BANK",), ("SURY",), ("suryodaybank.com",)),
|
||||
BankIdentity("Ujjivan Small Finance Bank", "Small Finance Bank", ("UJJIVAN SMALL FINANCE BANK",), ("UJVN",), ("ujjivansfb.in",)),
|
||||
BankIdentity("Unity Small Finance Bank", "Small Finance Bank", ("UNITY SMALL FINANCE BANK",), ("UNSF",), ("theunitybank.com",)),
|
||||
BankIdentity("Utkarsh Small Finance Bank", "Small Finance Bank", ("UTKARSH SMALL FINANCE BANK",), ("UTKS",), ("utkarsh.bank",)),
|
||||
|
||||
# Payments banks
|
||||
BankIdentity("Airtel Payments Bank", "Payments Bank", ("AIRTEL PAYMENTS BANK",), ("AIRP",), ("airtel.in/bank",)),
|
||||
BankIdentity("Fino Payments Bank", "Payments Bank", ("FINO PAYMENTS BANK",), ("FINO",), ("finobank.com",)),
|
||||
BankIdentity("India Post Payments Bank", "Payments Bank", ("INDIA POST PAYMENTS BANK", "IPPB"), ("IPOS",), ("ippbonline.com",)),
|
||||
BankIdentity("Jio Payments Bank", "Payments Bank", ("JIO PAYMENTS BANK",), ("JIOP",), ("jiopaymentsbank.com",)),
|
||||
BankIdentity("NSDL Payments Bank", "Payments Bank", ("NSDL PAYMENTS BANK",), ("NSPB",), ("nsdlpayments.bank",)),
|
||||
BankIdentity("Paytm Payments Bank", "Payments Bank", ("PAYTM PAYMENTS BANK",), ("PYTM",), ("paytmbank.com",)),
|
||||
|
||||
# Regional Rural Banks after One-State-One-RRB (2025)
|
||||
BankIdentity("Andhra Pradesh Grameena Bank", "Regional Rural Bank", ("ANDHRA PRADESH GRAMEENA BANK", "ANDHRA PRAGATHI GRAMEENA BANK", "CHAITANYA GODAVARI GRAMEENA BANK", "SAPTAGIRI GRAMEENA BANK"), ("UBIN0CG",), ("corebanking.apgb.in", "apgb.in")),
|
||||
BankIdentity("Arunachal Pradesh Rural Bank", "Regional Rural Bank", ("ARUNACHAL PRADESH RURAL BANK",), (), ("apruralbank.com",)),
|
||||
BankIdentity("Assam Gramin Bank", "Regional Rural Bank", ("ASSAM GRAMIN BANK", "ASSAM GRAMIN VIKASH BANK"), (), ("agvbank.co.in",)),
|
||||
BankIdentity("Bihar Gramin Bank", "Regional Rural Bank", ("BIHAR GRAMIN BANK", "DAKSHIN BIHAR GRAMIN BANK", "UTTAR BIHAR GRAMIN BANK"), (), ("dbgb.in", "ubgb.in")),
|
||||
BankIdentity("Chhattisgarh Gramin Bank", "Regional Rural Bank", ("CHHATTISGARH GRAMIN BANK", "CHHATTISGARH RAJYA GRAMIN BANK"), (), ("cgbank.in",)),
|
||||
BankIdentity("Gujarat Gramin Bank", "Regional Rural Bank", ("GUJARAT GRAMIN BANK", "BARODA GUJARAT GRAMIN BANK", "SAURASHTRA GRAMIN BANK"), (), ("bggb.in", "sgbrrb.org")),
|
||||
BankIdentity("Haryana Gramin Bank", "Regional Rural Bank", ("HARYANA GRAMIN BANK", "SARVA HARYANA GRAMIN BANK"), (), ("shgb.co.in",)),
|
||||
BankIdentity("Himachal Pradesh Gramin Bank", "Regional Rural Bank", ("HIMACHAL PRADESH GRAMIN BANK",), (), ("hpgb.in",)),
|
||||
BankIdentity("Jammu and Kashmir Grameen Bank", "Regional Rural Bank", ("JAMMU AND KASHMIR GRAMEEN BANK", "J&K GRAMEEN BANK", "ELLAQUAI DEHATI BANK"), (), ("jkgb.in",)),
|
||||
BankIdentity("Jharkhand Rajya Gramin Bank", "Regional Rural Bank", ("JHARKHAND RAJYA GRAMIN BANK",), (), ("jrgb.in",)),
|
||||
BankIdentity("Karnataka Grameena Bank", "Regional Rural Bank", ("KARNATAKA GRAMEENA BANK", "KARNATAKA GRAMIN BANK", "KARNATAKA VIKAS GRAMEENA BANK"), (), ("karnatakagraminbank.com", "kvgbank.com")),
|
||||
BankIdentity("Kerala Gramin Bank", "Regional Rural Bank", ("KERALA GRAMIN BANK",), (), ("keralagbank.com",)),
|
||||
BankIdentity("Madhya Pradesh Gramin Bank", "Regional Rural Bank", ("MADHYA PRADESH GRAMIN BANK", "MADHYANCHAL GRAMIN BANK"), (), ("mpgb.co.in",)),
|
||||
BankIdentity("Maharashtra Gramin Bank", "Regional Rural Bank", ("MAHARASHTRA GRAMIN BANK", "VIDHARBHA KONKAN GRAMIN BANK"), (), ("mahagramin.in", "vkgb.co.in")),
|
||||
BankIdentity("Manipur Rural Bank", "Regional Rural Bank", ("MANIPUR RURAL BANK",), (), ("manipurruralbank.com",)),
|
||||
BankIdentity("Meghalaya Rural Bank", "Regional Rural Bank", ("MEGHALAYA RURAL BANK",), (), ("meghalayaruralbank.co.in",)),
|
||||
BankIdentity("Mizoram Rural Bank", "Regional Rural Bank", ("MIZORAM RURAL BANK",), (), ("mizoramruralbank.in",)),
|
||||
BankIdentity("Nagaland Rural Bank", "Regional Rural Bank", ("NAGALAND RURAL BANK",), (), ("nagalandruralbank.com",)),
|
||||
BankIdentity("Odisha Grameen Bank", "Regional Rural Bank", ("ODISHA GRAMEEN BANK", "ODISHA GRAMYA BANK", "UTKAL GRAMEEN BANK"), (), ("odishabank.in", "odishagramyabank.in")),
|
||||
BankIdentity("Puducherry Grama Bank", "Regional Rural Bank", ("PUDUCHERRY GRAMA BANK", "PUDUVAI BHARATHIAR GRAMA BANK"), (), ("puduvaibharathiargramabank.in",)),
|
||||
BankIdentity("Punjab Gramin Bank", "Regional Rural Bank", ("PUNJAB GRAMIN BANK",), (), ("pgb.org.in",)),
|
||||
BankIdentity("Rajasthan Gramin Bank", "Regional Rural Bank", ("RAJASTHAN GRAMIN BANK", "RAJASTHAN MARUDHARA GRAMIN BANK", "BARODA RAJASTHAN KSHETRIYA GRAMIN BANK"), (), ("rmgb.in", "brkgb.com")),
|
||||
BankIdentity("Tamil Nadu Grama Bank", "Regional Rural Bank", ("TAMIL NADU GRAMA BANK",), (), ("tamilnadugramabank.com",)),
|
||||
BankIdentity("Telangana Grameena Bank", "Regional Rural Bank", ("TELANGANA GRAMEENA BANK", "ANDHRA PRADESH GRAMEENA VIKAS BANK"), (), ("tgbhyd.in", "apgvbank.in")),
|
||||
BankIdentity("Tripura Gramin Bank", "Regional Rural Bank", ("TRIPURA GRAMIN BANK",), (), ("tripuragraminbank.org",)),
|
||||
BankIdentity("Uttar Pradesh Gramin Bank", "Regional Rural Bank", ("UTTAR PRADESH GRAMIN BANK", "ARYAVART BANK", "BARODA U.P. BANK", "BARODA UP BANK", "PRATHAMA U.P. GRAMIN BANK"), (), ("upgbank.com", "aryavart-rrb.com", "barodaupbank.in", "prathamaupbank.com")),
|
||||
BankIdentity("Uttarakhand Gramin Bank", "Regional Rural Bank", ("UTTARAKHAND GRAMIN BANK",), (), ("uttarakhandgraminbank.com",)),
|
||||
BankIdentity("West Bengal Gramin Bank", "Regional Rural Bank", ("WEST BENGAL GRAMIN BANK", "BANGIYA GRAMIN VIKASH BANK", "PASCHIM BANGA GRAMIN BANK", "UTTARBANGA KSHETRIYA GRAMIN BANK"), (), ("bgvb.in", "pbgbank.com", "ubkgb.org")),
|
||||
|
||||
# Foreign banks commonly issuing Indian account statements
|
||||
BankIdentity("HSBC India", "Foreign Bank", ("HSBC BANK", "THE HONGKONG AND SHANGHAI BANKING CORPORATION"), ("HSBC",), ("hsbc.co.in",)),
|
||||
BankIdentity("Standard Chartered Bank India", "Foreign Bank", ("STANDARD CHARTERED BANK",), ("SCBL",), ("sc.com/in",)),
|
||||
BankIdentity("DBS Bank India", "Foreign Bank", ("DBS BANK INDIA", "DBS BANK"), ("DBSS",), ("dbs.com/in",)),
|
||||
BankIdentity("Deutsche Bank India", "Foreign Bank", ("DEUTSCHE BANK",), ("DEUT",), ("deutschebank.co.in",)),
|
||||
BankIdentity("Citibank India", "Foreign Bank", ("CITIBANK", "CITI BANK"), ("CITI",), ("citibank.co.in",)),
|
||||
BankIdentity("Bank of America India", "Foreign Bank", ("BANK OF AMERICA",), ("BOFA",), ("bankofamerica.com",)),
|
||||
BankIdentity("Barclays Bank India", "Foreign Bank", ("BARCLAYS BANK",), ("BARC",), ("barclays.in",)),
|
||||
BankIdentity("BNP Paribas India", "Foreign Bank", ("BNP PARIBAS",), ("BNPA",), ("apac.bnpparibas",)),
|
||||
BankIdentity("MUFG Bank India", "Foreign Bank", ("MUFG BANK", "BANK OF TOKYO-MITSUBISHI UFJ"), ("BOTM",), ("bk.mufg.jp",)),
|
||||
BankIdentity("Mizuho Bank India", "Foreign Bank", ("MIZUHO BANK",), ("MHCB",), ("mizuhogroup.com",)),
|
||||
BankIdentity("Sumitomo Mitsui Banking Corporation India", "Foreign Bank", ("SUMITOMO MITSUI BANKING CORPORATION", "SMBC"), ("SMBC",), ("smbc.co.jp",)),
|
||||
)
|
||||
|
||||
|
||||
def _normalise(value: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9.&]+", " ", str(value or "").upper()).strip()
|
||||
|
||||
|
||||
def _alias_present(text_upper: str, alias: str) -> bool:
|
||||
normalised_alias = _normalise(alias)
|
||||
if not normalised_alias:
|
||||
return False
|
||||
return re.search(rf"(?<![A-Z0-9]){re.escape(normalised_alias)}(?![A-Z0-9])", text_upper) is not None
|
||||
|
||||
|
||||
def detect_bank_identity(
|
||||
text: str,
|
||||
*,
|
||||
ifsc: str = "",
|
||||
source_file: str = "",
|
||||
) -> BankIdentityMatch | None:
|
||||
raw_text = str(text or "")
|
||||
text_upper = _normalise(raw_text)
|
||||
ifsc_upper = re.sub(r"[^A-Z0-9]", "", str(ifsc or "").upper())
|
||||
file_upper = _normalise(Path(source_file or "").stem)
|
||||
|
||||
best: BankIdentityMatch | None = None
|
||||
for bank in BANK_IDENTITIES:
|
||||
score = 0.0
|
||||
evidence: list[str] = []
|
||||
|
||||
# Longest/special IFSC prefixes carry the strongest identity signal.
|
||||
matching_prefixes = [prefix for prefix in bank.ifsc_prefixes if ifsc_upper.startswith(prefix.upper())]
|
||||
if matching_prefixes:
|
||||
prefix = max(matching_prefixes, key=len)
|
||||
score += 0.72 if len(prefix) > 4 else 0.58
|
||||
evidence.append(f"IFSC prefix {prefix}")
|
||||
|
||||
for domain in bank.domains:
|
||||
if domain.lower() in raw_text.lower():
|
||||
score += 0.72
|
||||
evidence.append(f"domain {domain}")
|
||||
break
|
||||
|
||||
alias_hits = [alias for alias in bank.aliases if _alias_present(text_upper, alias)]
|
||||
if alias_hits:
|
||||
longest = max(alias_hits, key=len)
|
||||
# Long formal headings are stronger than abbreviations such as SBI.
|
||||
score += 0.72 if len(_normalise(longest)) >= 12 else 0.36
|
||||
evidence.append(f"name {longest}")
|
||||
|
||||
if any(_alias_present(file_upper, alias) for alias in bank.aliases):
|
||||
score += 0.10
|
||||
evidence.append("filename")
|
||||
|
||||
confidence = min(score, 0.99)
|
||||
if confidence < 0.50:
|
||||
continue
|
||||
candidate = BankIdentityMatch(bank.name, bank.category, confidence, tuple(dict.fromkeys(evidence)))
|
||||
if best is None or candidate.confidence > best.confidence:
|
||||
best = candidate
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def apply_detected_bank_identity(meta, df, text: str):
|
||||
match = detect_bank_identity(
|
||||
text,
|
||||
ifsc=str(getattr(meta, "ifsc", "") or ""),
|
||||
source_file=str(getattr(meta, "source_file", "") or ""),
|
||||
)
|
||||
if match is None:
|
||||
return meta, df
|
||||
|
||||
# Do not let a reused layout/parser brand the statement as another bank.
|
||||
# A high-confidence independent identity always wins.
|
||||
if match.confidence >= 0.68:
|
||||
meta.bank_name = match.name
|
||||
if df is not None and not df.empty:
|
||||
df = df.copy()
|
||||
df["bank_name"] = match.name
|
||||
return meta, df
|
||||
@@ -1,9 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .idfc import IDFCFirstParser
|
||||
from .axis import AxisParser
|
||||
from .hdfc import HDFCParser
|
||||
@@ -18,12 +14,15 @@ from .yes_bank import YesBankParser
|
||||
from .city_union_bank import CityUnionBankParser
|
||||
from .bank_of_baroda import BankOfBarodaParser
|
||||
from .rbl_bank import RBLBankParser
|
||||
from .andhra_pradesh_grameena_bank import AndhraPradeshGrameenaBankParser
|
||||
from .bank_identity import apply_detected_bank_identity
|
||||
from .base import extract_text
|
||||
from .template_engine import parse_with_template
|
||||
|
||||
|
||||
PARSERS = [
|
||||
IDFCFirstParser,
|
||||
AxisParser,
|
||||
AndhraPradeshGrameenaBankParser,
|
||||
CentralBankOfIndiaParser,
|
||||
YesBankParser,
|
||||
CityUnionBankParser,
|
||||
@@ -43,6 +42,7 @@ PARSERS = [
|
||||
BANK_OPTIONS = [
|
||||
("auto", "Auto Detect"),
|
||||
("axis", "Axis Bank"),
|
||||
("andhra_pradesh_grameena_bank", "Andhra Pradesh Grameena Bank"),
|
||||
("central_bank_of_india", "Central Bank of India"),
|
||||
("yes_bank", "YES Bank"),
|
||||
("city_union_bank", "City Union Bank"),
|
||||
@@ -60,6 +60,7 @@ BANK_OPTIONS = [
|
||||
|
||||
BANK_PARSERS = {
|
||||
"axis": [AxisParser],
|
||||
"andhra_pradesh_grameena_bank": [AndhraPradeshGrameenaBankParser],
|
||||
"central_bank_of_india": [CentralBankOfIndiaParser],
|
||||
"yes_bank": [YesBankParser],
|
||||
"city_union_bank": [CityUnionBankParser],
|
||||
@@ -77,7 +78,11 @@ BANK_PARSERS = {
|
||||
|
||||
|
||||
def detect_parser(text):
|
||||
scored = sorted(((parser.detect(text), parser) for parser in PARSERS), key=lambda item: item[0], reverse=True)
|
||||
scored = sorted(
|
||||
((parser.detect(text), parser) for parser in PARSERS),
|
||||
key=lambda item: item[0],
|
||||
reverse=True,
|
||||
)
|
||||
if not scored or scored[0][0] <= 0:
|
||||
return None, 0
|
||||
return scored[0][1](), scored[0][0]
|
||||
@@ -87,115 +92,50 @@ def _selected_parser(bank_key: str, text: str):
|
||||
candidates = BANK_PARSERS.get(bank_key, [])
|
||||
if not candidates:
|
||||
return None, 0
|
||||
scored = sorted(((parser.detect(text), parser) for parser in candidates), key=lambda item: item[0], reverse=True)
|
||||
scored = sorted(
|
||||
((parser.detect(text), parser) for parser in candidates),
|
||||
key=lambda item: item[0],
|
||||
reverse=True,
|
||||
)
|
||||
if scored and scored[0][0] > 0:
|
||||
return scored[0][1](), scored[0][0]
|
||||
return None, 0
|
||||
|
||||
|
||||
def _usable_result(result) -> bool:
|
||||
if result is None:
|
||||
return False
|
||||
try:
|
||||
_meta, frame = result
|
||||
return frame is not None and not frame.empty
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _attempt(parser, path, text: str, route: str):
|
||||
try:
|
||||
result = parser.parse(path, text)
|
||||
if _usable_result(result):
|
||||
meta, frame = result
|
||||
logger.info(
|
||||
"Bank analyzer route accepted: route=%s parser=%s rows=%s file=%s",
|
||||
route, getattr(meta, "parser_name", parser.__class__.__name__), len(frame), path,
|
||||
)
|
||||
return result
|
||||
logger.warning(
|
||||
"Bank analyzer route returned no rows: route=%s parser=%s file=%s",
|
||||
route, parser.__class__.__name__, path,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Bank analyzer route failed: route=%s parser=%s file=%s error=%s",
|
||||
route, parser.__class__.__name__, path, exc, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _template_first(path, text: str, hint: str):
|
||||
try:
|
||||
result = parse_with_template(path, text=text, bank_hint=hint)
|
||||
if _usable_result(result):
|
||||
meta, frame = result
|
||||
logger.info(
|
||||
"Bank analyzer template accepted: parser=%s rows=%s file=%s",
|
||||
getattr(meta, "parser_name", "TemplateBasedStatementParser"), len(frame), path,
|
||||
)
|
||||
return result
|
||||
logger.warning("Bank analyzer template returned no rows: file=%s", path)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Bank analyzer template rejected: file=%s error=%s", path, exc, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _ordered_bank_candidates(text: str):
|
||||
scored = sorted(
|
||||
((parser.detect(text), parser) for parser in PARSERS),
|
||||
key=lambda item: item[0],
|
||||
reverse=True,
|
||||
)
|
||||
return [parser for score, parser in scored if score > 0]
|
||||
def _parse_and_identify(parser, path, text: str):
|
||||
meta, df = parser.parse(path, text)
|
||||
return apply_detected_bank_identity(meta, df, text)
|
||||
|
||||
|
||||
def parse_pdf(path, bank_hint: str | None = None):
|
||||
hint = (bank_hint or "auto").strip().lower()
|
||||
text = extract_text(path)
|
||||
|
||||
template_result = _template_first(path, text, hint)
|
||||
if template_result is not None:
|
||||
return template_result
|
||||
if hint == "hsbc":
|
||||
meta, df = HSBCParser().parse(path, text)
|
||||
return apply_detected_bank_identity(meta, df, text)
|
||||
|
||||
if hint and hint != "auto":
|
||||
candidates = BANK_PARSERS.get(hint, [])
|
||||
for parser_class in sorted(candidates, key=lambda cls: cls.detect(text), reverse=True):
|
||||
result = _attempt(parser_class(), path, text, f"selected:{hint}")
|
||||
if result is not None:
|
||||
return result
|
||||
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
|
||||
parser, score = _selected_parser(hint, text)
|
||||
if parser is None:
|
||||
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
|
||||
raise ValueError(
|
||||
f"The uploaded statement does not match the selected {label} format. "
|
||||
"Please verify the selected bank or choose Auto Detect."
|
||||
)
|
||||
return _parse_and_identify(parser, path, text)
|
||||
|
||||
parser, score = detect_parser(text)
|
||||
if parser is None and not (text or "").strip():
|
||||
# Image-only statements cannot be identified by pdftotext. HSBCParser
|
||||
# performs its own OCR identification and raises a precise mismatch error.
|
||||
try:
|
||||
meta, df = HSBCParser().parse(path, text)
|
||||
return apply_detected_bank_identity(meta, df, text)
|
||||
except ValueError:
|
||||
pass
|
||||
if parser is None:
|
||||
raise ValueError(
|
||||
f"The uploaded statement could not be parsed by a validated layout template or the "
|
||||
f"selected {label} parser. Please verify that the PDF is readable and supported."
|
||||
"Unsupported statement format. Select the bank manually or add a bank-specific parser for this statement layout."
|
||||
)
|
||||
|
||||
attempted: set[type] = set()
|
||||
for parser_class in _ordered_bank_candidates(text):
|
||||
attempted.add(parser_class)
|
||||
result = _attempt(parser_class(), path, text, "auto-detected")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if not (text or "").strip():
|
||||
result = _attempt(HSBCParser(), path, text, "image-only-ocr")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Last-resort compatibility pass: a detector may miss a new layout even
|
||||
# though the bank-specific parser can parse its visual table correctly.
|
||||
for parser_class in PARSERS:
|
||||
if parser_class in attempted:
|
||||
continue
|
||||
result = _attempt(parser_class(), path, text, "compatibility-fallback")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
raise ValueError(
|
||||
"The bank statement format was identified, but no transaction rows could be extracted. "
|
||||
"The template engine and all bank-specific parsers were attempted. "
|
||||
"Please verify that the PDF is text-readable and that this statement layout is supported."
|
||||
)
|
||||
|
||||
return _parse_and_identify(parser, path, text)
|
||||
|
||||
Reference in New Issue
Block a user