Files
MyClub/internal/controllers/facr_controller.go
T
Tomas Dvorak 8762bde4bf dev day #89
2025-11-11 10:29:30 +01:00

579 lines
16 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"fotbal-club/internal/models"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// In-memory cache for logo lookup
var logoCache = map[string]string{}
// club data cache (JSON bytes) with TTL and disk persistence
type cachedItem struct {
Data []byte `json:"data"`
StoredAt time.Time `json:"stored_at"`
}
var (
clubCacheMu sync.RWMutex
clubCache = map[string]cachedItem{}
cacheTTL = 30 * time.Minute
)
func cacheDir() string {
return filepath.Join("cache", "facr")
}
func cachePath(key string) string {
return filepath.Join(cacheDir(), key+".json")
}
func getCachedJSON(key string) ([]byte, bool) {
// memory first
clubCacheMu.RLock()
item, ok := clubCache[key]
clubCacheMu.RUnlock()
if ok && time.Since(item.StoredAt) < cacheTTL {
return item.Data, true
}
// disk fallback
b, err := os.ReadFile(cachePath(key))
if err == nil {
var disk cachedItem
if json.Unmarshal(b, &disk) == nil {
if time.Since(disk.StoredAt) < cacheTTL {
// warm memory
clubCacheMu.Lock()
clubCache[key] = disk
clubCacheMu.Unlock()
return disk.Data, true
}
}
}
return nil, false
}
func setCachedJSON(key string, data []byte) {
item := cachedItem{Data: data, StoredAt: time.Now()}
clubCacheMu.Lock()
clubCache[key] = item
clubCacheMu.Unlock()
// persist to disk (best-effort)
_ = os.MkdirAll(cacheDir(), 0o755)
if b, err := json.Marshal(item); err == nil {
_ = os.WriteFile(cachePath(key), b, 0o644)
}
}
// ----- Types (mirroring facr-scraper) -----
type Competition struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TeamCount string `json:"team_count"`
MatchesLink string `json:"matches_link"`
Matches []Match `json:"matches,omitempty"`
Table *CompetitionTable `json:"table,omitempty"`
}
type CompetitionTable struct {
Overall []TableRow `json:"overall"`
}
type ClubInfo struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
ClubInternalID string `json:"club_internal_id,omitempty"`
URL string `json:"url,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
Address string `json:"address,omitempty"`
Category string `json:"category,omitempty"`
Competitions []Competition `json:"competitions"`
}
type SearchResult struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
URL string `json:"url"`
LogoURL string `json:"logo_url"`
Category string `json:"category,omitempty"`
Address string `json:"address,omitempty"`
}
type Match struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
HomeID string `json:"home_id,omitempty"`
HomeLogoURL string `json:"home_logo_url,omitempty"`
Away string `json:"away"`
AwayID string `json:"away_id,omitempty"`
AwayLogoURL string `json:"away_logo_url,omitempty"`
Score string `json:"score"`
Venue string `json:"venue"`
Note string `json:"note,omitempty"`
MatchID string `json:"match_id"`
ReportURL string `json:"report_url,omitempty"`
DelegationURL string `json:"delegation_url,omitempty"`
}
type TableRow struct {
Rank string `json:"rank"`
Team string `json:"team"`
TeamID string `json:"team_id,omitempty"`
TeamLogoURL string `json:"team_logo_url,omitempty"`
Played string `json:"played"`
Wins string `json:"wins"`
Draws string `json:"draws"`
Losses string `json:"losses"`
Score string `json:"score"`
Points string `json:"points"`
}
// ----- Helpers -----
func containsFold(s, substr string) bool {
s = strings.ToLower(strings.TrimSpace(s))
substr = strings.ToLower(strings.TrimSpace(substr))
if substr == "" {
return false
}
return strings.Contains(s, substr)
}
func extractUUIDFromHref(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
re := regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
if m := re.FindString(href); m != "" {
return m
}
parts := strings.Split(href, "/")
if len(parts) > 0 {
cand := parts[len(parts)-1]
if re.MatchString(cand) {
return cand
}
}
return ""
}
func getLogoBySearch(name string) string {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return ""
}
if v, ok := logoCache[key]; ok {
return v
}
// Query local API routed through this same server
apiURL := fmt.Sprintf("http://localhost:8080/api/v1/facr/club/search?q=%s", neturl.QueryEscape(name))
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return ""
}
var payload struct {
Results []struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return ""
}
best := ""
for _, r := range payload.Results {
if strings.EqualFold(strings.TrimSpace(r.Name), strings.TrimSpace(name)) {
best = r.LogoURL
break
}
}
if best == "" {
for _, r := range payload.Results {
if strings.Contains(strings.ToLower(r.Name), key) || strings.Contains(key, strings.ToLower(r.Name)) {
best = r.LogoURL
break
}
}
}
if best == "" && len(payload.Results) > 0 {
best = payload.Results[0].LogoURL
}
if best != "" {
logoCache[key] = best
return best
}
// Fallback: directly scrape fotbal.cz search (same logic as SearchClubs)
vals := neturl.Values{}
vals.Set("q", name)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
resp2, err := client.Do(req)
if err != nil {
return ""
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp2.Body)
return ""
}
doc, err := goquery.NewDocumentFromReader(resp2.Body)
if err != nil {
return ""
}
// choose first exact match, else first contains, else empty
exact := ""
partial := ""
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
a := li.Find("a.Link--inverted").First()
n := strings.TrimSpace(a.Find("span.H7").First().Text())
if n == "" {
n = strings.TrimSpace(a.Text())
}
img := a.Find("img").First()
src, _ := img.Attr("src")
if src == "" {
return
}
if exact == "" && strings.EqualFold(n, name) {
exact = src
}
if partial == "" && (strings.Contains(strings.ToLower(n), key) || strings.Contains(key, strings.ToLower(n))) {
partial = src
}
})
best = exact
if best == "" {
best = partial
}
if best != "" {
logoCache[key] = best
}
return best
}
func getLogo(teamName, teamID string) string {
placeholder := "/dist/img/logo-club-empty.svg"
name := strings.ToLower(strings.TrimSpace(teamName))
if name == "" || strings.Contains(name, "volno") || strings.Contains(name, "volný los") || strings.Contains(name, "volny los") || strings.Contains(name, "bye") {
return placeholder
}
if logo := getLogoBySearch(teamName); logo != "" {
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
}
return placeholder
}
func resolveISURL(href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
if u, err := neturl.Parse(href); err == nil {
u.Scheme = "https"
u.Host = "is.fotbal.cz"
if !strings.HasPrefix(u.Path, "/public/") {
if strings.HasPrefix(u.Path, "/zapasy/") {
u.Path = "/public" + u.Path
}
}
q := u.Query()
q.Del("discipline")
u.RawQuery = q.Encode()
return u.String()
}
return href
}
href = strings.TrimPrefix(href, "./")
for strings.HasPrefix(href, "../") {
href = strings.TrimPrefix(href, "../")
}
href = strings.TrimPrefix(href, "/")
path := "/public/" + href
u := neturl.URL{Scheme: "https", Host: "is.fotbal.cz", Path: path}
return u.String()
}
// ----- Controller -----
type FACRController struct{ DB *gorm.DB }
func NewFACRController(db *gorm.DB) *FACRController { return &FACRController{DB: db} }
// GET /api/v1/facr/club/search?q=
func (fc *FACRController) SearchClubs(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
vals := neturl.Values{}
vals.Set("q", q)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error creating request: %v", err)})
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error fetching search page: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Try once more with quoted query if short tokens present
resp.Body.Close()
searchURL2 := searchURL
tokens := strings.Fields(q)
for _, t := range tokens {
if len([]rune(t)) <= 2 {
vals2 := neturl.Values{}
vals2.Set("q", "\""+q+"\"")
searchURL2 = "https://www.fotbal.cz/club/hledej?" + vals2.Encode()
break
}
}
req2, _ := http.NewRequest("GET", searchURL2, nil)
req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req2.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req2.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp2, err2 := client.Do(req2)
if err2 != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error fetching (retry): %v", err2)})
return
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
c.JSON(http.StatusOK, gin.H{"query": q, "count": 0, "results": []SearchResult{}})
return
}
resp = resp2
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error parsing HTML: %v", err)})
return
}
var results []SearchResult
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
a := li.Find("a.Link--inverted").First()
href, _ := a.Attr("href")
if href == "" {
return
}
name := strings.TrimSpace(a.Find("span.H7").First().Text())
if name == "" {
name = strings.TrimSpace(a.Text())
}
img := a.Find("img").First()
logoURL, _ := img.Attr("src")
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
clubType := "football"
if strings.Contains(strings.ToLower(href), "/futsal/") {
clubType = "futsal"
}
parts := strings.Split(strings.TrimRight(href, "/"), "/")
clubID := ""
if len(parts) > 0 {
clubID = parts[len(parts)-1]
}
if !strings.HasPrefix(href, "http://") && !strings.HasPrefix(href, "https://") {
href = "https://www.fotbal.cz" + href
}
results = append(results, SearchResult{
Name: name,
ClubID: clubID,
ClubType: clubType,
URL: href,
LogoURL: logoURL,
Category: category,
Address: address,
})
})
// If setup is completed and a sport is configured, filter results to that sport only
if fc.DB != nil {
var s models.Settings
if err := fc.DB.First(&s).Error; err == nil {
if strings.TrimSpace(s.ClubID) != "" && strings.TrimSpace(s.ClubType) != "" {
filtered := make([]SearchResult, 0, len(results))
want := strings.ToLower(strings.TrimSpace(s.ClubType))
for _, r := range results {
if strings.ToLower(strings.TrimSpace(r.ClubType)) == want {
filtered = append(filtered, r)
}
}
results = filtered
}
}
}
if len(results) > 1 {
unique := make([]SearchResult, 0, len(results))
seenByID := map[string]int{}
seenByName := map[string]int{}
normKey := func(s string) string {
x := strings.ToLower(strings.TrimSpace(s))
x = strings.ReplaceAll(x, ".", "")
x = strings.ReplaceAll(x, " ", "")
return x
}
for _, r := range results {
id := strings.TrimSpace(r.ClubID)
if id != "" {
if idx, ok := seenByID[id]; ok {
// Keep the one with a logo
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByID[id] = len(unique)
unique = append(unique, r)
continue
}
key := normKey(r.Name)
if key == "" {
unique = append(unique, r)
continue
}
if idx, ok := seenByName[key]; ok {
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByName[key] = len(unique)
unique = append(unique, r)
}
results = unique
}
// respond and close the function
c.JSON(http.StatusOK, gin.H{
"query": q,
"count": len(results),
"results": results,
})
}
// GET /api/v1/facr/club/:type/:id
func (fc *FACRController) GetClubInfo(c *gin.Context) {
clubID := c.Param("id")
clubType := c.Param("type")
if clubID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
return
}
cacheKey := fmt.Sprintf("%s_%s_info", clubType, clubID)
force := c.Query("force") == "1"
if !force {
if data, ok := getCachedJSON(cacheKey); ok {
c.Data(http.StatusOK, "application/json", data)
return
}
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
c.Data(resp.StatusCode, "application/json", body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
// GET /api/v1/facr/club/:type/:id/table
func (fc *FACRController) GetClubTables(c *gin.Context) {
clubID := c.Param("id")
clubType := c.Param("type")
if clubID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
return
}
cacheKey := fmt.Sprintf("%s_%s_table", clubType, clubID)
force := c.Query("force") == "1"
if !force {
if data, ok := getCachedJSON(cacheKey); ok {
c.Data(http.StatusOK, "application/json", data)
return
}
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
c.Data(resp.StatusCode, "application/json", body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}