Compare commits

...

5 Commits

Author SHA1 Message Date
Tomas Dvorak 3ce2650952 fix 2026-03-20 16:57:40 +01:00
Tomas Dvorak e09633d2f0 fix update 2026-03-20 16:21:26 +01:00
Tomas Dvorak e450d84c41 increase limit for calls 2026-03-20 15:19:22 +01:00
Tomas Dvorak d166e8bd2a fix errors 2026-03-20 14:57:59 +01:00
Tomas Dvorak 1077d518fe push 2026-03-20 14:57:39 +01:00
5 changed files with 143 additions and 97 deletions
+2 -2
View File
@@ -214,7 +214,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">{t('nav.players')}</Button> <Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">{t('nav.players')}</Button>
)} )}
{hasTables && ( {hasTables && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">{t('nav.tables')}</Button> <Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">{t('nav.table')}</Button>
)} )}
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => { {Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url); const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
@@ -595,7 +595,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
baseLinks.push({ label: t('nav.players'), to: '/hraci' }); baseLinks.push({ label: t('nav.players'), to: '/hraci' });
} }
if (navbarData.hasTables) { if (navbarData.hasTables) {
baseLinks.push({ label: t('nav.tables'), to: '/tabulky' }); baseLinks.push({ label: t('nav.table'), to: '/tabulky' });
} }
if (navbarData.hasArticles) { if (navbarData.hasArticles) {
baseLinks.push( baseLinks.push(
+36 -1
View File
@@ -65,7 +65,7 @@ const SetupPage: React.FC = () => {
const [selectedClubSearchLabel, setSelectedClubSearchLabel] = useState(''); const [selectedClubSearchLabel, setSelectedClubSearchLabel] = useState('');
const { data: publicSettings } = usePublicSettings(); const { data: publicSettings } = usePublicSettings();
const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual'; const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual';
const { searchClubs, searchResults, searchLoading, clearSearchResults } = useFacrApi(); const { searchClubs, searchResults, searchLoading, clearSearchResults, getClub, getClubTable } = useFacrApi();
const suppressNextClubSearchRef = useRef(false); const suppressNextClubSearchRef = useRef(false);
const resolveLogoUrl = (u?: string | null) => { const resolveLogoUrl = (u?: string | null) => {
@@ -138,6 +138,8 @@ const SetupPage: React.FC = () => {
const [processingLogos, setProcessingLogos] = useState(false); const [processingLogos, setProcessingLogos] = useState(false);
const [rembgTotal, setRembgTotal] = useState(0); const [rembgTotal, setRembgTotal] = useState(0);
const [rembgDone, setRembgDone] = useState(0); const [rembgDone, setRembgDone] = useState(0);
const [fetchingFacrData, setFetchingFacrData] = useState(false);
const [facrDataProgress, setFacrDataProgress] = useState('');
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -498,6 +500,29 @@ const SetupPage: React.FC = () => {
}); });
} catch {} } catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true }); toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
// Prefetch FACR data (club info and tables) before proceeding
// This ensures data is cached and ready when user enters admin
if (clubId && clubType && !isManualClubDataMode) {
setFetchingFacrData(true);
setFacrDataProgress('Získáváme data z FAČR...');
try {
// Fetch club info with retries (backend handles retries)
setFacrDataProgress('Získáváme data z FAČR... (informace o klubu)');
await getClub(clubId, clubType as 'football' | 'futsal').catch(() => null);
// Fetch club tables
setFacrDataProgress('Získáváme data z FAČR... (tabulky a zápasy)');
await getClubTable(clubId, clubType as 'football' | 'futsal').catch(() => null);
setFacrDataProgress('Data z FAČR načtena');
} catch {
// Continue even if FACR fetch fails - user can retry later
} finally {
setFetchingFacrData(false);
}
}
// Start background removal only if backend allows it; otherwise skip waiting UI // Start background removal only if backend allows it; otherwise skip waiting UI
let allowRembg = false; let allowRembg = false;
try { try {
@@ -1324,6 +1349,16 @@ const SetupPage: React.FC = () => {
</VStack> </VStack>
</Box> </Box>
)} )}
{fetchingFacrData && (
<Box position="fixed" top={0} left={0} right={0} bottom={0} bg="rgba(0,0,0,0.6)" zIndex={9999} display="flex" alignItems="center" justifyContent="center">
<VStack spacing={3} bg={bg} p={8} borderRadius="xl" boxShadow="xl">
<Spinner size="xl" />
<Heading size="md">Získáváme data z FAČR</Heading>
<Text>{facrDataProgress || 'Načítám data…'}</Text>
<Text fontSize="sm" color="gray.500">Prosím vyčkejte, může to chvíli trvat</Text>
</VStack>
</Box>
)}
</Box> </Box>
); );
}; };
+102 -91
View File
@@ -17,7 +17,6 @@ import (
"fotbal-club/internal/models" "fotbal-club/internal/models"
"fotbal-club/internal/services" "fotbal-club/internal/services"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -34,6 +33,70 @@ var (
cacheTTL = 30 * time.Minute cacheTTL = 30 * time.Minute
) )
// facrRetryConfig defines retry behavior for FACR API calls
var facrRetryConfig = struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}{
MaxAttempts: 10,
BaseDelay: 5 * time.Second,
MaxDelay: 60 * time.Second,
}
// fetchWithRetry performs HTTP GET with retry logic and exponential backoff
func fetchWithRetry(url string, timeout time.Duration) (*http.Response, error) {
var lastErr error
client := &http.Client{Timeout: timeout}
for attempt := 1; attempt <= facrRetryConfig.MaxAttempts; attempt++ {
resp, err := client.Get(url)
if err != nil {
lastErr = err
if attempt < facrRetryConfig.MaxAttempts {
multiplier := 1 << uint(attempt-1) // 2^(attempt-1)
delay := facrRetryConfig.BaseDelay * time.Duration(multiplier)
if delay > facrRetryConfig.MaxDelay {
delay = facrRetryConfig.MaxDelay
}
time.Sleep(delay)
}
continue
}
// Check if response indicates an error from the FACR API
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close()
// Check if body contains scraping error (temporary failure)
bodyStr := string(body)
if strings.Contains(bodyStr, "scraping failed") ||
strings.Contains(bodyStr, "Cloudflare") ||
strings.Contains(bodyStr, "failed to fetch") ||
resp.StatusCode >= 500 {
lastErr = fmt.Errorf("FACR API temporary error (attempt %d): %s", attempt, bodyStr)
if attempt < facrRetryConfig.MaxAttempts {
multiplier := 1 << uint(attempt-1) // 2^(attempt-1)
delay := facrRetryConfig.BaseDelay * time.Duration(multiplier)
if delay > facrRetryConfig.MaxDelay {
delay = facrRetryConfig.MaxDelay
}
time.Sleep(delay)
}
continue
}
// For other errors, return immediately with a reconstructed response
return resp, nil
}
return resp, nil
}
return nil, fmt.Errorf("FACR API failed after %d attempts: %v", facrRetryConfig.MaxAttempts, lastErr)
}
func cacheDir() string { func cacheDir() string {
return filepath.Join("cache", "facr") return filepath.Join("cache", "facr")
} }
@@ -224,102 +287,52 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
}) })
return return
} }
// Use external FACR API proxy instead of scraping www.fotbal.cz directly
vals := neturl.Values{} vals := neturl.Values{}
vals.Set("q", q) vals.Set("q", q)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode() searchURL := "https://facr.tdvorak.dev/club/search?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil) resp, err := fetchWithRetry(searchURL, 10*time.Minute)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error creating request: %v", err)}) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error fetching from FACR API: %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 return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Try once more with quoted query if short tokens present body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close() c.Data(resp.StatusCode, "application/json", body)
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 return
} }
var results []SearchResult
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) { // Read and parse the external API response
a := li.Find("a.Link--inverted").First() body, err := io.ReadAll(resp.Body)
href, _ := a.Attr("href") if err != nil {
if href == "" { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error reading response: %v", err)})
return return
}
// Parse the JSON to apply local processing (logo handling, filtering, deduplication)
var apiResp struct {
Query string `json:"query"`
Count int `json:"count"`
Results []SearchResult `json:"results"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error parsing response: %v", err)})
return
}
results := apiResp.Results
// Process logos through local rembg service
for i := range results {
if logoURL := strings.TrimSpace(results[i].LogoURL); logoURL != "" {
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
results[i].LogoURL = p
}
} }
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")
// Best-effort: Process FACR logos to transparent PNG. Non-facr URLs are returned unchanged.
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
logoURL = p
}
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 setup is completed and a sport is configured, filter results to that sport only
if fc.DB != nil { if fc.DB != nil {
@@ -338,6 +351,7 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
} }
} }
// Deduplicate results
if len(results) > 1 { if len(results) > 1 {
unique := make([]SearchResult, 0, len(results)) unique := make([]SearchResult, 0, len(results))
seenByID := map[string]int{} seenByID := map[string]int{}
@@ -379,7 +393,6 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
results = unique results = unique
} }
// respond and close the function
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"query": q, "query": q,
"count": len(results), "count": len(results),
@@ -421,8 +434,7 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
return return
} }
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID) external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second} resp, err := fetchWithRetry(external, 10*time.Minute)
resp, err := httpClient.Get(external)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)}) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return return
@@ -689,8 +701,7 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
return return
} }
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID) external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second} resp, err := fetchWithRetry(external, 10*time.Minute)
resp, err := httpClient.Get(external)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)}) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return return
+1 -1
View File
@@ -40,7 +40,7 @@ func NewFACRService(baseURL string) *FACRService {
return &FACRService{ return &FACRService{
baseURL: strings.TrimSuffix(baseURL, "/"), baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 15 * time.Minute, // FAČR API can be very slow
}, },
} }
} }
+2 -2
View File
@@ -356,7 +356,7 @@ func StartPrefetcher(baseURL string) {
log.Printf("[prefetch] failed to create cache dir: %v", err) log.Printf("[prefetch] failed to create cache dir: %v", err)
} }
client := &http.Client{Timeout: 20 * time.Second} client := &http.Client{Timeout: 15 * time.Minute} // FACR API can be very slow
// Feature toggles (env) // Feature toggles (env)
enableFastMatch := envBool("ENABLE_FAST_MATCH_PREFETCH", true) enableFastMatch := envBool("ENABLE_FAST_MATCH_PREFETCH", true)
@@ -504,7 +504,7 @@ func StartPrefetcher(baseURL string) {
// PrefetchOnce runs a single prefetch cycle immediately. Useful to trigger after setup. // PrefetchOnce runs a single prefetch cycle immediately. Useful to trigger after setup.
func PrefetchOnce(baseURL string) { func PrefetchOnce(baseURL string) {
baseURL = strings.TrimSuffix(baseURL, "/") baseURL = strings.TrimSuffix(baseURL, "/")
client := &http.Client{Timeout: 20 * time.Second} client := &http.Client{Timeout: 15 * time.Minute} // FACR API can be very slow
doPrefetchCycleGuarded(client, baseURL) doPrefetchCycleGuarded(client, baseURL)
} }