mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-10-29 16:14:44 +00:00
Compare commits
3
Commits
85666d290c
...
9775295d38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9775295d38 | ||
|
|
fc4bb11c41 | ||
|
|
225da9253c |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN go build -o server ./main.go
|
||||
RUN go build -o server .
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module bizoni-backend
|
||||
|
||||
go 1.22
|
||||
+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