mirror of
https://github.com/Dvorinka/bizoni.git
synced 2026-06-03 18:22:57 +00:00
340 lines
8.9 KiB
Go
340 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
clubID = "441d3783-06aa-436a-b438-359300ee0371"
|
|
clubType = "futsal"
|
|
baseURL = "https://facr.tdvorak.dev"
|
|
)
|
|
|
|
type ClubDetail 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 []struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
TeamCount string `json:"team_count"`
|
|
MatchesLink string `json:"matches_link"`
|
|
Matches []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"`
|
|
} `json:"matches"`
|
|
} `json:"competitions"`
|
|
}
|
|
|
|
func dataPath() string {
|
|
if p := os.Getenv("DATA_PATH"); p != "" {
|
|
return p
|
|
}
|
|
return "/app/data/club.json"
|
|
}
|
|
|
|
func staticPath() string {
|
|
if p := os.Getenv("STATIC_PATH"); p != "" {
|
|
return p
|
|
}
|
|
// Default mount point for the site in the container
|
|
return "/app/site"
|
|
}
|
|
|
|
type ClubTable struct {
|
|
Name string `json:"name"`
|
|
ClubID string `json:"club_id"`
|
|
ClubType string `json:"club_type"`
|
|
LogoURL string `json:"logo_url"`
|
|
Competitions []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 []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"`
|
|
} `json:"overall"`
|
|
} `json:"table"`
|
|
} `json:"competitions"`
|
|
}
|
|
|
|
type Combined struct {
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
ClubDetail ClubDetail `json:"club_detail"`
|
|
ClubTable ClubTable `json:"club_table"`
|
|
}
|
|
|
|
type cache struct {
|
|
mu sync.RWMutex
|
|
data Combined
|
|
}
|
|
|
|
var c cache
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
|
|
// initial fetch
|
|
if err := refresh(ctx); err != nil {
|
|
log.Printf("initial refresh error: %v", err)
|
|
}
|
|
|
|
// scheduler
|
|
go scheduler(ctx)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
mux.HandleFunc("/data/club.json", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
_ = json.NewEncoder(w).Encode(c.data)
|
|
case http.MethodDelete:
|
|
// delete on-disk file and clear in-memory cache
|
|
path := dataPath()
|
|
_ = os.Remove(path)
|
|
c.mu.Lock()
|
|
c.data = Combined{}
|
|
c.mu.Unlock()
|
|
// trigger immediate refresh so next GET has fresh data
|
|
if err := refresh(r.Context()); err != nil {
|
|
log.Printf("manual refresh after delete failed: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
mux.HandleFunc("/data/club.js", func(w http.ResponseWriter, r *http.Request) {
|
|
okCORS(w)
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
c.mu.RLock()
|
|
payload, _ := json.Marshal(c.data)
|
|
c.mu.RUnlock()
|
|
w.Write([]byte("window.FACR_DATA="))
|
|
w.Write(payload)
|
|
w.Write([]byte(";"))
|
|
})
|
|
|
|
// Static file server for the frontend
|
|
fs := http.FileServer(http.Dir(staticPath()))
|
|
// Serve common asset prefixes explicitly
|
|
mux.Handle("/img/", fs)
|
|
mux.Handle("/css/", fs)
|
|
mux.Handle("/js/", fs)
|
|
mux.Handle("/blog/", fs)
|
|
mux.Handle("/zapasy/", fs)
|
|
// Fallback: serve index.html and other root-level pages
|
|
mux.Handle("/", fs)
|
|
|
|
srv := &http.Server{
|
|
Addr: ":80",
|
|
Handler: mux,
|
|
}
|
|
go func() {
|
|
log.Println("server listening on :80")
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
ctxShut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctxShut)
|
|
}
|
|
|
|
func okCORS(w http.ResponseWriter) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
|
|
func scheduler(ctx context.Context) {
|
|
// default 30m; during match window (±2h around any match today), 2m
|
|
for {
|
|
var d time.Duration = 30 * time.Minute
|
|
if withinMatchWindow() {
|
|
d = 2 * time.Minute
|
|
}
|
|
select {
|
|
case <-time.After(d):
|
|
if err := refresh(ctx); err != nil {
|
|
log.Printf("refresh error: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func withinMatchWindow() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
loc, err := time.LoadLocation("Europe/Prague")
|
|
if err != nil {
|
|
// Fallback to local/UTC if tzdata is missing to avoid panic
|
|
loc = time.Local
|
|
}
|
|
now := time.Now().In(loc)
|
|
for _, comp := range c.data.ClubDetail.Competitions {
|
|
for _, m := range comp.Matches {
|
|
// date format in API example: "12.08.2023 18:00" or "12.08.2023 18:00"
|
|
t, err := time.ParseInLocation("02.01.2006 15:04", m.DateTime, loc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if absDuration(now.Sub(t)) <= 2*time.Hour {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func absDuration(d time.Duration) time.Duration {
|
|
if d < 0 {
|
|
return -d
|
|
}
|
|
return d
|
|
}
|
|
|
|
func refresh(ctx context.Context) error {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
urlDetail := fmt.Sprintf("%s/club/%s/%s", baseURL, clubType, clubID)
|
|
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
|
|
|
|
var detail ClubDetail
|
|
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
|
|
return fmt.Errorf("detail: %w", err)
|
|
}
|
|
var table ClubTable
|
|
if err := getJSON(ctx, client, urlTable, &table); err != nil {
|
|
return fmt.Errorf("table: %w", err)
|
|
}
|
|
|
|
// Override or inject facr_link based on match_id
|
|
for i := range detail.Competitions {
|
|
for j := range detail.Competitions[i].Matches {
|
|
mid := detail.Competitions[i].Matches[j].MatchID
|
|
if mid != "" {
|
|
detail.Competitions[i].Matches[j].FacrLink = fmt.Sprintf("https://www.fotbal.cz/futsal/zapasy/futsal/%s", mid)
|
|
}
|
|
// Override logo URLs for our club in match details
|
|
if detail.Competitions[i].Matches[j].HomeID == clubID {
|
|
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
|
|
}
|
|
if detail.Competitions[i].Matches[j].AwayID == clubID {
|
|
detail.Competitions[i].Matches[j].AwayLogoURL = "/img/logo.png"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Override logo URLs for our club in the table standings
|
|
for i := range table.Competitions {
|
|
for j := range table.Competitions[i].Table.Overall {
|
|
if table.Competitions[i].Table.Overall[j].TeamID == clubID {
|
|
table.Competitions[i].Table.Overall[j].TeamLogo = "/img/logo.png"
|
|
}
|
|
}
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.data = Combined{
|
|
FetchedAt: time.Now(),
|
|
ClubDetail: detail,
|
|
ClubTable: table,
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// persist to disk for control/deletion
|
|
if err := writeDiskJSON(c.data); err != nil {
|
|
log.Printf("warn: write disk json: %v", err)
|
|
}
|
|
log.Printf("refreshed data: comps=%d", len(detail.Competitions))
|
|
return nil
|
|
}
|
|
|
|
func getJSON(ctx context.Context, client *http.Client, url string, out any) error {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("status %d: %s", resp.StatusCode, bytes.TrimSpace(b))
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
func writeDiskJSON(d Combined) error {
|
|
path := dataPath()
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
b, err := json.MarshalIndent(d, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
|
return fmt.Errorf("write tmp: %w", err)
|
|
}
|
|
// On Windows, Rename over existing file may fail; remove target first.
|
|
_ = os.Remove(path)
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|