package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// ---- futsalliga.cz scraper ----
// Secondary data source used when the primary FAČR API is unavailable.
// Scrapes the official 2. Futsal liga - východ site (eSports/eSportsmedia CMS)
// and produces the same Combined JSON shape the frontend expects.
const (
flBase = "https://2fl-v.futsalliga.cz"
flClubLogo = "/img/logo.png"
)
func flSeason() string {
if s := os.Getenv("FL_SEASON"); s != "" {
return s
}
return "2027" // 2026/2027
}
func flTeamID() string {
if s := os.Getenv("FL_TEAM_ID"); s != "" {
return s
}
return "60" // FC Bizoni Uherské Hradiště
}
var (
flH2Re = regexp.MustCompile(`(?s)
]*>.*?
`)
flDateRe = regexp.MustCompile(`(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})`)
flRowRe = regexp.MustCompile(`(?s)]*>\s*\s*([0-9:]+)\s*
`)
flTeamRe = regexp.MustCompile(`(?s)(.*?)
`)
flImgRe = regexp.MustCompile(`(?:data-src|src)="(https?://[^"]+)"`)
flScoreRe = regexp.MustCompile(`(?s)match-results[^>]*>\s*([^<]*?)\s*`)
flScoreNumRe = regexp.MustCompile(`(\d+)\s*:\s*(\d+)`)
flVenueRe = regexp.MustCompile(`(?s)\s*([^<]*?)\s*
`)
flTagRe = regexp.MustCompile(`<[^>]+>`)
flWsRe = regexp.MustCompile(`\s+`)
flTableRe = regexp.MustCompile(`(?s)]*standingsTable[^>]*>(.*?)
`)
flTrRe = regexp.MustCompile(`(?s)(.*?)
`)
flTdRe = regexp.MustCompile(`(?s)]*>(.*?) | `)
)
// wire structs mirroring the FAČR API JSON shape; marshaled then unmarshaled
// into ClubDetail/ClubTable (which use anonymous nested structs)
type flWireMatch struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
HomeID string `json:"home_id"`
HomeLogoURL string `json:"home_logo_url"`
Away string `json:"away"`
AwayID string `json:"away_id"`
AwayLogoURL string `json:"away_logo_url"`
Score string `json:"score"`
Venue string `json:"venue"`
MatchID string `json:"match_id"`
ReportURL string `json:"report_url"`
FacrLink string `json:"facr_link"`
}
type flWireCompetition struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TeamCount string `json:"team_count"`
MatchesLink string `json:"matches_link"`
Matches []flWireMatch `json:"matches"`
}
type flWireDetail struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
URL string `json:"url"`
LogoURL string `json:"logo_url"`
Address string `json:"address"`
Category string `json:"category"`
Competitions []flWireCompetition `json:"competitions"`
}
type flWireRow struct {
Rank string `json:"rank"`
Team string `json:"team"`
TeamID string `json:"team_id"`
TeamLogo string `json:"team_logo_url"`
Played string `json:"played"`
Wins string `json:"wins"`
Draws string `json:"draws"`
Losses string `json:"losses"`
Score string `json:"score"`
Points string `json:"points"`
}
type flWireTableComp struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TeamCount string `json:"team_count"`
MatchesLink string `json:"matches_link"`
Table struct {
Overall []flWireRow `json:"overall"`
} `json:"table"`
}
type flWireTable struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
LogoURL string `json:"logo_url"`
Competitions []flWireTableComp `json:"competitions"`
}
func flClean(s string) string {
return strings.TrimSpace(flWsRe.ReplaceAllString(flTagRe.ReplaceAllString(s, ""), " "))
}
func flIsBizoni(name string) bool {
return strings.Contains(strings.ToLower(name), "bizoni")
}
func flTeamLogo(content string) string {
if m := flImgRe.FindStringSubmatch(content); m != nil {
return m[1]
}
return ""
}
func fetchText(ctx context.Context, client *http.Client, u string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (bizoni-site-scraper)")
req.Header.Set("Accept", "text/html")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: %s", u, resp.Status)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
// flParseMatches parses the team-filtered match list page.
// The page groups matches under date headers ("neděle 4. 10. 2026");
// each row starts with a time anchor to /match/.
func flParseMatches(doc string) []flWireMatch {
var matches []flWireMatch
teamID := flTeamID()
headers := flH2Re.FindAllStringIndex(doc, -1)
for i, loc := range headers {
headText := flTagRe.ReplaceAllString(doc[loc[0]:loc[1]], "")
dm := flDateRe.FindStringSubmatch(headText)
if dm == nil {
continue
}
day, _ := strconv.Atoi(dm[1])
mon, _ := strconv.Atoi(dm[2])
yr, _ := strconv.Atoi(dm[3])
secEnd := len(doc)
if i+1 < len(headers) {
secEnd = headers[i+1][0]
}
section := doc[loc[1]:secEnd]
rows := flRowRe.FindAllStringSubmatchIndex(section, -1)
for j, rloc := range rows {
matchID := section[rloc[2]:rloc[3]]
kickoff := section[rloc[4]:rloc[5]]
segEnd := len(section)
if j+1 < len(rows) {
segEnd = rows[j+1][0]
}
seg := section[rloc[1]:segEnd]
teams := flTeamRe.FindAllStringSubmatch(seg, -1)
if len(teams) < 2 {
continue
}
home := flClean(teams[0][1])
away := flClean(teams[1][1])
homeLogo := flTeamLogo(teams[0][1])
awayLogo := flTeamLogo(teams[1][1])
if flIsBizoni(home) {
homeLogo = flClubLogo
}
if flIsBizoni(away) {
awayLogo = flClubLogo
}
score := ""
if sm := flScoreRe.FindStringSubmatch(seg); sm != nil {
if sc := flScoreNumRe.FindStringSubmatch(sm[1]); sc != nil {
score = sc[1] + ":" + sc[2]
}
}
venue := ""
if vm := flVenueRe.FindStringSubmatch(seg); vm != nil {
venue = flClean(vm[1])
}
m := flWireMatch{
DateTime: fmt.Sprintf("%02d.%02d.%04d %s", day, mon, yr, kickoff),
Home: home,
HomeLogoURL: homeLogo,
Away: away,
AwayLogoURL: awayLogo,
Score: score,
Venue: venue,
MatchID: matchID,
ReportURL: fmt.Sprintf("%s/match/%s", flBase, matchID),
FacrLink: fmt.Sprintf("%s/match/%s", flBase, matchID),
}
if flIsBizoni(home) {
m.HomeID = teamID
}
if flIsBizoni(away) {
m.AwayID = teamID
}
matches = append(matches, m)
}
}
return matches
}
// flParseStandings parses the standings table (rank, team, Z, V, R, P, skóre, body).
func flParseStandings(doc string) []flWireRow {
var rows []flWireRow
tm := flTableRe.FindStringSubmatch(doc)
if tm == nil {
return rows
}
for _, tr := range flTrRe.FindAllStringSubmatch(tm[1], -1) {
cells := flTdRe.FindAllStringSubmatch(tr[1], -1)
if len(cells) < 8 {
continue
}
team := flClean(cells[1][1])
row := flWireRow{
Rank: flClean(cells[0][1]),
Team: team,
Played: flClean(cells[2][1]),
Wins: flClean(cells[3][1]),
Draws: flClean(cells[4][1]),
Losses: flClean(cells[5][1]),
Score: flClean(cells[6][1]),
Points: flClean(cells[7][1]),
}
if flIsBizoni(team) {
row.TeamID = flTeamID()
row.TeamLogo = flClubLogo
} else {
row.TeamLogo = flTeamLogo(cells[1][1])
}
rows = append(rows, row)
}
return rows
}
func flCompName() string {
y, err := strconv.Atoi(flSeason())
if err != nil || y <= 0 {
return "2. Futsal liga - východ"
}
return fmt.Sprintf("2. Futsal liga - východ %d/%d", y-1, y)
}
// scrapeFutsalLiga fetches Bizoni matches + league standings from
// 2fl-v.futsalliga.cz and returns a Combined payload equivalent to what the
// FAČR/flashscore APIs produce.
func scrapeFutsalLiga(ctx context.Context, client *http.Client) (Combined, error) {
season := flSeason()
teamID := flTeamID()
mDoc, err := fetchText(ctx, client, fmt.Sprintf("%s/matches/%s?team=%s", flBase, season, teamID))
if err != nil {
return Combined{}, fmt.Errorf("futsalliga matches: %w", err)
}
sDoc, err := fetchText(ctx, client, fmt.Sprintf("%s/standings/%s", flBase, season))
if err != nil {
return Combined{}, fmt.Errorf("futsalliga standings: %w", err)
}
matches := flParseMatches(mDoc)
if len(matches) == 0 {
return Combined{}, fmt.Errorf("futsalliga: parsed 0 matches")
}
tableRows := flParseStandings(sDoc)
clubName := "FC Bizoni Uherské Hradiště"
matchesLink := fmt.Sprintf("%s/matches/%s?team=%s", flBase, season, teamID)
teamCount := strconv.Itoa(len(tableRows))
wd := flWireDetail{
Name: clubName,
ClubID: teamID,
ClubType: "futsal",
URL: fmt.Sprintf("%s/team/%s", flBase, teamID),
LogoURL: flClubLogo,
Category: "Futsal",
Competitions: []flWireCompetition{{
ID: season,
Code: "2FL-V",
Name: flCompName(),
TeamCount: teamCount,
MatchesLink: matchesLink,
Matches: matches,
}},
}
wt := flWireTable{
Name: clubName,
ClubID: teamID,
ClubType: "futsal",
LogoURL: flClubLogo,
}
wt.Competitions = append(wt.Competitions, flWireTableComp{
ID: season,
Code: "2FL-V",
Name: flCompName(),
TeamCount: teamCount,
MatchesLink: matchesLink,
})
wt.Competitions[0].Table.Overall = tableRows
var detail ClubDetail
var table ClubTable
db, err := json.Marshal(wd)
if err != nil {
return Combined{}, fmt.Errorf("futsalliga marshal detail: %w", err)
}
if err := json.Unmarshal(db, &detail); err != nil {
return Combined{}, fmt.Errorf("futsalliga unmarshal detail: %w", err)
}
tb, err := json.Marshal(wt)
if err != nil {
return Combined{}, fmt.Errorf("futsalliga marshal table: %w", err)
}
if err := json.Unmarshal(tb, &table); err != nil {
return Combined{}, fmt.Errorf("futsalliga unmarshal table: %w", err)
}
return Combined{
FetchedAt: time.Now(),
ClubDetail: detail,
ClubTable: table,
}, nil
}