deveos commited on
Commit
91f9cc4
·
verified ·
1 Parent(s): c1cd8c2

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +23 -129
  2. app.py +423 -1
  3. packages.txt +3 -0
  4. requirements.txt +6 -6
README.md CHANGED
@@ -1,147 +1,41 @@
1
  ---
2
- title: Bank Statement AI Mode Extractor
3
- emoji: 🧾
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
- # Bank Statement AI Mode Extractor
11
 
12
- Local FastAPI app that:
13
 
14
- 1. Accepts a PDF bank statement from the frontend.
15
- 2. Converts every PDF page into a PNG image.
16
- 3. Sends page images to Google AI Mode in parallel waves, one page per tab.
17
- 4. Saves every batch JSON response.
18
- 5. Combines all batch responses into one JSON file.
19
-
20
- Google AI Mode does not provide a stable public browser automation API, so this app uses Playwright Chromium. In Docker Spaces it runs as a headed browser inside `xvfb-run`.
21
-
22
- ## Setup
23
-
24
- ```powershell
25
- python -m venv .venv
26
- .\.venv\Scripts\Activate.ps1
27
- pip install -r requirements.txt
28
- python -m playwright install chromium
29
- ```
30
-
31
- ## Run
32
-
33
- ```powershell
34
- uvicorn app.main:app --reload --port 8000
35
- ```
36
-
37
- Open:
38
-
39
- ```text
40
- http://127.0.0.1:8000
41
- ```
42
-
43
- ## Important
44
-
45
- - Hugging Face Spaces should be created with `sdk: docker`.
46
- - The frontend uploads only `pdf` and optional `pwd`.
47
- - The frontend polls `/api/jobs/{job_id}` for job status.
48
- - Public deployment may be blocked by Google AI Mode bot checks, regional availability, or account/login requirements.
49
- - If Google changes AI Mode controls, adjust the AI Mode URL or selectors in `app/google_ai_mode.py`.
50
- - Output files are stored under `runs/<job_id>/`.
51
-
52
- ## Hugging Face Deployment
53
-
54
- Create a new Space:
55
-
56
- ```text
57
- SDK: Docker
58
- Visibility: Public or Private
59
- ```
60
-
61
- Upload this repository's files to the Space, including:
62
-
63
- ```text
64
- Dockerfile
65
- README.md
66
- requirements.txt
67
- app.py
68
- app/
69
- static/
70
- ```
71
-
72
- Do not upload local runtime folders:
73
-
74
- ```text
75
- runs/
76
- browser-profile/
77
- browser-profile-debug-upload/
78
- browser-profile-visible-test/
79
- ```
80
-
81
- The Dockerfile runs:
82
-
83
- ```bash
84
- xvfb-run -a uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
85
- ```
86
-
87
- Supported environment variables:
88
-
89
- ```text
90
- API_ONLY=true
91
- PARALLEL_TABS=10
92
- BROWSER_HEADLESS=false
93
- BROWSER_CHANNEL=chromium
94
- BROWSER_PROXY_SERVER=http://host:port
95
- BROWSER_PROXY_USERNAME=optional
96
- BROWSER_PROXY_PASSWORD=optional
97
- ```
98
-
99
- Only one proxy server is supported for normal network routing. Rotating proxy/IP logic is intentionally not included.
100
-
101
- Status response includes frontend-friendly counters:
102
 
103
  ```json
104
  {
105
- "status": "extracting",
106
- "total_pages": 50,
107
- "completed_pages": 20,
108
- "total_batches": 5,
109
- "completed_batches": 2,
110
- "active_tabs": 10,
111
- "current_pages": "21-30",
112
- "page_statuses": {
113
- "21": "queued"
114
- },
115
- "latest_screenshot": "server-side path"
116
  }
117
  ```
118
 
119
- Browser view screenshots:
120
 
121
- ```bash
122
- curl -L "$SPACE_URL/api/jobs/<job_id>/browser-view/latest" -o latest.png
123
- curl -L "$SPACE_URL/api/jobs/<job_id>/browser-view/page/21" -o page-21.png
124
- ```
125
 
126
- ## API
127
 
128
- Create a job:
129
 
130
- ```bash
131
- curl -X POST "$SPACE_URL/api/jobs" \
132
- -F "pdf=@statement.pdf" \
133
- -F "pwd=optional-password"
134
  ```
135
 
136
- Check status:
137
-
138
- ```bash
139
- curl "$SPACE_URL/api/jobs/<job_id>"
140
- ```
141
-
142
- Download:
143
-
144
- ```bash
145
- curl -L "$SPACE_URL/api/jobs/<job_id>/download" -o result.json
146
- curl -L "$SPACE_URL/api/jobs/<job_id>/download.csv" -o result.csv
147
- ```
 
1
  ---
2
+ title: Bank PDF To Transaction JSON
3
+ eemoji: 🧾
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Free Bank Statement PDF Transaction JSON
13
 
14
+ Modified from the MinerU Space idea, but focused on bank transaction extraction.
15
 
16
+ ## Output schema
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ```json
19
  {
20
+ "date": "",
21
+ "narration": "",
22
+ "voucher_type": "Payment/Receipt",
23
+ "amount": "",
24
+ "closing_balance": ""
 
 
 
 
 
 
25
  }
26
  ```
27
 
28
+ ## Fast mode
29
 
30
+ Uses `pdfplumber` first, which is fast and good for text/table PDFs.
 
 
 
31
 
32
+ ## MinerU fallback
33
 
34
+ The app has a checkbox for MinerU fallback, but MinerU is commented in `requirements.txt` because it is heavy. For GPU Space, uncomment:
35
 
36
+ ```txt
37
+ mineru[pipeline,api]>=2.7.6
38
+ gradio-pdf>=0.0.22
 
39
  ```
40
 
41
+ Then restart the Space.
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1 +1,423 @@
1
- from app.main import app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space: Fast PDF Bank Statement -> JSON
2
+ # Modified from the idea of opendatalab/MinerU Space:
3
+ # - original launches generic mineru-gradio
4
+ # - this app is bank-focused and returns only transactions JSON
5
+ # - fast path: pdfplumber text/table extraction
6
+ # - fallback: optional MinerU CLI if installed/available
7
+
8
+ import os
9
+ import re
10
+ import json
11
+ import tempfile
12
+ import subprocess
13
+ from datetime import datetime
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import gradio as gr
17
+ import pdfplumber
18
+
19
+ os.environ.setdefault("GRADIO_SSR_MODE", "false")
20
+ os.environ.setdefault("MINERU_MODEL_SOURCE", "local")
21
+ os.environ.setdefault("MINERU_PDF_RENDER_TIMEOUT", "10")
22
+
23
+ MONTHS = {
24
+ "jan": "01", "feb": "02", "mar": "03", "apr": "04", "may": "05", "jun": "06",
25
+ "jul": "07", "aug": "08", "sep": "09", "oct": "10", "nov": "11", "dec": "12",
26
+ "january": "01", "february": "02", "march": "03", "april": "04", "june": "06",
27
+ "july": "07", "august": "08", "september": "09", "october": "10", "november": "11", "december": "12",
28
+ }
29
+
30
+ SKIP_WORDS = [
31
+ "opening balance", "page total", "closing balance", "transaction details", "account statement",
32
+ "txn date", "value date", "narration", "debit", "credit", "balance", "branch name",
33
+ "account number", "ifsc", "customer id", "from date", "to date", "statement of account",
34
+ ]
35
+
36
+
37
+ def clean_spaces(s: Any) -> str:
38
+ if s is None:
39
+ return ""
40
+ s = str(s).replace("\n", " ").replace("\t", " ")
41
+ s = re.sub(r"\s+", " ", s).strip()
42
+ return s
43
+
44
+
45
+ def fix_number_spaces(s: str) -> str:
46
+ s = clean_spaces(s)
47
+ # 11962.9 6 -> 11962.96, 675725 6.04 -> 6757256.04
48
+ old = None
49
+ while old != s:
50
+ old = s
51
+ s = re.sub(r"(?<=\d)\s+(?=\d)", "", s)
52
+ return s
53
+
54
+
55
+ def clean_amount(s: Any) -> str:
56
+ s = fix_number_spaces(str(s or ""))
57
+ s = re.sub(r"\b(Cr|Dr)\b", "", s, flags=re.I)
58
+ s = s.replace(",", "")
59
+ m = re.search(r"-?\d+(?:\.\d+)?", s)
60
+ if not m:
61
+ return ""
62
+ try:
63
+ return f"{float(m.group(0)):.2f}"
64
+ except Exception:
65
+ return m.group(0)
66
+
67
+
68
+ def clean_balance(s: Any) -> str:
69
+ s = fix_number_spaces(str(s or ""))
70
+ sign = ""
71
+ msign = re.search(r"\b(Cr|Dr)\b", s, flags=re.I)
72
+ if msign:
73
+ sign = msign.group(1).title()
74
+ amt = clean_amount(s)
75
+ return f"{amt} {sign}".strip() if amt else ""
76
+
77
+
78
+ def parse_date(s: str) -> str:
79
+ s = clean_spaces(s)
80
+ if not s:
81
+ return ""
82
+ # Apr/01/2024 or 01/Apr/2024
83
+ for fmt in ["%b/%d/%Y", "%d/%b/%Y", "%d-%m-%Y", "%d/%m/%Y", "%d-%m-%y", "%d/%m/%y", "%Y-%m-%d"]:
84
+ try:
85
+ return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
86
+ except Exception:
87
+ pass
88
+ m = re.search(r"(\d{1,2})[-/ ]([A-Za-z]{3,9})[-/ ](\d{2,4})", s)
89
+ if m:
90
+ d, mon, y = m.groups()
91
+ mm = MONTHS.get(mon.lower()[:3])
92
+ if mm:
93
+ if len(y) == 2:
94
+ y = "20" + y
95
+ return f"{y}-{mm}-{int(d):02d}"
96
+ m = re.search(r"([A-Za-z]{3,9})[-/ ](\d{1,2})[-/ ](\d{2,4})", s)
97
+ if m:
98
+ mon, d, y = m.groups()
99
+ mm = MONTHS.get(mon.lower()[:3])
100
+ if mm:
101
+ if len(y) == 2:
102
+ y = "20" + y
103
+ return f"{y}-{mm}-{int(d):02d}"
104
+ return s
105
+
106
+
107
+ def looks_date(s: str) -> bool:
108
+ s = clean_spaces(s)
109
+ return bool(re.search(r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}|\d{1,2}[-/ ][A-Za-z]{3,9}[-/ ]\d{2,4}|[A-Za-z]{3,9}[-/]\d{1,2}[-/]\d{2,4}", s))
110
+
111
+
112
+ def is_skip_row(text: str) -> bool:
113
+ t = text.lower()
114
+ return any(w in t for w in SKIP_WORDS)
115
+
116
+
117
+ def detect_bank_text(text: str) -> str:
118
+ t = text.lower()
119
+ if "vidarbha merchants" in t:
120
+ return "Vidarbha Merchants Urban Co-op Bank Ltd."
121
+ if "bank of baroda" in t:
122
+ return "Bank of Baroda"
123
+ if "hdfc bank" in t:
124
+ return "HDFC Bank"
125
+ if "icici bank" in t:
126
+ return "ICICI Bank"
127
+ if "state bank of india" in t or "sbi" in t:
128
+ return "State Bank of India"
129
+ return "Unknown"
130
+
131
+
132
+ def extract_account_number(text: str) -> str:
133
+ patterns = [
134
+ r"Account Number\s*[:-]?\s*([0-9Xx* -]{6,})",
135
+ r"A/C Number\s*[:-]?\s*([0-9Xx* -]{6,})",
136
+ r"Account No\.?\s*[:-]?\s*([0-9Xx* -]{6,})",
137
+ r"A/C No\.?\s*[:-]?\s*([0-9Xx* -]{6,})",
138
+ ]
139
+ for p in patterns:
140
+ m = re.search(p, text, flags=re.I)
141
+ if m:
142
+ return re.sub(r"\s+", "", m.group(1)).strip()
143
+ return ""
144
+
145
+
146
+ def make_txn(date: str, narration: str, debit: str, credit: str, balance: str) -> Optional[Dict[str, str]]:
147
+ narration = clean_spaces(narration)
148
+ if not date or not narration or is_skip_row(narration):
149
+ return None
150
+ debit_amt = clean_amount(debit)
151
+ credit_amt = clean_amount(credit)
152
+ if debit_amt:
153
+ voucher_type = "Payment"
154
+ amount = debit_amt
155
+ elif credit_amt:
156
+ voucher_type = "Receipt"
157
+ amount = credit_amt
158
+ else:
159
+ return None
160
+ return {
161
+ "date": parse_date(date),
162
+ "narration": narration,
163
+ "voucher_type": voucher_type,
164
+ "amount": amount,
165
+ "closing_balance": clean_balance(balance),
166
+ }
167
+
168
+
169
+ def parse_structured_table(table: List[List[Any]]) -> List[Dict[str, str]]:
170
+ out: List[Dict[str, str]] = []
171
+ if not table:
172
+ return out
173
+
174
+ header_idx = -1
175
+ headers: List[str] = []
176
+ for i, row in enumerate(table[:8]):
177
+ cells = [clean_spaces(c).lower() for c in row]
178
+ joined = " ".join(cells)
179
+ if ("date" in joined and "balance" in joined) and ("debit" in joined or "withdraw" in joined) and ("credit" in joined or "deposit" in joined):
180
+ header_idx = i
181
+ headers = cells
182
+ break
183
+
184
+ if header_idx == -1:
185
+ return parse_loose_table(table)
186
+
187
+ def find_col(*names: str) -> int:
188
+ for name in names:
189
+ for idx, h in enumerate(headers):
190
+ if name in h:
191
+ return idx
192
+ return -1
193
+
194
+ date_i = find_col("txn date", "date")
195
+ narr_i = find_col("narration", "particular", "description")
196
+ debit_i = find_col("debit", "withdraw")
197
+ credit_i = find_col("credit", "deposit")
198
+ bal_i = find_col("balance")
199
+
200
+ pending: Optional[Dict[str, str]] = None
201
+ for row in table[header_idx + 1:]:
202
+ row = [clean_spaces(c) for c in row]
203
+ if not any(row):
204
+ continue
205
+ row_text = " ".join(row)
206
+ if is_skip_row(row_text):
207
+ continue
208
+
209
+ date = row[date_i] if 0 <= date_i < len(row) else ""
210
+ narr = row[narr_i] if 0 <= narr_i < len(row) else ""
211
+ debit = row[debit_i] if 0 <= debit_i < len(row) else ""
212
+ credit = row[credit_i] if 0 <= credit_i < len(row) else ""
213
+ bal = row[bal_i] if 0 <= bal_i < len(row) else ""
214
+
215
+ if date and looks_date(date):
216
+ txn = make_txn(date, narr, debit, credit, bal)
217
+ if txn:
218
+ out.append(txn)
219
+ pending = out[-1]
220
+ else:
221
+ pending = None
222
+ elif pending and narr and not clean_amount(row_text):
223
+ pending["narration"] = clean_spaces(pending["narration"] + " " + narr)
224
+ return out
225
+
226
+
227
+ def parse_loose_table(table: List[List[Any]]) -> List[Dict[str, str]]:
228
+ out: List[Dict[str, str]] = []
229
+ pending: Optional[Dict[str, str]] = None
230
+ for row in table:
231
+ cells = [clean_spaces(c) for c in row if clean_spaces(c)]
232
+ if not cells:
233
+ continue
234
+ text = " ".join(cells)
235
+ if is_skip_row(text):
236
+ continue
237
+ if looks_date(cells[0]) and len(cells) >= 3:
238
+ date = cells[0]
239
+ # heuristic: last is balance, amount is previous numeric, narration middle
240
+ balance = cells[-1]
241
+ nums = [(i, clean_amount(c)) for i, c in enumerate(cells)]
242
+ nums = [(i, a) for i, a in nums if a]
243
+ if len(nums) >= 2:
244
+ amount_i, amount = nums[-2]
245
+ bal = cells[nums[-1][0]]
246
+ narration = " ".join(cells[1:amount_i]) or cells[1]
247
+ # Most loose BOB examples have deposit-only amount; default Receipt.
248
+ txn = {
249
+ "date": parse_date(date),
250
+ "narration": narration,
251
+ "voucher_type": "Receipt",
252
+ "amount": amount,
253
+ "closing_balance": clean_balance(bal),
254
+ }
255
+ out.append(txn)
256
+ pending = out[-1]
257
+ elif pending and not re.search(r"\bpage total\b", text, flags=re.I):
258
+ # continuation narration line
259
+ if not clean_amount(text) or text.upper().startswith(("UPI/", "IMPS/", "NEFT/", "RTGS/")):
260
+ pending["narration"] = clean_spaces(pending["narration"] + " " + text)
261
+ return out
262
+
263
+
264
+ def extract_with_pdfplumber(pdf_path: str, max_pages: int = 30) -> Tuple[List[Dict[str, str]], str, str, int]:
265
+ transactions: List[Dict[str, str]] = []
266
+ all_text = []
267
+ pages_read = 0
268
+ with pdfplumber.open(pdf_path) as pdf:
269
+ for page in pdf.pages[:max_pages]:
270
+ pages_read += 1
271
+ txt = page.extract_text() or ""
272
+ all_text.append(txt)
273
+ tables = page.extract_tables() or []
274
+ for table in tables:
275
+ transactions.extend(parse_structured_table(table))
276
+
277
+ # fallback line parser if no tables on page
278
+ if not tables and txt:
279
+ transactions.extend(parse_text_lines(txt))
280
+
281
+ full_text = "\n".join(all_text)
282
+ return dedupe_transactions(transactions), detect_bank_text(full_text), extract_account_number(full_text), pages_read
283
+
284
+
285
+ def parse_text_lines(text: str) -> List[Dict[str, str]]:
286
+ out: List[Dict[str, str]] = []
287
+ pending: Optional[Dict[str, str]] = None
288
+ for line in text.splitlines():
289
+ line = clean_spaces(line)
290
+ if not line or is_skip_row(line):
291
+ continue
292
+ if looks_date(line):
293
+ parts = line.split()
294
+ date_part = parts[0]
295
+ if not looks_date(date_part) and len(parts) > 1:
296
+ date_part = " ".join(parts[:3])
297
+ nums = list(re.finditer(r"(?:\d{1,3}(?:,\d{2,3})+|\d+)(?:\.\d+)?\s*(?:Cr|Dr)?", line, flags=re.I))
298
+ if len(nums) >= 2:
299
+ amount_m = nums[-2]
300
+ balance_m = nums[-1]
301
+ narr = line[len(date_part):amount_m.start()].strip()
302
+ debit_credit_part = line[amount_m.start():amount_m.end()]
303
+ # simple heuristic by Cr/Dr marker near amount
304
+ voucher = "Payment" if re.search(r"\bDr\b", debit_credit_part, re.I) else "Receipt"
305
+ txn = {
306
+ "date": parse_date(date_part),
307
+ "narration": clean_spaces(narr),
308
+ "voucher_type": voucher,
309
+ "amount": clean_amount(amount_m.group(0)),
310
+ "closing_balance": clean_balance(balance_m.group(0)),
311
+ }
312
+ if txn["narration"] and txn["amount"]:
313
+ out.append(txn)
314
+ pending = out[-1]
315
+ elif pending:
316
+ pending["narration"] = clean_spaces(pending["narration"] + " " + line)
317
+ return out
318
+
319
+
320
+ def dedupe_transactions(txns: List[Dict[str, str]]) -> List[Dict[str, str]]:
321
+ seen = set()
322
+ out = []
323
+ for t in txns:
324
+ key = (t.get("date"), t.get("narration"), t.get("voucher_type"), t.get("amount"), t.get("closing_balance"))
325
+ if key not in seen:
326
+ seen.add(key)
327
+ out.append(t)
328
+ return out
329
+
330
+
331
+ def mineru_available() -> bool:
332
+ try:
333
+ r = subprocess.run(["bash", "-lc", "command -v mineru || command -v magic-pdf"], capture_output=True, text=True, timeout=3)
334
+ return r.returncode == 0 and bool(r.stdout.strip())
335
+ except Exception:
336
+ return False
337
+
338
+
339
+ def extract_with_mineru_cli(pdf_path: str, max_pages: int = 20) -> Tuple[List[Dict[str, str]], str]:
340
+ """Optional fallback. MinerU CLI output names differ by version, so we scan output md/json/txt."""
341
+ if not mineru_available():
342
+ return [], "MinerU CLI not found. Fast pdfplumber path used only."
343
+ out_dir = tempfile.mkdtemp(prefix="mineru_out_")
344
+ cmd_candidates = [
345
+ f"mineru -p '{pdf_path}' -o '{out_dir}'",
346
+ f"magic-pdf -p '{pdf_path}' -o '{out_dir}' -m auto",
347
+ ]
348
+ last_err = ""
349
+ for cmd in cmd_candidates:
350
+ try:
351
+ r = subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True, timeout=240)
352
+ if r.returncode == 0:
353
+ texts = []
354
+ for root, _, files in os.walk(out_dir):
355
+ for fn in files:
356
+ if fn.lower().endswith((".md", ".txt", ".json")):
357
+ p = os.path.join(root, fn)
358
+ try:
359
+ texts.append(open(p, "r", encoding="utf-8", errors="ignore").read())
360
+ except Exception:
361
+ pass
362
+ parsed = parse_text_lines("\n".join(texts))
363
+ return dedupe_transactions(parsed), "MinerU fallback completed."
364
+ last_err = r.stderr[-1000:] or r.stdout[-1000:]
365
+ except Exception as e:
366
+ last_err = str(e)
367
+ return [], "MinerU fallback failed: " + last_err[:500]
368
+
369
+
370
+ def extract_pdf(pdf_file, max_pages: int, use_mineru_fallback: bool):
371
+ if pdf_file is None:
372
+ return {"success": False, "error": "Please upload a PDF."}, ""
373
+ pdf_path = pdf_file.name if hasattr(pdf_file, "name") else str(pdf_file)
374
+ try:
375
+ txns, bank, acc, pages = extract_with_pdfplumber(pdf_path, max_pages=max_pages)
376
+ engine = "pdfplumber-fast"
377
+ note = ""
378
+ if use_mineru_fallback and len(txns) == 0:
379
+ miner_txns, note = extract_with_mineru_cli(pdf_path, max_pages=max_pages)
380
+ if miner_txns:
381
+ txns = miner_txns
382
+ engine = "mineru-fallback"
383
+
384
+ result = {
385
+ "success": True,
386
+ "engine": engine,
387
+ "pages_read": pages,
388
+ "bank_name": bank,
389
+ "account_number": acc,
390
+ "count": len(txns),
391
+ "transactions": txns,
392
+ }
393
+ if note:
394
+ result["note"] = note
395
+ json_text = json.dumps(result, ensure_ascii=False, indent=2)
396
+ out_path = os.path.join(tempfile.gettempdir(), "transactions.json")
397
+ with open(out_path, "w", encoding="utf-8") as f:
398
+ f.write(json_text)
399
+ return result, out_path
400
+ except Exception as e:
401
+ return {"success": False, "error": str(e)}, ""
402
+
403
+
404
+ CSS = """
405
+ .gradio-container {max-width: 1100px !important}
406
+ #title {text-align:center}
407
+ """
408
+
409
+ with gr.Blocks(css=CSS, title="Free Bank PDF to JSON") as demo:
410
+ gr.Markdown("# Free PDF Bank Statement → Transaction JSON", elem_id="title")
411
+ gr.Markdown("Fast testing app for Hugging Face. Output fields: `date`, `narration`, `voucher_type`, `amount`, `closing_balance` only.")
412
+ with gr.Row():
413
+ pdf = gr.File(label="Upload bank statement PDF", file_types=[".pdf"])
414
+ with gr.Column():
415
+ max_pages = gr.Slider(1, 100, value=30, step=1, label="Max pages for testing")
416
+ mineru = gr.Checkbox(value=False, label="Use MinerU fallback only if fast parser finds 0 transactions")
417
+ btn = gr.Button("Extract JSON", variant="primary")
418
+ output = gr.JSON(label="Result JSON")
419
+ download = gr.File(label="Download transactions.json")
420
+ btn.click(extract_pdf, inputs=[pdf, max_pages, mineru], outputs=[output, download])
421
+
422
+ if __name__ == "__main__":
423
+ demo.queue(default_concurrency_limit=2).launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fonts-noto-core
2
+ fonts-noto-cjk
3
+ poppler-utils
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- fastapi==0.115.6
2
- uvicorn[standard]==0.34.0
3
- python-multipart==0.0.20
4
- pymupdf==1.25.1
5
- Pillow>=12.0.0
6
- playwright>=1.56.0
 
1
+ gradio>=4.44.0
2
+ pdfplumber>=0.11.4
3
+ pypdfium2>=4.30.0
4
+ # Optional heavy fallback. Uncomment only on GPU Space / paid Space if you really want full MinerU installed.
5
+ # mineru[pipeline,api]>=2.7.6
6
+ # gradio-pdf>=0.0.22