This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+208 -151
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -21,9 +22,6 @@ import (
"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"`
@@ -81,6 +79,14 @@ func setCachedJSON(key string, data []byte) {
}
}
func isManualClubDataMode() bool {
if config.AppConfig == nil {
return false
}
mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode))
return mode == "manual"
}
// ----- Types (mirroring facr-scraper) -----
type Competition struct {
@@ -150,15 +156,6 @@ type TableRow struct {
// ----- 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 == "" {
@@ -177,145 +174,6 @@ func extractUUIDFromHref(href string) string {
}
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 != "" {
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
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 != "" {
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
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 != "" {
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
return p
}
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
return p
}
return u
}
return placeholder
}
func resolveISURL(href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
@@ -357,6 +215,15 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
// In manual club data mode we do not perform any external FACR/fotbal.cz lookups.
if isManualClubDataMode() {
c.JSON(http.StatusOK, gin.H{
"query": q,
"count": 0,
"results": []SearchResult{},
})
return
}
vals := neturl.Values{}
vals.Set("q", q)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
@@ -536,6 +403,23 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
return
}
}
// Manual mode: build FACR-like payload from DB-backed manual models, no external HTTP.
if isManualClubDataMode() {
payload, err := fc.buildManualClubPayload(clubID, clubType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)})
return
}
b, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
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)
@@ -615,6 +499,162 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
c.Data(http.StatusOK, "application/json", b)
}
// buildManualClubPayload constructs a FACR-like ClubInfo payload from manual DB models.
// It is used in manual club data mode to avoid any external FACR/fotbal.cz HTTP calls.
func (fc *FACRController) buildManualClubPayload(clubID, clubType string) (*ClubInfo, error) {
if fc.DB == nil {
return nil, fmt.Errorf("database handle not available")
}
clubID = strings.TrimSpace(clubID)
clubType = strings.TrimSpace(clubType)
if clubID == "" || clubType == "" {
return nil, fmt.Errorf("missing club id or type")
}
// Load primary settings for club metadata when available.
var s models.Settings
_ = fc.DB.First(&s).Error
name := strings.TrimSpace(s.ClubName)
url := strings.TrimSpace(s.ClubURL)
logoURL := strings.TrimSpace(s.ClubLogoURL)
if name == "" {
name = ""
}
payload := &ClubInfo{
Name: name,
ClubID: clubID,
ClubType: clubType,
URL: url,
LogoURL: logoURL,
Competitions: []Competition{},
}
// Load manual competitions for this club.
var comps []models.ManualCompetition
if err := fc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).Order("id ASC").Find(&comps).Error; err != nil {
return nil, err
}
if len(comps) == 0 {
return payload, nil
}
payload.Competitions = make([]Competition, len(comps))
idxByCompID := make(map[uint]int, len(comps))
compIDs := make([]uint, len(comps))
for i, c := range comps {
payload.Competitions[i] = Competition{
ID: strings.TrimSpace(c.ExternalID),
Code: strings.TrimSpace(c.Code),
Name: strings.TrimSpace(c.Name),
TeamCount: strings.TrimSpace(c.TeamCount),
MatchesLink: strings.TrimSpace(c.MatchesLink),
}
idxByCompID[c.ID] = i
compIDs[i] = c.ID
}
// Load manual matches for all competitions.
var mm []models.ManualMatch
if err := fc.DB.Where("competition_id IN ?", compIDs).Order("kickoff ASC, id ASC").Find(&mm).Error; err == nil {
primaryName := name
primaryID := clubID
for _, m := range mm {
idx, ok := idxByCompID[m.CompetitionID]
if !ok {
continue
}
// Derive home/away teams relative to primary club.
hName := strings.TrimSpace(m.OpponentName)
aName := primaryName
hID := strings.TrimSpace(m.OpponentExternalID)
aID := primaryID
if m.IsHome {
// Primary club at home.
hName = primaryName
aName = strings.TrimSpace(m.OpponentName)
hID = primaryID
aID = strings.TrimSpace(m.OpponentExternalID)
}
// Format kickoff as FACR-style "dd.MM.yyyy HH:mm".
var dt string
if !m.Kickoff.IsZero() {
dt = m.Kickoff.In(time.Local).Format("02.01.2006 15:04")
}
// Compose score with optional halftime in parentheses.
score := strings.TrimSpace(m.Score)
if ht := strings.TrimSpace(m.HalftimeScore); ht != "" {
if score != "" {
score = fmt.Sprintf("%s (%s)", score, ht)
} else {
score = ht
}
}
// Build FACR-like placeholder logos based on team IDs; LogoAPI/local overrides are applied on the frontend.
hLogo := facrPlaceholderLogo(hID)
aLogo := facrPlaceholderLogo(aID)
payload.Competitions[idx].Matches = append(payload.Competitions[idx].Matches, Match{
DateTime: dt,
Home: hName,
HomeID: hID,
HomeLogoURL: hLogo,
Away: aName,
AwayID: aID,
AwayLogoURL: aLogo,
Score: score,
Venue: strings.TrimSpace(m.Venue),
Note: strings.TrimSpace(m.Note),
MatchID: strings.TrimSpace(m.ExternalMatchID),
ReportURL: strings.TrimSpace(m.MatchURL),
})
}
}
// Load manual table rows for all competitions.
var rows []models.ManualTableRow
if err := fc.DB.Where("competition_id IN ?", compIDs).Order("rank ASC, id ASC").Find(&rows).Error; err == nil {
for _, r := range rows {
idx, ok := idxByCompID[r.CompetitionID]
if !ok {
continue
}
tr := TableRow{
Rank: strings.TrimSpace(r.Rank),
Team: strings.TrimSpace(r.TeamName),
TeamID: strings.TrimSpace(r.ExternalTeamID),
TeamLogoURL: facrPlaceholderLogo(strings.TrimSpace(r.ExternalTeamID)),
Played: strings.TrimSpace(r.Played),
Wins: strings.TrimSpace(r.Wins),
Draws: strings.TrimSpace(r.Draws),
Losses: strings.TrimSpace(r.Losses),
Score: strings.TrimSpace(r.Score),
Points: strings.TrimSpace(r.Points),
}
if payload.Competitions[idx].Table == nil {
payload.Competitions[idx].Table = &CompetitionTable{Overall: []TableRow{}}
}
payload.Competitions[idx].Table.Overall = append(payload.Competitions[idx].Table.Overall, tr)
}
}
return payload, nil
}
// facrPlaceholderLogo builds a static FACR-style logo URL from a team UUID.
// It does not perform any HTTP requests and is safe in manual mode; LogoAPI/local
// overrides remain responsible for primary logo resolution on the frontend.
func facrPlaceholderLogo(teamID string) string {
id := strings.TrimSpace(teamID)
if id == "" {
return ""
}
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", id, id)
}
// GET /api/v1/facr/club/:type/:id/table
func (fc *FACRController) GetClubTables(c *gin.Context) {
clubID := c.Param("id")
@@ -631,6 +671,23 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
return
}
}
// Manual mode: reuse manual FACR-like payload and return competitions with tables.
if isManualClubDataMode() {
payload, err := fc.buildManualClubPayload(clubID, clubType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)})
return
}
b, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
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)