mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-10-28 07:41:18 +00:00
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:
1 parent
85666d290c
commit
225da9253c
6 files changed
+914
-1331
No files matched your search
@@ -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
@@ -1660,7 +1660,20 @@ func refresh(ctx context.Context) error {
|
||||
|
||||
// Try primary API first
|
||||
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)
|
||||
urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
|
||||
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
||||
|
||||
+270
-1248
File diff suppressed because it is too large.
Load diff
+41
-41
@@ -1,56 +1,56 @@
|
||||
{
|
||||
"fetched_at": "2025-09-27T15:19:20.398328338Z",
|
||||
"fetched_at": "2026-10-04T21:20:12.1582953+02:00",
|
||||
"channel": "@FCBizoniUH",
|
||||
"items": [
|
||||
{
|
||||
"video_id": "h_-TS6oVvKA",
|
||||
"title": "Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg",
|
||||
"views_text": "41 views",
|
||||
"views": 41,
|
||||
"published_text": "9 hours ago",
|
||||
"published_date": "2025-09-27"
|
||||
"video_id": "9pIX0nLZj7E",
|
||||
"title": "Bizoni UH-Žabin.Vlci Brno 6:3/4:0/-semifinále SFČR-UH 11.6.26",
|
||||
"length": "5:27",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/9pIX0nLZj7E/maxresdefault.jpg",
|
||||
"views_text": "171",
|
||||
"views": 171,
|
||||
"published_text": "3mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "ozH8xE7V458",
|
||||
"title": "Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg",
|
||||
"views_text": "276 views",
|
||||
"views": 276,
|
||||
"published_text": "10 days ago",
|
||||
"published_date": "2025-09-17"
|
||||
"video_id": "qu5UuNFfhdY",
|
||||
"title": "SK Slavia Praha B - FC Bizoni Uherské Hradiště",
|
||||
"length": "4:18",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/qu5UuNFfhdY/maxresdefault.jpg",
|
||||
"views_text": "375",
|
||||
"views": 375,
|
||||
"published_text": "6mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "nrj6_1IoYoo",
|
||||
"title": "Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg",
|
||||
"views_text": "214 views",
|
||||
"views": 214,
|
||||
"published_text": "2 weeks ago",
|
||||
"published_date": "2025-09-13"
|
||||
"video_id": "XHgpogBABLM",
|
||||
"title": "Bizoni UH-Slavia\"B\" 2:3/2:0/-futsal SUPERPOHÁR v UH 28.2.26",
|
||||
"length": "4:02",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/XHgpogBABLM/maxresdefault.jpg",
|
||||
"views_text": "320",
|
||||
"views": 320,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "pSGwSJvdd14",
|
||||
"title": "Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg",
|
||||
"views_text": "98 views",
|
||||
"views": 98,
|
||||
"published_text": "3 months ago",
|
||||
"published_date": "2025-06-27"
|
||||
"video_id": "gl3azpVKZ4k",
|
||||
"title": "Bizoni UH-Baník Ostrava 17:8/10:4/-22.kolo futsal ligy východ-20.2.26 v UH",
|
||||
"length": "10:55",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/gl3azpVKZ4k/maxresdefault.jpg",
|
||||
"views_text": "381",
|
||||
"views": 381,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "AbGKYfOmtlw",
|
||||
"title": "FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg",
|
||||
"views_text": "218 views",
|
||||
"views": 218,
|
||||
"published_text": "6 months ago",
|
||||
"published_date": "2025-03-27"
|
||||
"video_id": "EC5WCbnhDFA",
|
||||
"title": "Bizoni UH-Tango Hodonín 3:2/2:2/-20.kolo 2.ligy východ-6.2.26 v UH",
|
||||
"length": "7:28",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/EC5WCbnhDFA/maxresdefault.jpg",
|
||||
"views_text": "476",
|
||||
"views": 476,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
-41
@@ -1,56 +1,56 @@
|
||||
{
|
||||
"fetched_at": "2025-09-27T15:19:20.398328338Z",
|
||||
"fetched_at": "2026-10-04T21:20:12.1582953+02:00",
|
||||
"channel": "@FCBizoniUH",
|
||||
"items": [
|
||||
{
|
||||
"video_id": "h_-TS6oVvKA",
|
||||
"title": "Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg",
|
||||
"views_text": "41 views",
|
||||
"views": 41,
|
||||
"published_text": "9 hours ago",
|
||||
"published_date": "2025-09-27"
|
||||
"video_id": "9pIX0nLZj7E",
|
||||
"title": "Bizoni UH-Žabin.Vlci Brno 6:3/4:0/-semifinále SFČR-UH 11.6.26",
|
||||
"length": "5:27",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/9pIX0nLZj7E/maxresdefault.jpg",
|
||||
"views_text": "171",
|
||||
"views": 171,
|
||||
"published_text": "3mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "ozH8xE7V458",
|
||||
"title": "Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg",
|
||||
"views_text": "276 views",
|
||||
"views": 276,
|
||||
"published_text": "10 days ago",
|
||||
"published_date": "2025-09-17"
|
||||
"video_id": "qu5UuNFfhdY",
|
||||
"title": "SK Slavia Praha B - FC Bizoni Uherské Hradiště",
|
||||
"length": "4:18",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/qu5UuNFfhdY/maxresdefault.jpg",
|
||||
"views_text": "375",
|
||||
"views": 375,
|
||||
"published_text": "6mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "nrj6_1IoYoo",
|
||||
"title": "Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg",
|
||||
"views_text": "214 views",
|
||||
"views": 214,
|
||||
"published_text": "2 weeks ago",
|
||||
"published_date": "2025-09-13"
|
||||
"video_id": "XHgpogBABLM",
|
||||
"title": "Bizoni UH-Slavia\"B\" 2:3/2:0/-futsal SUPERPOHÁR v UH 28.2.26",
|
||||
"length": "4:02",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/XHgpogBABLM/maxresdefault.jpg",
|
||||
"views_text": "320",
|
||||
"views": 320,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "pSGwSJvdd14",
|
||||
"title": "Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg",
|
||||
"views_text": "98 views",
|
||||
"views": 98,
|
||||
"published_text": "3 months ago",
|
||||
"published_date": "2025-06-27"
|
||||
"video_id": "gl3azpVKZ4k",
|
||||
"title": "Bizoni UH-Baník Ostrava 17:8/10:4/-22.kolo futsal ligy východ-20.2.26 v UH",
|
||||
"length": "10:55",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/gl3azpVKZ4k/maxresdefault.jpg",
|
||||
"views_text": "381",
|
||||
"views": 381,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
},
|
||||
{
|
||||
"video_id": "AbGKYfOmtlw",
|
||||
"title": "FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS",
|
||||
"length": "",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg",
|
||||
"views_text": "218 views",
|
||||
"views": 218,
|
||||
"published_text": "6 months ago",
|
||||
"published_date": "2025-03-27"
|
||||
"video_id": "EC5WCbnhDFA",
|
||||
"title": "Bizoni UH-Tango Hodonín 3:2/2:2/-20.kolo 2.ligy východ-6.2.26 v UH",
|
||||
"length": "7:28",
|
||||
"thumbnail_url": "https://img.youtube.com/vi/EC5WCbnhDFA/maxresdefault.jpg",
|
||||
"views_text": "476",
|
||||
"views": 476,
|
||||
"published_text": "7mo ago",
|
||||
"published_date": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user