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) }