Files
FCBizoniUH/backend/main.go
T
Your Name 225da9253c feat(backend): add FutsalLiga scraper as secondary data source
Implement web scraping fallback for the official FutsalLiga website as a secondary data source between the primary API and the FlashScore fallback. This provides more reliable data retrieval with correct competition information when the primary API fails.

Add scrapeFutsalLiga function to extract club details, matches, and standings directly from the league website. Update refresh logic to attempt FutsalLiga scraping before falling back to FlashScore API
2026-10-04 21:24:28 +02:00

1771 lines
53 KiB
Go

package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
"image/png"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
clubID = "441d3783-06aa-436a-b438-359300ee0371"
clubType = "futsal"
baseURL = "https://facr.tdvorak.dev"
fallbackBaseURL = "https://flashscore.tdvorak.dev"
fallbackClubID = "xzS3gX3T"
fallbackSlug = "uherske-hradiste"
)
// Paths
func dataPath() string {
if p := os.Getenv("DATA_PATH"); p != "" {
return p
}
// Default: Docker volume path (writable)
return "/app/data/club.json"
}
// ---------------- Image normalization for blog thumbnails ----------------
// Target dimensions for blog images
const (
blogImgW = 1600
blogImgH = 969
)
// avgLuma computes average luminance of an image (0..255)
func avgLuma(img image.Image) float64 {
b := img.Bounds()
if b.Empty() {
return 0
}
var sum uint64
var n uint64
for y := b.Min.Y; y < b.Max.Y; y += 4 { // sample every 4th row for speed
for x := b.Min.X; x < b.Max.X; x += 4 { // sample every 4th column
r, g, bv, _ := img.At(x, y).RGBA()
r8 := float64(r >> 8)
g8 := float64(g >> 8)
b8 := float64(bv >> 8)
// Rec. 601 approximate luma
l := 0.299*r8 + 0.587*g8 + 0.114*b8
if l < 0 {
l = 0
}
if l > 255 {
l = 255
}
sum += uint64(l)
n++
}
}
if n == 0 {
return 0
}
return float64(sum) / float64(n)
}
// fitWithin returns destination size that fits source into max size, without upscaling
func fitWithin(sw, sh, mw, mh int) (int, int) {
if sw <= 0 || sh <= 0 {
return 0, 0
}
if sw <= mw && sh <= mh {
return sw, sh
}
wr := float64(mw) / float64(sw)
hr := float64(mh) / float64(sh)
r := wr
if hr < wr {
r = hr
}
dw := int(float64(sw) * r)
dh := int(float64(sh) * r)
if dw < 1 {
dw = 1
}
if dh < 1 {
dh = 1
}
return dw, dh
}
// scaleNearest performs nearest-neighbor downscaling from src to a new RGBA of size (dw, dh)
func scaleNearest(src image.Image, dw, dh int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
for y := 0; y < dh; y++ {
sy := sb.Min.Y + int(float64(y)*float64(sh)/float64(dh))
for x := 0; x < dw; x++ {
sx := sb.Min.X + int(float64(x)*float64(sw)/float64(dw))
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// normalizeBlogImage decodes any supported image (PNG/JPEG) and writes a 1600x969 PNG with letterboxing (black/white)
func normalizeBlogImage(r io.Reader, outPath string) error {
img, _, err := image.Decode(r)
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
// Choose background based on average luminance
l := avgLuma(img)
bg := color.Black
if l > 160 { // bright image -> white bg; tweak threshold as needed
bg = color.White
}
// Compute fitted size (no upscaling)
srcB := img.Bounds()
dw, dh := fitWithin(srcB.Dx(), srcB.Dy(), blogImgW, blogImgH)
var scaled image.Image
if dw == srcB.Dx() && dh == srcB.Dy() {
scaled = img
} else {
scaled = scaleNearest(img, dw, dh)
}
// Compose centered on canvas
canvas := image.NewRGBA(image.Rect(0, 0, blogImgW, blogImgH))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
offX := (blogImgW - dw) / 2
offY := (blogImgH - dh) / 2
draw.Draw(canvas, image.Rect(offX, offY, offX+dw, offY+dh), scaled, scaled.Bounds().Min, draw.Over)
// Write PNG atomically
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
tmp := outPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(f, canvas); err != nil {
f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("encode png: %w", err)
}
f.Close()
_ = os.Remove(outPath)
if err := os.Rename(tmp, outPath); err != nil {
return err
}
return nil
}
func staticPath() string {
if p := os.Getenv("STATIC_PATH"); p != "" {
return p
}
// Default: use current working directory when running locally
cwd, err := os.Getwd()
if err == nil && cwd != "" {
// If CWD contains index.html, assume it's the site root
if _, err := os.Stat(filepath.Join(cwd, "index.html")); err == nil {
return cwd
}
// Otherwise, try parent directory (common when running from ./backend)
parent := filepath.Dir(cwd)
if parent != "" {
if _, err := os.Stat(filepath.Join(parent, "index.html")); err == nil {
return parent
}
}
// Fallback to CWD even if index.html not found
return cwd
}
// Fallback to container default if CWD is unavailable
return "/app/site"
}
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"`
}
// ---------------- Admin Basic Auth ----------------
func adminCreds() (string, string) {
u := os.Getenv("ADMIN_USER")
p := os.Getenv("ADMIN_PASS")
if u == "" {
u = "[email protected]"
}
if p == "" {
p = "%8s3Yad*!b3*t"
}
return u, p
}
func checkBasicAuth(r *http.Request) bool {
user, pass := adminCreds()
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Basic ") {
return false
}
b64 := strings.TrimPrefix(auth, "Basic ")
dec, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return false
}
parts := strings.SplitN(string(dec), ":", 2)
if len(parts) != 2 {
return false
}
return parts[0] == user && parts[1] == pass
}
func requireBasicAuth(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", "Basic realm=admin")
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
func basicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !checkBasicAuth(r) {
requireBasicAuth(w)
return
}
next.ServeHTTP(w, r)
})
}
// Compute next blog numeric ID by scanning blog/*.html
func nextBlogID(siteRoot string) (int, error) {
blogDir := filepath.Join(siteRoot, "blog")
entries, err := os.ReadDir(blogDir)
if err != nil {
return 0, err
}
re := regexp.MustCompile(`^(\d{4})\.html$`)
max := -1
for _, e := range entries {
m := re.FindStringSubmatch(e.Name())
if len(m) == 2 {
if n, err := strconv.Atoi(m[1]); err == nil && n > max {
max = n
}
}
}
if max < 0 {
return 0, fmt.Errorf("no posts found to derive id")
}
return max + 1, nil
}
// Minimal HTML escaper for title
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
// ---------------- YouTube: periodic refresh and persistence ----------------
func videosScheduler(ctx context.Context) {
// Refresh once a day
for {
select {
case <-time.After(24 * time.Hour):
if err := refreshVideos(ctx); err != nil {
log.Printf("videos refresh error: %v", err)
}
case <-ctx.Done():
return
}
}
}
func refreshVideos(ctx context.Context) error {
client := &http.Client{Timeout: 30 * time.Second}
base := "https://youtube.tdvorak.dev"
ch := ytChannel()
u := base + "/channel_videos?channel=" + url.QueryEscape(ch)
var resp YTChannelResp
if err := getJSON(ctx, client, u, &resp); err != nil {
return fmt.Errorf("yt get: %w", err)
}
items := resp.Videos
if len(items) == 0 {
// API returned empty data; keep existing local videos as fallback
vc.mu.RLock()
existingCount := len(vc.data.Items)
vc.mu.RUnlock()
log.Printf("warn: yt api returned 0 videos; keeping %d existing videos", existingCount)
return nil
}
if len(items) > 5 {
items = items[:5]
}
vc.mu.Lock()
vc.data.FetchedAt = time.Now()
vc.data.Channel = resp.Channel
vc.data.Items = items
vc.mu.Unlock()
if err := writeVideosJSON(); err != nil {
log.Printf("warn: write videos json: %v", err)
}
return nil
}
func writeVideosJSON() error {
path := videosPath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
vc.mu.RLock()
payload := struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Items []YTVideo `json:"items"`
}{FetchedAt: vc.data.FetchedAt, Channel: vc.data.Channel, Items: vc.data.Items}
vc.mu.RUnlock()
b, err := json.MarshalIndent(payload, "", " ")
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)
}
_ = os.Remove(path)
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
// Also mirror to static site path for fast fetch by frontend
// Attempt best-effort; log warnings but do not fail the main write
// Target files: <STATIC_PATH>/data/videos.json and <STATIC_PATH>/data/video.json
func() {
defer func() { _ = recover() }()
sp := staticPath()
if sp == "" {
return
}
dataDir := filepath.Join(sp, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Printf("warn: mkdir static data dir: %v", err)
return
}
// Write videos.json
dest1 := filepath.Join(dataDir, "videos.json")
if err := os.WriteFile(dest1, b, 0644); err != nil {
log.Printf("warn: write static videos.json: %v", err)
}
// Write video.json (alias) to match frontend expectation
dest2 := filepath.Join(dataDir, "video.json")
if err := os.WriteFile(dest2, b, 0644); err != nil {
log.Printf("warn: write static video.json: %v", err)
}
}()
return nil
}
func loadVideosJSON() error {
path := videosPath()
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read videos json: %w", err)
}
var payload struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Items []YTVideo `json:"items"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("unmarshal videos json: %w", err)
}
vc.mu.Lock()
vc.data.FetchedAt = payload.FetchedAt
vc.data.Channel = payload.Channel
vc.data.Items = payload.Items
vc.mu.Unlock()
return nil
}
func videosPath() string {
if p := os.Getenv("VIDEOS_PATH"); p != "" {
return p
}
// Default: Docker volume path (writable)
return "/app/data/videos.json"
}
func ytChannel() string {
if c := os.Getenv("YT_CHANNEL"); c != "" {
return c
}
// Default YouTube channel
return "@FCBizoniUH"
}
// BlogItem represents a simple blog card item for the homepage
type BlogItem struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Link string `json:"link"`
Image string `json:"image"`
MTime time.Time `json:"mtime"`
Categories []string `json:"categories,omitempty"`
}
// generateSlug creates a URL-friendly slug from a title
func generateSlug(title string) string {
slug := strings.ToLower(title)
// Replace Czech characters with their ASCII equivalents
replacements := map[string]string{
"á": "a", "ä": "a", "č": "c", "ď": "d", "é": "e", "ě": "e", "í": "i", "ľ": "l",
"ň": "n", "ó": "o", "ö": "o", "ô": "o", "ř": "r", "š": "s", "ť": "t", "ú": "u",
"ů": "u", "ý": "y", "ž": "z",
"Á": "a", "Ä": "a", "Č": "c", "Ď": "d", "É": "e", "Ě": "e", "Í": "i", "Ľ": "l",
"Ň": "n", "Ó": "o", "Ö": "o", "Ô": "o", "Ř": "r", "Š": "s", "Ť": "t", "Ú": "u",
"Ů": "u", "Ý": "y", "Ž": "z",
}
for czech, ascii := range replacements {
slug = strings.ReplaceAll(slug, czech, ascii)
}
// Remove any character that isn't alphanumeric, space, or hyphen
re := regexp.MustCompile(`[^a-z0-9\s-]`)
slug = re.ReplaceAllString(slug, "")
// Replace spaces and multiple hyphens with a single hyphen
re = regexp.MustCompile(`[\s-]+`)
slug = re.ReplaceAllString(slug, "-")
// Remove leading and trailing hyphens
slug = strings.Trim(slug, "-")
return slug
}
// ensureUniqueSlug ensures the slug is unique by appending a number if needed
func ensureUniqueSlug(siteRoot, baseSlug string) string {
blogDir := filepath.Join(siteRoot, "blog")
entries, err := os.ReadDir(blogDir)
if err != nil {
return baseSlug
}
existingSlugs := make(map[string]bool)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".html") {
continue
}
// Extract slug from filename if it follows the new pattern
name := strings.TrimSuffix(e.Name(), ".html")
// Check if it's a slug-based filename (contains letters, not just numbers)
if regexp.MustCompile(`[a-z]`).MatchString(name) {
existingSlugs[name] = true
}
}
if !existingSlugs[baseSlug] {
return baseSlug
}
// Try baseSlug-2, baseSlug-3, etc.
for i := 2; i < 100; i++ {
testSlug := fmt.Sprintf("%s-%d", baseSlug, i)
if !existingSlugs[testSlug] {
return testSlug
}
}
// Fallback to timestamp
return fmt.Sprintf("%s-%d", baseSlug, time.Now().Unix())
}
func extractCategories(path string) []string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
s := string(b)
// match <meta name="category" content="Category">
re := regexp.MustCompile(`(?is)<meta name="category" content="([^"]+)"`)
matches := re.FindAllStringSubmatch(s, -1)
categories := make([]string, len(matches))
for i, match := range matches {
categories[i] = match[1]
}
return categories
}
// extractAnnotation extracts the annotation from blog HTML meta tags
func extractAnnotation(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
// Try to find description in meta tag first
re := regexp.MustCompile(`(?is)<meta name="description" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
// extractContentMode extracts the content mode from blog HTML meta tags
func extractContentMode(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
// Try to find content_mode in meta tag first
re := regexp.MustCompile(`(?is)<meta name="content_mode" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
// Default to visual if not found
return "visual"
}
// extractSlug extracts the slug from blog HTML meta tags or generates from filename
func extractSlug(path, filename string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return extractSlugFromContent(string(b))
}
// extractSlugFromContent extracts the slug from HTML content string
func extractSlugFromContent(htmlContent string) string {
re := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
m := re.FindStringSubmatch(htmlContent)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractBlogID(path, filename string) string {
b, err := os.ReadFile(path)
if err != nil {
return strings.TrimSuffix(filename, ".html")
}
s := string(b)
// Try to find ID in meta tag first
re := regexp.MustCompile(`(?is)<meta name="id" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
// Fallback: use filename without extension
return strings.TrimSuffix(filename, ".html")
}
func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
// Use the siteRoot path where blogs are actually located
blogDir := filepath.Join(siteRoot, "blog")
// For local development, you can override with environment variable
if envPath := os.Getenv("REMOTE_BLOG_DIR"); envPath != "" {
blogDir = envPath
}
// If blog directory doesn't exist, return error
if _, err := os.Stat(blogDir); os.IsNotExist(err) {
return nil, fmt.Errorf("blog directory not found: %s. Set REMOTE_BLOG_DIR environment variable or ensure blog directory exists", blogDir)
}
imgDir := filepath.Join(filepath.Dir(blogDir), "img", "blog")
entries, err := os.ReadDir(blogDir)
if err != nil {
return nil, fmt.Errorf("readdir blog: %w", err)
}
// Match both numeric (0001.html) and slug-based filenames
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
var items []BlogItem
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
for _, e := range entries {
name := e.Name()
if !re.MatchString(name) {
continue
}
id := strings.TrimSuffix(name, ".html")
// Title and categories extraction from blog HTML
blogPath := filepath.Join(blogDir, name)
title := extractTitle(blogPath)
slug := extractSlug(blogPath, name)
cats := extractCategories(blogPath)
// Determine canonical ID: prefer numeric when available.
// This ensures consistent item identity regardless of whether the
// numeric or slug filename is encountered first in the directory.
canonicalID := id
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
for _, numericFile := range numericFiles {
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
numericPath := filepath.Join(blogDir, numericFile)
numericSlug := extractSlug(numericPath, numericFile)
if numericSlug == id {
canonicalID = numericID
break
}
}
}
// Skip if this canonical ID or its slug was already processed (deduplication)
if seenIDs[canonicalID] || (slug != "" && seenIDs[slug]) {
continue
}
// Mark both canonical ID and slug as seen
seenIDs[canonicalID] = true
if slug != "" {
seenIDs[slug] = true
}
// Determine mod time - prefer image modtime if exists, else html
mtime := time.Time{}
htmlInfo, err1 := os.Stat(filepath.Join(blogDir, name))
if err1 == nil {
mtime = htmlInfo.ModTime()
}
// For image path, canonicalID is already numeric when a numeric file exists
imageID := canonicalID
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
// If image is newer, use that as a proxy for recency
if imgInfo.ModTime().After(mtime) {
mtime = imgInfo.ModTime()
}
}
// Use slug-based link if slug exists and is not just numeric, otherwise use canonical numeric ID
var link string
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
link = "/blog/" + slug
} else {
link = "/blog/" + canonicalID + ".html"
}
items = append(items, BlogItem{
ID: canonicalID,
Title: title,
Slug: slug,
Link: link,
Image: "/img/blog/" + imageID + ".png",
MTime: mtime,
Categories: cats,
})
}
sort.Slice(items, func(i, j int) bool {
// Always prefer numeric ID descending (higher ID = newer post).
// Numeric IDs monotonically increase via nextBlogID, so they are
// the authoritative ordering regardless of file MTime.
ii, err1 := strconv.Atoi(items[i].ID)
jj, err2 := strconv.Atoi(items[j].ID)
if err1 == nil && err2 == nil {
return ii > jj
}
// If only one item has a numeric ID, it is newer
if err1 == nil {
return true
}
if err2 == nil {
return false
}
// Both non-numeric: fall back to MTime, then string comparison
if !items[i].MTime.Equal(items[j].MTime) {
return items[i].MTime.After(items[j].MTime)
}
return items[i].ID > items[j].ID
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
return items, nil
}
// validateBlogOrdering performs a startup health check to catch ordering regressions early.
func validateBlogOrdering(siteRoot string) error {
items, err := listLatestBlogs(siteRoot, 12)
if err != nil {
return err
}
if len(items) < 2 {
return nil
}
for i := 0; i < len(items)-1; i++ {
ii, e1 := strconv.Atoi(items[i].ID)
jj, e2 := strconv.Atoi(items[i+1].ID)
if e1 == nil && e2 == nil && ii <= jj {
return fmt.Errorf("blog ordering suspect: %s (%d) should be newer than %s (%d)", items[i].ID, ii, items[i+1].ID, jj)
}
}
log.Printf("blog ordering validated: newest=%s total=%d", items[0].ID, len(items))
return nil
}
// extractTitle finds the first <h1>...</h1> and returns its inner text (very simple, best-effort)
func extractTitle(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
// match <h1 ...>Title</h1>
re := regexp.MustCompile(`(?is)<h1[^>]*>(.*?)</h1>`) // non-greedy
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
// strip HTML tags inside, if any
inner := m[1]
// remove any nested tags crudely
inner = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(inner, "")
inner = strings.TrimSpace(inner)
return inner
}
return ""
}
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
// ---- YouTube videos cache ----
type YTVideo struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
Length string `json:"length"`
ThumbnailURL string `json:"thumbnail_url"`
ViewsText string `json:"views_text"`
Views int `json:"views"`
PublishedText string `json:"published_text"`
PublishedDate string `json:"published_date"`
}
type YTChannelResp struct {
Channel string `json:"channel"`
ChannelURL string `json:"channel_url"`
SubscribersText string `json:"subscribers_text"`
Subscribers int `json:"subscribers"`
Videos []YTVideo `json:"videos"`
}
type videosCache struct {
mu sync.RWMutex
data struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Items []YTVideo `json:"items"`
}
}
var vc videosCache
// simple in-memory rate limiter for manual videos refresh
type rateLimiter struct {
mu sync.Mutex
hits []time.Time
}
func (rl *rateLimiter) Allow(now time.Time, limit int, per time.Duration) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
// drop timestamps older than window
cutoff := now.Add(-per)
i := 0
for _, t := range rl.hits {
if t.After(cutoff) {
rl.hits[i] = t
i++
}
}
rl.hits = rl.hits[:i]
if len(rl.hits) >= limit {
return false
}
rl.hits = append(rl.hits, now)
return true
}
var videosPostLimiter rateLimiter
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)
go videosScheduler(ctx)
// Load previously persisted videos so we have a fallback if yt api fails
if err := loadVideosJSON(); err != nil {
log.Printf("no persisted videos to load: %v", err)
}
// Initial videos fetch on startup to warm cache
if err := refreshVideos(ctx); err != nil {
log.Printf("initial videos refresh error: %v", err)
}
// Startup validation: warn if blog ordering looks wrong
if err := validateBlogOrdering(staticPath()); err != nil {
log.Printf("blog ordering validation: %v", err)
}
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(";"))
})
// Blog list JSON for frontend
mux.HandleFunc("/data/blog-list.json", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
limit := 50 // Default limit for blog list
if q := r.URL.Query().Get("limit"); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 {
limit = n
}
}
items, err := listLatestBlogs(staticPath(), limit)
if err != nil {
log.Printf("blog-list.json error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(items)
})
// Blog API: latest N posts from filesystem
mux.HandleFunc("/api/blog/latest", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
limit := 5
if q := r.URL.Query().Get("limit"); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 {
limit = n
}
}
items, err := listLatestBlogs(staticPath(), limit)
if err != nil {
log.Printf("listLatestBlogs error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(items)
})
// Videos API
mux.HandleFunc("/api/videos/latest", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
switch r.Method {
case http.MethodGet:
vc.mu.RLock()
items := vc.data.Items
fetched := vc.data.FetchedAt
channel := vc.data.Channel
vc.mu.RUnlock()
if len(items) == 0 {
// lazy refresh if empty
if err := refreshVideos(r.Context()); err != nil {
log.Printf("refreshVideos error: %v", err)
}
vc.mu.RLock()
items = vc.data.Items
fetched = vc.data.FetchedAt
channel = vc.data.Channel
vc.mu.RUnlock()
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Items []YTVideo `json:"items"`
}{FetchedAt: fetched, Channel: channel, Items: items})
case http.MethodPost:
// rate limit: 5 requests per minute for manual refresh
if !videosPostLimiter.Allow(time.Now(), 5, time.Minute) {
http.Error(w, "rate limit: max 5 refresh per minute", http.StatusTooManyRequests)
return
}
if err := refreshVideos(r.Context()); err != nil {
log.Printf("manual refreshVideos error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
// Serve raw persisted videos json for debugging/preview
mux.HandleFunc("/data/videos.json", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
b, err := os.ReadFile(videosPath())
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write(b)
})
// Blog creation API (admin)
mux.HandleFunc("/api/blog/new", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if !checkBasicAuth(r) {
requireBasicAuth(w)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.FormValue("title"))
slugInput := strings.TrimSpace(r.FormValue("slug"))
annotation := strings.TrimSpace(r.FormValue("annotation"))
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
htmlContent := strings.TrimSpace(r.FormValue("content"))
catsRaw := strings.TrimSpace(r.FormValue("categories"))
var cats []string
if catsRaw != "" {
for _, p := range strings.Split(catsRaw, ",") {
c := strings.TrimSpace(p)
if c != "" {
cats = append(cats, c)
}
}
}
if title == "" || htmlContent == "" {
http.Error(w, "missing title or content", http.StatusBadRequest)
return
}
// Default content mode to visual if not specified
if contentMode == "" {
contentMode = "visual"
}
f, fh, err := r.FormFile("image")
if err != nil {
http.Error(w, "missing image", http.StatusBadRequest)
return
}
defer f.Close()
// Accept PNG/JPG/JPEG; always store normalized PNG 1600x969
name := strings.ToLower(fh.Filename)
if !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")) {
http.Error(w, "image must be .png, .jpg, or .jpeg", http.StatusBadRequest)
return
}
site := staticPath()
// Determine next ID
nid, err := nextBlogID(site)
if err != nil {
http.Error(w, "failed to compute next id", http.StatusInternalServerError)
return
}
idStr := fmt.Sprintf("%04d", nid)
// Determine slug
var finalSlug string
if slugInput != "" {
// Use provided slug (already validated by frontend)
finalSlug = slugInput
} else {
// Generate from title
baseSlug := generateSlug(title)
finalSlug = ensureUniqueSlug(site, baseSlug)
}
// Write image (normalize to 1600x969 with letterboxing)
imgDir := filepath.Join(site, "img", "blog")
if err := os.MkdirAll(imgDir, 0755); err != nil {
http.Error(w, "storage error: img dir", http.StatusInternalServerError)
return
}
imgPath := filepath.Join(imgDir, idStr+".png")
if err := normalizeBlogImage(f, imgPath); err != nil {
http.Error(w, "image processing failed", http.StatusInternalServerError)
return
}
// Read template and replace
tplPath := filepath.Join(site, "blog", "0030.html")
tplBytes, err := os.ReadFile(tplPath)
if err != nil {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
s := string(tplBytes)
// Replace H1 title inside page header (match when lte-header is among multiple classes)
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="[^"]*\blte-header\b[^"]*"[^>]*>.*?</h1>`)
s = reH1.ReplaceAllString(s, "<h1 class=\"lte-header\">"+htmlEscape(title)+"</h1>")
// Replace main hero image to point to new id
reImg := regexp.MustCompile(`(?is)src=\"\.\./img/blog/\d{4}\.png\"`)
s = reImg.ReplaceAllString(s, "src=\"../img/blog/"+idStr+".png\"")
// Replace post top image similarly if found with different quoting
reImg2 := regexp.MustCompile(`(?is)src=\"\.\./img/blog/\d{4}\.png\"`)
s = reImg2.ReplaceAllString(s, "src=\"../img/blog/"+idStr+".png\"")
// Replace the main content inside <div class="text lte-text-page clearfix"> ... </div>
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">[\s\S]*?</div>`)
s = reContent.ReplaceAllString(s, "<div class=\"text lte-text-page clearfix\">\n"+htmlContent+"\n</div>")
// Inject categories as <meta name="category" content="..."> before </head>
if len(cats) > 0 {
var meta string
for _, c := range cats {
meta += "<meta name=\"category\" content=\"" + htmlEscape(c) + "\">\n"
}
reHead := regexp.MustCompile(`(?is)</head>`)
s = reHead.ReplaceAllString(s, meta+"</head>")
}
// Inject slug as <meta name="slug" content="..."> before </head>
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(finalSlug) + "\">\n"
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
// Inject annotation as <meta name="description" content="..."> before </head>
if annotation != "" {
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
}
// Inject content mode as <meta name="content_mode" content="..."> before </head>
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
// Inject Rybbit analytics script before </head>
rybbitScript := "<script src=\"https://rybbit.tdvorak.dev/api/script.js\" data-site-id=\"d40b7ffffffa\" defer></script>\n"
reHeadRybbit := regexp.MustCompile(`(?is)</head>`)
s = reHeadRybbit.ReplaceAllString(s, rybbitScript+"</head>")
// Write new blog html with both numeric and slug filenames
blogDir := filepath.Join(site, "blog")
if err := os.MkdirAll(blogDir, 0755); err != nil {
http.Error(w, "storage error: blog dir", http.StatusInternalServerError)
return
}
// Write numeric file (for backward compatibility)
htmlPath := filepath.Join(blogDir, idStr+".html")
if err := os.WriteFile(htmlPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
return
}
// Write slug file (new format)
slugPath := filepath.Join(blogDir, finalSlug+".html")
if err := os.WriteFile(slugPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write slug blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": idStr,
"slug": finalSlug,
"link": "/blog/" + finalSlug,
"image": "/img/blog/" + idStr + ".png",
"message": "created",
})
})
// Blog slug resolution: convert slug to numeric ID
mux.HandleFunc("/api/blog/resolve", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
http.Error(w, "missing slug", http.StatusBadRequest)
return
}
// Validate slug format
if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(slug) {
http.Error(w, "invalid slug format", http.StatusBadRequest)
return
}
site := staticPath()
blogDir := filepath.Join(site, "blog")
// First try to find slug-based file
slugPath := filepath.Join(blogDir, slug+".html")
if b, err := os.ReadFile(slugPath); err == nil {
s := string(b)
// Try to find the corresponding numeric file
entries, _ := os.ReadDir(blogDir)
for _, e := range entries {
name := e.Name()
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
continue
}
numericPath := filepath.Join(blogDir, name)
numericContent, _ := os.ReadFile(numericPath)
if string(numericContent) == s {
// Found matching numeric file
numericID := strings.TrimSuffix(name, ".html")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"id": numericID, "slug": slug})
return
}
}
}
// If not found, return 404
http.Error(w, "slug not found", http.StatusNotFound)
})
// Blog fetch (admin): returns title, content html, image for editing
mux.HandleFunc("/api/blog/get", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if !checkBasicAuth(r) {
requireBasicAuth(w)
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
re := regexp.MustCompile(`^\d{4}$`)
if !re.MatchString(id) {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
path := filepath.Join(staticPath(), "blog", id+".html")
b, err := os.ReadFile(path)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s := string(b)
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="[^"]*\blte-header\b[^"]*"[^>]*>(.*?)</h1>`)
m := reH1.FindStringSubmatch(s)
title := ""
if len(m) >= 2 {
// strip HTML tags inside, if any
inner := m[1]
// remove any nested tags crudely
inner = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(inner, "")
inner = strings.TrimSpace(inner)
title = inner
}
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">([\s\S]*?)</div>`)
mc := reContent.FindStringSubmatch(s)
content := ""
if len(mc) >= 2 {
content = strings.TrimSpace(mc[1])
}
cats := extractCategories(path)
slug := extractSlug(path, id+".html")
annotation := extractAnnotation(path)
contentMode := extractContentMode(path)
img := "/img/blog/" + id + ".png"
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "title": title, "slug": slug, "annotation": annotation, "content_mode": contentMode, "content_html": content, "image": img, "categories": cats})
})
// Blog edit (admin): update title/content and optionally replace image
mux.HandleFunc("/api/blog/edit", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if !checkBasicAuth(r) {
requireBasicAuth(w)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
if err := r.ParseMultipartForm(25 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
id := strings.TrimSpace(r.FormValue("id"))
re := regexp.MustCompile(`^\d{4}$`)
if !re.MatchString(id) {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.FormValue("title"))
slugInput := strings.TrimSpace(r.FormValue("slug"))
annotation := strings.TrimSpace(r.FormValue("annotation"))
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
htmlContent := strings.TrimSpace(r.FormValue("content"))
catsRaw := strings.TrimSpace(r.FormValue("categories"))
var cats []string
if catsRaw != "" {
for _, p := range strings.Split(catsRaw, ",") {
c := strings.TrimSpace(p)
if c != "" {
cats = append(cats, c)
}
}
}
if title == "" || htmlContent == "" {
http.Error(w, "missing title or content", http.StatusBadRequest)
return
}
// Default content mode to visual if not specified
if contentMode == "" {
contentMode = "visual"
}
site := staticPath()
if f, fh, err := r.FormFile("image"); err == nil {
defer f.Close()
// Accept PNG/JPG/JPEG; always store normalized PNG 1600x969
name := strings.ToLower(fh.Filename)
if !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")) {
http.Error(w, "image must be .png, .jpg, or .jpeg", http.StatusBadRequest)
return
}
imgPath := filepath.Join(site, "img", "blog", id+".png")
if err := os.MkdirAll(filepath.Dir(imgPath), 0755); err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
if err := normalizeBlogImage(f, imgPath); err != nil {
http.Error(w, "image processing failed", http.StatusInternalServerError)
return
}
}
hPath := filepath.Join(site, "blog", id+".html")
b, err := os.ReadFile(hPath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s := string(b)
reH1 := regexp.MustCompile(`(?is)<h1[^>]*class="lte-header"[^>]*>.*?</h1>`)
s = reH1.ReplaceAllString(s, "<h1 class=\"lte-header\">"+htmlEscape(title)+"</h1>")
reContent := regexp.MustCompile(`(?is)<div class="text lte-text-page clearfix">[\s\S]*?</div>`)
s = reContent.ReplaceAllString(s, "<div class=\"text lte-text-page clearfix\">\n"+htmlContent+"\n</div>")
// Replace categories meta tags
reMeta := regexp.MustCompile(`(?is)<meta name="category" content="[^"]*"\s*/?>\s*`)
s = reMeta.ReplaceAllString(s, "")
if len(cats) > 0 {
var meta string
for _, c := range cats {
meta += "<meta name=\"category\" content=\"" + htmlEscape(c) + "\">\n"
}
reHead := regexp.MustCompile(`(?is)</head>`)
s = reHead.ReplaceAllString(s, meta+"</head>")
}
// Update or add slug meta tag
reSlug := regexp.MustCompile(`(?is)<meta name="slug" content="[^"]*"\s*/?>\s*`)
s = reSlug.ReplaceAllString(s, "")
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(slugInput) + "\">\n"
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
// Update or add annotation meta tag
reAnnotation := regexp.MustCompile(`(?is)<meta name="description" content="[^"]*"\s*/?>\s*`)
s = reAnnotation.ReplaceAllString(s, "")
if annotation != "" {
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
}
// Update or add content mode meta tag
reContentMode := regexp.MustCompile(`(?is)<meta name="content_mode" content="[^"]*"\s*/?>\s*`)
s = reContentMode.ReplaceAllString(s, "")
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
// Ensure Rybbit analytics script is present (idempotent: remove existing first, then add)
reRybbit := regexp.MustCompile(`(?is)<script[^>]*src="https://rybbit\.tdvorak\.dev/api/script\.js"[^>]*>\s*</script>\s*`)
s = reRybbit.ReplaceAllString(s, "")
rybbitScript := "<script src=\"https://rybbit.tdvorak.dev/api/script.js\" data-site-id=\"d40b7ffffffa\" defer></script>\n"
reHeadRybbit := regexp.MustCompile(`(?is)</head>`)
s = reHeadRybbit.ReplaceAllString(s, rybbitScript+"</head>")
if err := os.WriteFile(hPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
// Blog delete (admin)
mux.HandleFunc("/api/blog/delete", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if !checkBasicAuth(r) {
requireBasicAuth(w)
return
}
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
// Accept numeric ID (0001) or slug (my-post-title)
validID := regexp.MustCompile(`^\d{4}$`).MatchString(id) || regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(id)
if !validID {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
site := staticPath()
blogDir := filepath.Join(site, "blog")
// If numeric ID, also find and delete the slug file
if regexp.MustCompile(`^\d{4}$`).MatchString(id) {
// Delete numeric HTML file
numericPath := filepath.Join(blogDir, id+".html")
// Find slug before deleting
if content, err := os.ReadFile(numericPath); err == nil {
slug := extractSlugFromContent(string(content))
if slug != "" && slug != id {
_ = os.Remove(filepath.Join(blogDir, slug+".html"))
}
}
_ = os.Remove(numericPath)
// Delete image
_ = os.Remove(filepath.Join(site, "img", "blog", id+".png"))
} else {
// It's a slug - find the numeric ID and delete both
entries, _ := os.ReadDir(blogDir)
for _, e := range entries {
name := e.Name()
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
continue
}
numericID := strings.TrimSuffix(name, ".html")
numericPath := filepath.Join(blogDir, name)
if content, err := os.ReadFile(numericPath); err == nil {
fileSlug := extractSlugFromContent(string(content))
if fileSlug == id {
// Found matching numeric file, delete both
_ = os.Remove(numericPath)
_ = os.Remove(filepath.Join(site, "img", "blog", numericID+".png"))
_ = os.Remove(filepath.Join(blogDir, id+".html"))
break
}
}
}
}
w.WriteHeader(http.StatusNoContent)
})
// Static file server for the frontend
sp := staticPath()
log.Printf("serving static from: %s", sp)
fs := http.FileServer(http.Dir(sp))
// Serve common asset prefixes explicitly
mux.Handle("/img/", fs)
mux.Handle("/css/", fs)
mux.Handle("/js/", fs)
// Protect /admin/ with Basic Auth
mux.Handle("/admin/", basicAuth(http.StripPrefix("/admin/", http.FileServer(http.Dir(filepath.Join(staticPath(), "admin"))))))
// Custom slug-based blog routing
mux.HandleFunc("/blog/", func(w http.ResponseWriter, r *http.Request) {
// Extract the slug/ID from the path
path := strings.TrimPrefix(r.URL.Path, "/blog/")
if path == "" || path == "/" {
// Serve the main blog listing page
http.ServeFile(w, r, filepath.Join(sp, "blog.html"))
return
}
// Remove .html extension if present
if strings.HasSuffix(path, ".html") {
path = strings.TrimSuffix(path, ".html")
}
// Check if it's a numeric ID (legacy format)
if regexp.MustCompile(`^\d{4}$`).MatchString(path) {
// Serve numeric file directly
numericPath := filepath.Join(sp, "blog", path+".html")
if _, err := os.Stat(numericPath); err == nil {
http.ServeFile(w, r, numericPath)
return
}
}
// Check if it's a slug (new format)
if regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(path) {
slugPath := filepath.Join(sp, "blog", path+".html")
if _, err := os.Stat(slugPath); err == nil {
http.ServeFile(w, r, slugPath)
return
}
// If slug file doesn't exist, try to resolve slug to numeric file
blogDir := filepath.Join(sp, "blog")
entries, err := os.ReadDir(blogDir)
if err == nil {
for _, entry := range entries {
name := entry.Name()
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
continue
}
numericPath := filepath.Join(blogDir, name)
// Check if this numeric file has the matching slug
b, err := os.ReadFile(numericPath)
if err != nil {
continue
}
s := string(b)
re := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 && m[1] == path {
http.ServeFile(w, r, numericPath)
return
}
}
}
}
// If not found, serve 404
http.NotFound(w, r)
})
mux.Handle("/zapasy/", fs)
// Fallback: serve index.html at root, otherwise delegate to static file server
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, filepath.Join(sp, "index.html"))
return
}
fs.ServeHTTP(w, r)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
}
go func() {
log.Printf("server listening on :%s", port)
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, POST, PUT, 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
var table ClubTable
var activeClubID string
// Try primary API first
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
log.Printf("primary api detail failed (%v), trying futsalliga scrape", err)
// Second source: scrape the official league site (current data, correct competition)
if comb, serr := scrapeFutsalLiga(ctx, client); serr == nil {
c.mu.Lock()
c.data = comb
c.mu.Unlock()
if werr := writeDiskJSON(c.data); werr != nil {
log.Printf("warn: write disk json: %v", werr)
}
log.Printf("refreshed data: comps=%d source=%s", len(comb.ClubDetail.Competitions), "futsalliga")
return nil
} else {
log.Printf("futsalliga scrape failed (%v), trying flashscore fallback", serr)
}
urlDetail = fmt.Sprintf("%s/club/%s/%s?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
return fmt.Errorf("fallback detail: %w", err)
}
if err := getJSON(ctx, client, urlTable, &table); err != nil {
return fmt.Errorf("fallback table: %w", err)
}
activeClubID = fallbackClubID
} else {
if err := getJSON(ctx, client, urlTable, &table); err != nil {
return fmt.Errorf("table: %w", err)
}
activeClubID = clubID
}
// 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 == activeClubID {
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
}
if detail.Competitions[i].Matches[j].AwayID == activeClubID {
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 == activeClubID {
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 source=%s", len(detail.Competitions), map[bool]string{true: "fallback", false: "primary"}[activeClubID == fallbackClubID])
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
}