feat(go): IO layer behind interfaces (M4)
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs - io/drive: Drive v3 modifiedTime client + Fake - io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/ WriteHeader/SortByDateColumn) + Fake with call-capture - io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic writes; generic Get[T]; Python-compatible JSON format; Flush() - io/fio: Client interface backed by Fio REST API (apiClient) and HTML scraper (transparentClient); Fake; testdata fixtures - membership/sources: NewSources wires attendance CSV + Sheets + cache into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech month parsing + merged-month maps - banksync: SyncToSheets (SHA-256 dedup, optional sort) and InferPayments ([?] review prefix, dry-run) — tested with fakes - cmd/fuj: sync and infer subcommands wired; fees and reconcile use real NewSources; go.mod gains google.golang.org/api + x/net - gofumpt extra-rules applied across all packages; lint clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
217
go/internal/io/fio/transparent.go
Normal file
217
go/internal/io/fio/transparent.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
ghtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
||||
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
||||
type transparentClient struct {
|
||||
accountNum string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time.Time) ([]Transaction, error) {
|
||||
// Transparent page date format: D.M.YYYY
|
||||
url := fmt.Sprintf(
|
||||
"https://ib.fio.cz/ib/transparent?a=%s&f=%s&t=%s",
|
||||
c.accountNum,
|
||||
from.Format("2.1.2006"),
|
||||
to.Format("2.1.2006"),
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fio transparent: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTransparentHTML(body)
|
||||
}
|
||||
|
||||
// Column indices in the transparent-page table (0-based).
|
||||
// Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
|
||||
const (
|
||||
tColDate = 0
|
||||
tColAmount = 1
|
||||
tColSender = 3
|
||||
tColMessage = 4
|
||||
tColKS = 5
|
||||
tColVS = 6
|
||||
tColSS = 7
|
||||
)
|
||||
|
||||
func parseTransparentHTML(body []byte) ([]Transaction, error) {
|
||||
rows := extractSecondTableRows(body)
|
||||
|
||||
var txns []Transaction
|
||||
for _, row := range rows {
|
||||
col := func(i int) string {
|
||||
if i < len(row) {
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
dateStr := parseCzechDate(col(tColDate))
|
||||
amount := parseCzechAmount(col(tColAmount))
|
||||
if dateStr == "" || amount <= 0 {
|
||||
continue
|
||||
}
|
||||
txns = append(txns, Transaction{
|
||||
Date: dateStr,
|
||||
Amount: amount,
|
||||
Sender: col(tColSender),
|
||||
Message: col(tColMessage),
|
||||
KS: col(tColKS),
|
||||
VS: col(tColVS),
|
||||
SS: col(tColSS),
|
||||
BankID: "", // not available on HTML path
|
||||
})
|
||||
}
|
||||
return txns, nil
|
||||
}
|
||||
|
||||
// extractSecondTableRows walks the HTML token stream and returns data rows
|
||||
// from the second <table class="table"> element, skipping the <thead>.
|
||||
func extractSecondTableRows(body []byte) [][]string {
|
||||
z := ghtml.NewTokenizer(strings.NewReader(string(body)))
|
||||
|
||||
tableCount := 0
|
||||
inTarget := false
|
||||
inThead := false
|
||||
inRow := false
|
||||
inCell := false
|
||||
var currentRow []string
|
||||
var cellBuf strings.Builder
|
||||
var rows [][]string
|
||||
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == ghtml.ErrorToken {
|
||||
break
|
||||
}
|
||||
switch tt {
|
||||
case ghtml.StartTagToken:
|
||||
t := z.Token()
|
||||
switch t.Data {
|
||||
case "table":
|
||||
if hasClass(t, "table") {
|
||||
tableCount++
|
||||
if tableCount == 2 {
|
||||
inTarget = true
|
||||
}
|
||||
}
|
||||
case "thead":
|
||||
if inTarget {
|
||||
inThead = true
|
||||
}
|
||||
case "tr":
|
||||
if inTarget && !inThead {
|
||||
inRow = true
|
||||
currentRow = nil
|
||||
}
|
||||
case "td", "th":
|
||||
if inRow {
|
||||
inCell = true
|
||||
cellBuf.Reset()
|
||||
}
|
||||
}
|
||||
case ghtml.EndTagToken:
|
||||
t := z.Token()
|
||||
switch t.Data {
|
||||
case "td", "th":
|
||||
if inCell {
|
||||
currentRow = append(currentRow, cellBuf.String())
|
||||
inCell = false
|
||||
}
|
||||
case "thead":
|
||||
inThead = false
|
||||
case "tr":
|
||||
if inRow {
|
||||
if len(currentRow) > 0 {
|
||||
rows = append(rows, currentRow)
|
||||
}
|
||||
inRow = false
|
||||
}
|
||||
case "table":
|
||||
if inTarget {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
case ghtml.TextToken:
|
||||
if inCell {
|
||||
cellBuf.WriteString(z.Token().Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func hasClass(t ghtml.Token, cls string) bool {
|
||||
for _, a := range t.Attr {
|
||||
if a.Key == "class" {
|
||||
for _, c := range strings.Fields(a.Val) {
|
||||
if c == cls {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCzechDate parses "DD.MM.YYYY" or "DD/MM/YYYY" → "YYYY-MM-DD".
|
||||
// Returns "" on parse error.
|
||||
func parseCzechDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, layout := range []string{"02.01.2006", "02/01/2006"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var nonNumericRe = regexp.MustCompile(`[^\d.,]`)
|
||||
|
||||
// parseCzechAmount parses "1 500,00 CZK" / "1.500,00" / "1500.00" → float64.
|
||||
// Returns 0 on error.
|
||||
func parseCzechAmount(s string) float64 {
|
||||
// Remove NBSP, regular spaces, currency letters
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == ' ' || unicode.IsSpace(r) || unicode.IsLetter(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
if strings.Contains(s, ",") {
|
||||
// Czech decimal: 1.500,00 → remove dots (thousand sep), comma → dot
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
} else {
|
||||
// Remove any remaining non-numeric except one dot
|
||||
s = nonNumericRe.ReplaceAllString(s, "")
|
||||
}
|
||||
var f float64
|
||||
_, _ = fmt.Sscanf(s, "%f", &f)
|
||||
return f
|
||||
}
|
||||
Reference in New Issue
Block a user