feat(backend): add FutsalLiga scraper as secondary data source

Implement web scraping fallback for the official FutsalLiga website as a secondary data source between the primary API and the FlashScore fallback. This provides more reliable data retrieval with correct competition information when the primary API fails.

Add scrapeFutsalLiga function to extract club details, matches, and standings directly from the league website. Update refresh logic to attempt FutsalLiga scraping before falling back to FlashScore API
This commit is contained in:
Your Name committed 2026-10-04 21:24:28 +02:00
1 parent 85666d290c
commit 225da9253c
6 files changed
+913 -1330

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
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)<h2 class="delta[^"]*"[^>]*>.*?</h2>`)
flDateRe = regexp.MustCompile(`(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})`)
flRowRe = regexp.MustCompile(`(?s)<a href="/match/(\d+)"[^>]*>\s*<div class="delta mb-0 pl-md-3 pl-lg-8">\s*([0-9:]+)\s*</div>`)
flTeamRe = regexp.MustCompile(`(?s)<div class="match-team">(.*?)</div>`)
flImgRe = regexp.MustCompile(`(?:data-src|src)="(https?://[^"]+)"`)
flScoreRe = regexp.MustCompile(`(?s)match-results[^>]*>\s*([^<]*?)\s*</div>`)
flScoreNumRe = regexp.MustCompile(`(\d+)\s*:\s*(\d+)`)
flVenueRe = regexp.MustCompile(`(?s)<div class="match-place">\s*([^<]*?)\s*</div>`)
flTagRe = regexp.MustCompile(`<[^>]+>`)
flWsRe = regexp.MustCompile(`\s+`)
flTableRe = regexp.MustCompile(`(?s)<table[^>]*standingsTable[^>]*>(.*?)</table>`)
flTrRe = regexp.MustCompile(`(?s)<tr>(.*?)</tr>`)
flTdRe = regexp.MustCompile(`(?s)<td[^>]*>(.*?)</td>`)
)
// 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 <h2> date headers ("neděle 4. 10. 2026");
// each row starts with a time anchor to /match/<id>.
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
}
+14 -1
View File
@@ -1660,7 +1660,20 @@ func refresh(ctx context.Context) error {
// Try primary API first // Try primary API first
if err := getJSON(ctx, client, urlDetail, &detail); err != nil { if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
log.Printf("primary api detail failed (%v), trying fallback", err) log.Printf("primary api detail failed (%v), trying futsalliga scrape", err)
// Second source: scrape the official league site (current data, correct competition)
if comb, serr := scrapeFutsalLiga(ctx, client); serr == nil {
c.mu.Lock()
c.data = comb
c.mu.Unlock()
if werr := writeDiskJSON(c.data); werr != nil {
log.Printf("warn: write disk json: %v", werr)
}
log.Printf("refreshed data: comps=%d source=%s", len(comb.ClubDetail.Competitions), "futsalliga")
return nil
} else {
log.Printf("futsalliga scrape failed (%v), trying flashscore fallback", serr)
}
urlDetail = fmt.Sprintf("%s/club/%s/%s?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug) urlDetail = fmt.Sprintf("%s/club/%s/%s?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug) urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
if err := getJSON(ctx, client, urlDetail, &detail); err != nil { if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
+269 -1247
View File
File diff suppressed because it is too large. Load diff
+41 -41
View File
@@ -1,56 +1,56 @@
{ {
"fetched_at": "2025-09-27T15:19:20.398328338Z", "fetched_at": "2026-10-04T21:20:12.1582953+02:00",
"channel": "@FCBizoniUH", "channel": "@FCBizoniUH",
"items": [ "items": [
{ {
"video_id": "h_-TS6oVvKA", "video_id": "9pIX0nLZj7E",
"title": "Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25", "title": "Bizoni UH-Žabin.Vlci Brno 6:3/4:0/-semifinále SFČR-UH 11.6.26",
"length": "", "length": "5:27",
"thumbnail_url": "https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/9pIX0nLZj7E/maxresdefault.jpg",
"views_text": "41 views", "views_text": "171",
"views": 41, "views": 171,
"published_text": "9 hours ago", "published_text": "3mo ago",
"published_date": "2025-09-27" "published_date": ""
}, },
{ {
"video_id": "ozH8xE7V458", "video_id": "qu5UuNFfhdY",
"title": "Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH", "title": "SK Slavia Praha B - FC Bizoni Uherské Hradiště",
"length": "", "length": "4:18",
"thumbnail_url": "https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/qu5UuNFfhdY/maxresdefault.jpg",
"views_text": "276 views", "views_text": "375",
"views": 276, "views": 375,
"published_text": "10 days ago", "published_text": "6mo ago",
"published_date": "2025-09-17" "published_date": ""
}, },
{ {
"video_id": "nrj6_1IoYoo", "video_id": "XHgpogBABLM",
"title": "Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti", "title": "Bizoni UH-Slavia\"B\" 2:3/2:0/-futsal SUPERPOHÁR v UH 28.2.26",
"length": "", "length": "4:02",
"thumbnail_url": "https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/XHgpogBABLM/maxresdefault.jpg",
"views_text": "214 views", "views_text": "320",
"views": 214, "views": 320,
"published_text": "2 weeks ago", "published_text": "7mo ago",
"published_date": "2025-09-13" "published_date": ""
}, },
{ {
"video_id": "pSGwSJvdd14", "video_id": "gl3azpVKZ4k",
"title": "Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH", "title": "Bizoni UH-Baník Ostrava 17:8/10:4/-22.kolo futsal ligy východ-20.2.26 v UH",
"length": "", "length": "10:55",
"thumbnail_url": "https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/gl3azpVKZ4k/maxresdefault.jpg",
"views_text": "98 views", "views_text": "381",
"views": 98, "views": 381,
"published_text": "3 months ago", "published_text": "7mo ago",
"published_date": "2025-06-27" "published_date": ""
}, },
{ {
"video_id": "AbGKYfOmtlw", "video_id": "EC5WCbnhDFA",
"title": "FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS", "title": "Bizoni UH-Tango Hodonín 3:2/2:2/-20.kolo 2.ligy východ-6.2.26 v UH",
"length": "", "length": "7:28",
"thumbnail_url": "https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/EC5WCbnhDFA/maxresdefault.jpg",
"views_text": "218 views", "views_text": "476",
"views": 218, "views": 476,
"published_text": "6 months ago", "published_text": "7mo ago",
"published_date": "2025-03-27" "published_date": ""
} }
] ]
} }
+41 -41
View File
@@ -1,56 +1,56 @@
{ {
"fetched_at": "2025-09-27T15:19:20.398328338Z", "fetched_at": "2026-10-04T21:20:12.1582953+02:00",
"channel": "@FCBizoniUH", "channel": "@FCBizoniUH",
"items": [ "items": [
{ {
"video_id": "h_-TS6oVvKA", "video_id": "9pIX0nLZj7E",
"title": "Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25", "title": "Bizoni UH-Žabin.Vlci Brno 6:3/4:0/-semifinále SFČR-UH 11.6.26",
"length": "", "length": "5:27",
"thumbnail_url": "https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/9pIX0nLZj7E/maxresdefault.jpg",
"views_text": "41 views", "views_text": "171",
"views": 41, "views": 171,
"published_text": "9 hours ago", "published_text": "3mo ago",
"published_date": "2025-09-27" "published_date": ""
}, },
{ {
"video_id": "ozH8xE7V458", "video_id": "qu5UuNFfhdY",
"title": "Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH", "title": "SK Slavia Praha B - FC Bizoni Uherské Hradiště",
"length": "", "length": "4:18",
"thumbnail_url": "https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/qu5UuNFfhdY/maxresdefault.jpg",
"views_text": "276 views", "views_text": "375",
"views": 276, "views": 375,
"published_text": "10 days ago", "published_text": "6mo ago",
"published_date": "2025-09-17" "published_date": ""
}, },
{ {
"video_id": "nrj6_1IoYoo", "video_id": "XHgpogBABLM",
"title": "Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti", "title": "Bizoni UH-Slavia\"B\" 2:3/2:0/-futsal SUPERPOHÁR v UH 28.2.26",
"length": "", "length": "4:02",
"thumbnail_url": "https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/XHgpogBABLM/maxresdefault.jpg",
"views_text": "214 views", "views_text": "320",
"views": 214, "views": 320,
"published_text": "2 weeks ago", "published_text": "7mo ago",
"published_date": "2025-09-13" "published_date": ""
}, },
{ {
"video_id": "pSGwSJvdd14", "video_id": "gl3azpVKZ4k",
"title": "Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH", "title": "Bizoni UH-Baník Ostrava 17:8/10:4/-22.kolo futsal ligy východ-20.2.26 v UH",
"length": "", "length": "10:55",
"thumbnail_url": "https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/gl3azpVKZ4k/maxresdefault.jpg",
"views_text": "98 views", "views_text": "381",
"views": 98, "views": 381,
"published_text": "3 months ago", "published_text": "7mo ago",
"published_date": "2025-06-27" "published_date": ""
}, },
{ {
"video_id": "AbGKYfOmtlw", "video_id": "EC5WCbnhDFA",
"title": "FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS", "title": "Bizoni UH-Tango Hodonín 3:2/2:2/-20.kolo 2.ligy východ-6.2.26 v UH",
"length": "", "length": "7:28",
"thumbnail_url": "https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg", "thumbnail_url": "https://img.youtube.com/vi/EC5WCbnhDFA/maxresdefault.jpg",
"views_text": "218 views", "views_text": "476",
"views": 218, "views": 476,
"published_text": "6 months ago", "published_text": "7mo ago",
"published_date": "2025-03-27" "published_date": ""
} }
] ]
} }
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Scrape FC Bizoni UH data from 2fl-v.futsalliga.cz into the club.json shape
the site frontend expects (same as facr.tdvorak.dev API output).
Usage: python tools/scrape_futsalliga.py [--out data/club.json]
"""
import json
import re
import sys
import urllib.request
from datetime import datetime, timezone
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
BASE = "https://2fl-v.futsalliga.cz"
SEASON = "2027" # 2026/2027
TEAM_ID = "60" # FC Bizoni Uherske Hradiste
CLUB_NAME = "FC Bizoni Uherské Hradiště"
COMP_NAME = "2. Futsal liga - východ 2026/2027"
CLUB_LOGO = "/img/logo.png"
UA = {"User-Agent": "Mozilla/5.0 (bizoni-site-scraper)"}
def fetch(url):
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
def strip_tags(s):
return re.sub(r"<[^>]+>", "", s)
def clean(s):
return re.sub(r"\s+", " ", strip_tags(s)).strip()
def team_logo(content):
m = re.search(r'(?:data-src|src)="(https?://[^"]+)"', content)
return m.group(1) if m else ""
def is_bizoni(name):
return "bizoni" in name.lower()
def parse_matches(html_text):
# Each date group: <h2 class="delta ...">...weekday D. M. YYYY</h2> followed by match rows
h2re = re.compile(r'<h2 class="delta[^"]*"[^>]*>.*?</h2>', re.S)
headers = list(h2re.finditer(html_text))
matches = []
for i, h in enumerate(headers):
date_m = re.search(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})", strip_tags(h.group(0)))
if not date_m:
continue
d, mo, y = int(date_m.group(1)), int(date_m.group(2)), int(date_m.group(3))
section = html_text[h.end():headers[i + 1].start() if i + 1 < len(headers) else len(html_text)]
# each match row starts with the time anchor
rowre = re.compile(
r'<a href="/match/(\d+)"[^>]*>\s*<div class="delta mb-0 pl-md-3 pl-lg-8">\s*([0-9:]+)\s*</div>',
re.S,
)
rows = list(rowre.finditer(section))
for j, row in enumerate(rows):
seg = section[row.end():rows[j + 1].start() if j + 1 < len(rows) else len(section)]
match_id, time_str = row.group(1), row.group(2)
teams = re.findall(r'<div class="match-team">(.*?)</div>', seg, re.S)
if len(teams) < 2:
continue
home, away = clean(teams[0]), clean(teams[1])
home_logo, away_logo = team_logo(teams[0]), team_logo(teams[1])
score = ""
sm = re.search(r'match-results[^>]*>\s*([^<]*?)\s*</div>', seg, re.S)
if sm:
sc = re.search(r"(\d+)\s*:\s*(\d+)", sm.group(1))
if sc:
score = f"{sc.group(1)}:{sc.group(2)}"
vm = re.search(r'<div class="match-place">\s*([^<]*?)\s*</div>', seg, re.S)
venue = vm.group(1).strip() if vm else ""
if is_bizoni(home):
home_logo = CLUB_LOGO
if is_bizoni(away):
away_logo = CLUB_LOGO
matches.append({
"date_time": f"{d:02d}.{mo:02d}.{y:04d} {time_str}",
"home": home,
"home_id": TEAM_ID if is_bizoni(home) else "",
"home_logo_url": home_logo,
"away": away,
"away_id": TEAM_ID if is_bizoni(away) else "",
"away_logo_url": away_logo,
"score": score,
"venue": venue,
"match_id": match_id,
"report_url": f"{BASE}/match/{match_id}",
"facr_link": f"{BASE}/match/{match_id}",
})
return matches
def parse_standings(html_text):
tm = re.search(r'<table[^>]*standingsTable[^>]*>(.*?)</table>', html_text, re.S)
if not tm:
return []
rows = []
for tr in re.findall(r"<tr>(.*?)</tr>", tm.group(1), re.S):
cells = re.findall(r"<td[^>]*>(.*?)</td>", tr, re.S)
if len(cells) < 8:
continue
team = clean(cells[1])
rows.append({
"rank": clean(cells[0]),
"team": team,
"team_id": TEAM_ID if is_bizoni(team) else "",
"team_logo_url": CLUB_LOGO if is_bizoni(team) else team_logo(cells[1]),
"played": clean(cells[2]),
"wins": clean(cells[3]),
"draws": clean(cells[4]),
"losses": clean(cells[5]),
"score": clean(cells[6]),
"points": clean(cells[7]),
})
return rows
def main():
out = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else None
matches_html = fetch(f"{BASE}/matches/{SEASON}?team={TEAM_ID}")
standings_html = fetch(f"{BASE}/standings/{SEASON}")
matches = parse_matches(matches_html)
table = parse_standings(standings_html)
comp = {
"id": SEASON,
"code": "2FL-V",
"name": COMP_NAME,
"team_count": str(len(table)),
"matches_link": f"{BASE}/matches/{SEASON}?team={TEAM_ID}",
"matches": matches,
}
detail = {
"name": CLUB_NAME,
"club_id": TEAM_ID,
"club_type": "futsal",
"url": f"{BASE}/team/{TEAM_ID}",
"logo_url": CLUB_LOGO,
"address": "",
"category": "Futsal",
"competitions": [comp],
}
table_obj = {
"name": CLUB_NAME,
"club_id": TEAM_ID,
"club_type": "futsal",
"logo_url": CLUB_LOGO,
"competitions": [{
"id": SEASON,
"code": "2FL-V",
"name": COMP_NAME,
"team_count": str(len(table)),
"matches_link": comp["matches_link"],
"table": {"overall": table},
}],
}
combined = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"club_detail": detail,
"club_table": table_obj,
}
print(f"matches={len(matches)} standings_rows={len(table)}")
for m in matches[:4]:
print(f" {m['date_time']} {m['home']} {m['score'] or '-:-'} {m['away']} @ {m['venue']}")
print(" ...")
for m in matches[-2:]:
print(f" {m['date_time']} {m['home']} {m['score'] or '-:-'} {m['away']} @ {m['venue']}")
if out:
with open(out, "w", encoding="utf-8") as f:
json.dump(combined, f, ensure_ascii=False, indent=2)
print(f"wrote {out}")
if __name__ == "__main__":
main()