#!/usr/bin/env python3 """Shared Fio bank fetching utilities.""" import json import os import re import sys import urllib.request from datetime import datetime from html.parser import HTMLParser # --------------------------------------------------------------------------- # Transaction fetching # --------------------------------------------------------------------------- class FioTableParser(HTMLParser): """Parse the second on the Fio transparent page. Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka Indices: 0 1 2 3 4 5 6 7 8 """ def __init__(self): super().__init__() self._table_count = 0 self._in_target_table = False self._in_thead = False self._in_row = False self._in_cell = False self._current_row: list[str] = [] self._rows: list[list[str]] = [] self._cell_text = "" def handle_starttag(self, tag, attrs): cls = dict(attrs).get("class", "") if tag == "table" and "table" in cls.split(): self._table_count += 1 if self._table_count == 2: self._in_target_table = True if self._in_target_table: if tag == "thead": self._in_thead = True if tag == "tr" and not self._in_thead: self._in_row = True self._current_row = [] if self._in_row and tag in ("td", "th"): self._in_cell = True self._cell_text = "" def handle_endtag(self, tag): if self._in_cell and tag in ("td", "th"): self._in_cell = False self._current_row.append(self._cell_text.strip()) if tag == "thead": self._in_thead = False if self._in_row and tag == "tr": self._in_row = False if self._current_row: self._rows.append(self._current_row) if tag == "table" and self._in_target_table: self._in_target_table = False def handle_data(self, data): if self._in_cell: self._cell_text += data def get_rows(self) -> list[list[str]]: return self._rows # Fio transparent table column indices _COL_DATE = 0 _COL_AMOUNT = 1 _COL_SENDER = 3 _COL_MESSAGE = 4 _COL_KS = 5 _COL_VS = 6 _COL_SS = 7 _COL_NOTE = 8 def parse_czech_amount(s: str) -> float | None: """Parse '1 500,00 CZK' to float.""" s = s.replace("\xa0", "").replace(" ", "").replace(",", ".") s = re.sub(r"[A-Za-z]+", "", s).strip() try: return float(s) except ValueError: return None def parse_czech_date(s: str) -> str | None: """Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY' and 'DD.MM.YY' in the same response.""" s = s.strip() for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"): try: return datetime.strptime(s, fmt).strftime("%Y-%m-%d") except ValueError: continue return None def fetch_transactions_transparent( date_from: str, date_to: str, account_id: str ) -> list[dict]: """Fetch transactions from Fio transparent account HTML page. Args: date_from: D.M.YYYY format date_to: D.M.YYYY format """ url = ( f"https://ib.fio.cz/ib/transparent?a={account_id}" f"&f={date_from}&t={date_to}" ) req = urllib.request.Request(url) with urllib.request.urlopen(req) as resp: html = resp.read().decode("utf-8") parser = FioTableParser() parser.feed(html) rows = parser.get_rows() transactions = [] for row in rows: if len(row) < 5: continue def col(i): return row[i].strip() if i < len(row) else "" date_str = parse_czech_date(col(_COL_DATE)) amount = parse_czech_amount(col(_COL_AMOUNT)) if date_str is None or amount is None or amount <= 0: continue transactions.append({ "date": date_str, "amount": amount, "sender": col(_COL_SENDER), "message": col(_COL_MESSAGE), "ks": col(_COL_KS), "vs": col(_COL_VS), "ss": col(_COL_SS), "note": col(_COL_NOTE), "bank_id": "", # HTML scraping doesn't give stable ID }) print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr) return transactions def fetch_transactions_api( token: str, date_from: str, date_to: str ) -> list[dict]: """Fetch transactions via Fio REST API (JSON). Args: token: Fio API token date_from: YYYY-MM-DD format date_to: YYYY-MM-DD format """ url = ( f"https://fioapi.fio.cz/v1/rest/periods/{token}" f"/{date_from}/{date_to}/transactions.json" ) req = urllib.request.Request(url) with urllib.request.urlopen(req) as resp: data = json.loads(resp.read().decode("utf-8")) transactions = [] tx_list = data.get("accountStatement", {}).get("transactionList", {}) raw_list = tx_list.get("transaction") or [] for tx in raw_list: # Each field is {"value": ..., "name": ..., "id": ...} or null def val(col_id): col = tx.get(f"column{col_id}") return col["value"] if col else "" amount = float(val(1) or 0) if amount <= 0: continue # Skip outgoing date_raw = val(0) or "" # API returns date as "YYYY-MM-DD+HHMM" or ISO format date_str = date_raw[:10] if date_raw else "" transactions.append({ "date": date_str, "amount": amount, "sender": str(val(10) or ""), # column10 = sender name "message": str(val(16) or ""), # column16 = message for recipient "vs": str(val(5) or ""), # column5 = VS "ks": str(val(4) or ""), # column4 = KS "ss": str(val(6) or ""), # column6 = SS "user_id": str(val(7) or ""), # column7 = user identification "sender_account": str(val(2) or ""), # column2 = sender account "bank_id": str(val(22) or ""), # column22 = ID operace "currency": str(val(14) or "CZK"), # column14 = Currency }) print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr) return transactions def fetch_transactions_for(account: dict, date_from: str, date_to: str) -> list[dict]: """Fetch transactions for a single loaded account dict (from config.LOADED_ACCOUNTS). Uses the API path if the account has a token, otherwise the transparent scraper. date_from/date_to: YYYY-MM-DD. """ token = (account.get("token") or "").strip() acct_num = account["acct_num"] if token: print(f"fio: account {acct_num}: using authenticated API, window {date_from}..{date_to}", file=sys.stderr) return fetch_transactions_api(token, date_from, date_to) print( f"fio: account {acct_num}: using transparent page (no token — expect publishing lag), " f"window {date_from}..{date_to}", file=sys.stderr, ) from_dt = datetime.strptime(date_from, "%Y-%m-%d") to_dt = datetime.strptime(date_to, "%Y-%m-%d") return fetch_transactions_transparent( from_dt.strftime("%d.%m.%Y"), to_dt.strftime("%d.%m.%Y"), account_id=acct_num, ) def fetch_transactions_all( date_from: str, date_to: str, accounts: list[dict] | None = None ) -> list[dict]: """Fetch and combine transactions from all configured accounts. accounts: list of loaded account dicts (defaults to config.LOADED_ACCOUNTS). Returns a flat list of all transactions across all accounts. """ if accounts is None: from config import LOADED_ACCOUNTS accounts = LOADED_ACCOUNTS all_txns: list[dict] = [] for account in accounts: txns = fetch_transactions_for(account, date_from, date_to) print(f"fio: account {account['acct_num']}: {len(txns)} transaction(s)", file=sys.stderr) all_txns.extend(txns) print(f"fio: total {len(all_txns)} transaction(s) across {len(accounts)} account(s)", file=sys.stderr) return all_txns