extracter / app.py
deveos's picture
Upload 4 files
f064732 verified
Raw
History Blame Contribute Delete
9.68 kB
import re
import json
import tempfile
from datetime import datetime
from typing import List, Dict, Any, Optional
import gradio as gr
import pdfplumber
DATE_PATTERNS = [
(re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\b"), "dmy_num"),
(re.compile(r"\b([A-Za-z]{3})[/-](\d{1,2})[/-](\d{4})\b"), "mon_d_y"),
(re.compile(r"\b(\d{1,2})[/-]([A-Za-z]{3})[/-](\d{4})\b"), "d_mon_y"),
(re.compile(r"\b(\d{1,2})-([A-Za-z]{3})-(\d{2,4})\b"), "d_mon_y"),
]
MONTHS = {
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}
SKIP_WORDS = [
"opening balance", "page total", "closing balance", "statement of account",
"txn date", "value date", "narration", "particulars", "withdrawals", "deposits",
"balance", "account number", "branch name", "ifsc", "customer id",
]
def clean_text(x: Any) -> str:
if x is None:
return ""
s = str(x).replace("\n", " ").replace("\r", " ")
s = re.sub(r"\s+", " ", s).strip()
return s
def fix_number_spaces(s: str) -> str:
s = clean_text(s)
# 11962.9 6 -> 11962.96, 675725 6.04 -> 6757256.04
s = re.sub(r"(?<=\d)\s+(?=\d(?:\.\d+)?\b)", "", s)
return s
def amount_value(s: Any) -> str:
s = fix_number_spaces(str(s or ""))
s = re.sub(r"\b(Cr|Dr)\b", "", s, flags=re.I).strip()
m = re.search(r"-?\d[\d,]*\.?\d*", s)
if not m:
return ""
try:
return f"{float(m.group(0).replace(',', '')):.2f}"
except Exception:
return m.group(0).replace(',', '')
def balance_value(s: Any) -> str:
s = fix_number_spaces(str(s or ""))
sign = ""
msign = re.search(r"\b(Cr|Dr)\b", s, flags=re.I)
if msign:
sign = msign.group(1).title()
amt = amount_value(s)
return f"{amt} {sign}".strip() if amt else ""
def parse_date(text: str, default_year: Optional[int] = None) -> str:
text = clean_text(text)
for rx, kind in DATE_PATTERNS:
m = rx.search(text)
if not m:
continue
try:
if kind == "dmy_num":
d, mo, y = m.groups()
y = int(y)
if y < 100:
y += 2000
return datetime(y, int(mo), int(d)).strftime("%Y-%m-%d")
if kind == "mon_d_y":
mo, d, y = m.groups()
return datetime(int(y), MONTHS[mo.lower()], int(d)).strftime("%Y-%m-%d")
if kind == "d_mon_y":
d, mo, y = m.groups()
y = int(y)
if y < 100:
y += 2000
return datetime(y, MONTHS[mo.lower()], int(d)).strftime("%Y-%m-%d")
except Exception:
pass
return ""
def is_skip_row(text: str) -> bool:
t = text.lower()
return any(w in t for w in SKIP_WORDS)
def normalize_headers(row: List[str]) -> List[str]:
return [clean_text(c).lower().replace(" ", "_").replace("/", "_") for c in row]
def parse_known_table(headers: List[str], rows: List[List[str]]) -> List[Dict[str, str]]:
out = []
h = headers
def idx(*names):
for n in names:
for i, col in enumerate(h):
if n in col:
return i
return -1
date_i = idx("txn_date", "date")
narr_i = idx("narration", "particular", "description")
debit_i = idx("debit", "withdrawal")
credit_i = idx("credit", "deposit")
bal_i = idx("balance")
if date_i < 0 or narr_i < 0 or bal_i < 0 or (debit_i < 0 and credit_i < 0):
return []
pending = None
for row in rows:
row = [clean_text(c) for c in row]
txt = " ".join(row)
if not txt or is_skip_row(txt):
continue
date = parse_date(row[date_i] if date_i < len(row) else "")
narr = row[narr_i] if narr_i < len(row) else ""
debit = amount_value(row[debit_i]) if debit_i >= 0 and debit_i < len(row) else ""
credit = amount_value(row[credit_i]) if credit_i >= 0 and credit_i < len(row) else ""
bal = balance_value(row[bal_i]) if bal_i < len(row) else ""
if date and (debit or credit):
if pending:
out.append(pending)
pending = {
"date": date,
"narration": narr,
"voucher_type": "Payment" if debit else "Receipt",
"amount": debit or credit,
"closing_balance": bal,
}
elif pending and narr:
pending["narration"] = clean_text(pending["narration"] + " " + narr)
if bal and not pending.get("closing_balance"):
pending["closing_balance"] = bal
if pending:
out.append(pending)
return out
def parse_text_lines(text: str) -> List[Dict[str, str]]:
out = []
pending = None
lines = [clean_text(x) for x in text.splitlines() if clean_text(x)]
for line in lines:
low = line.lower()
if is_skip_row(line):
continue
date = parse_date(line)
nums = re.findall(r"(?:\d[\d,]*\.?\d*)\s*(?:Cr|Dr)?", fix_number_spaces(line), flags=re.I)
# heuristic: date + at least amount + balance
if date and len(nums) >= 2:
if pending:
out.append(pending)
bal = balance_value(nums[-1])
amt = amount_value(nums[-2])
# if line contains Dr near debit amount, assume Payment; otherwise Receipt for many bank PDFs
before_bal = line[: line.rfind(nums[-1]) if nums[-1] in line else len(line)]
vtype = "Payment" if re.search(r"\bDr\b", before_bal, re.I) or " to " in low or low.startswith("to ") else "Receipt"
narration = line
for rx, _ in DATE_PATTERNS:
narration = rx.sub("", narration, count=1)
narration = clean_text(narration)
pending = {
"date": date,
"narration": narration,
"voucher_type": vtype,
"amount": amt,
"closing_balance": bal,
}
elif pending and not date:
pending["narration"] = clean_text(pending["narration"] + " " + line)
if pending:
out.append(pending)
return out
def extract_pdf(pdf_path: str, max_pages: int = 0) -> Dict[str, Any]:
transactions = []
pages_done = 0
debug = []
with pdfplumber.open(pdf_path) as pdf:
pages = pdf.pages[:max_pages] if max_pages and max_pages > 0 else pdf.pages
for pno, page in enumerate(pages, start=1):
pages_done += 1
page_txns = []
try:
tables = page.extract_tables() or []
for table in tables:
if not table or len(table) < 2:
continue
header_row_idx = None
for i, r in enumerate(table[:4]):
joined = " ".join(clean_text(c) for c in r).lower()
if ("date" in joined and ("debit" in joined or "withdraw" in joined) and ("credit" in joined or "deposit" in joined)):
header_row_idx = i
break
if header_row_idx is not None:
headers = normalize_headers(table[header_row_idx])
rows = table[header_row_idx + 1:]
page_txns.extend(parse_known_table(headers, rows))
except Exception as e:
debug.append(f"Page {pno} table error: {e}")
if not page_txns:
try:
text = page.extract_text(x_tolerance=1, y_tolerance=3) or ""
page_txns.extend(parse_text_lines(text))
except Exception as e:
debug.append(f"Page {pno} text error: {e}")
transactions.extend(page_txns)
# final cleanup + de-duplicate simple duplicates
final = []
seen = set()
for t in transactions:
if not t.get("date") or not t.get("amount"):
continue
t["narration"] = clean_text(t.get("narration", ""))
key = (t["date"], t["narration"], t["amount"], t.get("closing_balance", ""))
if key not in seen:
seen.add(key)
final.append(t)
return {
"success": True,
"total_transactions": len(final),
"pages_processed": pages_done,
"transactions": final,
"debug": debug[:20],
}
def gradio_extract(file, max_pages):
if file is None:
return {"success": False, "error": "Upload PDF file"}, ""
result = extract_pdf(file.name, int(max_pages or 0))
return result, json.dumps(result.get("transactions", []), indent=2, ensure_ascii=False)
def build_ui():
with gr.Blocks(title="Free Bank Statement PDF to JSON") as demo:
gr.Markdown("# Free Bank Statement PDF → Transaction JSON\nFast test app for Hugging Face Spaces. Output fields: `date`, `narration`, `voucher_type`, `amount`, `closing_balance`.")
with gr.Row():
pdf = gr.File(label="Upload Bank Statement PDF", file_types=[".pdf"])
max_pages = gr.Number(label="Max pages for testing (0 = all)", value=0, precision=0)
btn = gr.Button("Extract Transactions", variant="primary")
output = gr.JSON(label="Full Response")
tx_json = gr.Code(label="Transactions JSON", language="json")
btn.click(gradio_extract, inputs=[pdf, max_pages], outputs=[output, tx_json])
return demo
if __name__ == "__main__":
build_ui().queue(default_concurrency_limit=2).launch(server_name="0.0.0.0", server_port=7860)