diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 13:07:26 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 13:07:26 +0200 |
| commit | a8170d4032c6afa175d887b414e6799f088f070c (patch) | |
| tree | 84ea8f2b7585215201b777abe028b123029df213 | |
| parent | e240d4c03cc30b583a0811553580fe2a15cd1097 (diff) | |
| download | lectio-a8170d4032c6afa175d887b414e6799f088f070c.tar.gz lectio-a8170d4032c6afa175d887b414e6799f088f070c.zip | |
liturgy: fetch + HTML/JSON cache
| -rw-r--r-- | internal/liturgy/fetch.go | 154 | ||||
| -rw-r--r-- | internal/liturgy/fetch_test.go | 32 |
2 files changed, 186 insertions, 0 deletions
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go new file mode 100644 index 0000000..1b1458f --- /dev/null +++ b/internal/liturgy/fetch.go @@ -0,0 +1,154 @@ +package liturgy + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "time" +) + +// baseURL is the niedziela.pl URL template ("%s" is the date, YYYY-MM-DD). +// It is a package var so tests can point it at an httptest server. +var baseURL = "https://niezbednik.niedziela.pl/liturgia/%s/Ewangelia" + +// userAgent is sent on every fetch; the site serves a different (broken) +// page to non-browser clients without it. +const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" + +// publishedRe matches the tab-pane id niedziela.pl gives a fully published +// day's page (e.g. "tabnowy0all"). Its absence -- together with a "Przykro +// nam" placeholder -- marks a date that has not been published yet; see +// ewangelia.py's fetch() for the original behaviour this mirrors. +var publishedRe = regexp.MustCompile(`id="\w*0all"`) + +// Options controls how Load resolves a day's readings. +type Options struct { + // Date is the day to load, formatted YYYY-MM-DD. + Date string + // Refresh bypasses both cache layers and re-fetches from the network. + Refresh bool + // Offline restricts Load to previously cached/harvested data, never + // hitting the network. Its behavior is added in a later task; for now + // it is a no-op on the modern-source path. + Offline bool +} + +// cacheDir is where the HTML/JSON cache layers live: +// ${XDG_CACHE_HOME:-~/.cache}/lectio/ +func cacheDir() string { + base := os.Getenv("XDG_CACHE_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + base = filepath.Join(home, ".cache") + } + return filepath.Join(base, "lectio") +} + +// Load returns the day's reading sections, from the modern (niedziela.pl) +// source, preferring cache over network: +// +// 1. Unless Refresh, the parsed JSON cache ({date}.json). +// 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed, and the +// result written to the JSON cache for next time. +// 3. Otherwise, fetch the page over the network, parse it, and -- only if +// the page is fully published -- write both cache layers. +func Load(opts Options) ([]Section, error) { + dir := cacheDir() + jsonPath := filepath.Join(dir, opts.Date+".json") + htmlPath := filepath.Join(dir, opts.Date+".html") + + if !opts.Refresh { + if secs, err := loadJSONCache(jsonPath); err == nil { + return secs, nil + } + if page, err := os.ReadFile(htmlPath); err == nil { + secs, err := Parse(string(page)) + if err != nil { + return nil, err + } + writeJSONCache(jsonPath, secs) + return secs, nil + } + } + + page, err := fetch(opts.Date) + if err != nil { + return nil, err + } + + // Parse itself reports an unpublished date as "no reading published for + // this date yet" (via the "Przykro nam" placeholder), so an unpublished + // page is neither cached nor returned as sections here. + secs, err := Parse(page) + if err != nil { + return nil, err + } + + // Cache only fully-published pages, so an as-yet-unpublished future date + // keeps being retried instead of caching a "no reading" placeholder. + if publishedRe.MatchString(page) { + if err := os.MkdirAll(dir, 0o755); err == nil { + _ = os.WriteFile(htmlPath, []byte(page), 0o644) + writeJSONCache(jsonPath, secs) + } + } + + return secs, nil +} + +// loadJSONCache reads and unmarshals the parsed-sections cache file. +func loadJSONCache(path string) ([]Section, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var secs []Section + if err := json.Unmarshal(data, &secs); err != nil { + return nil, err + } + return secs, nil +} + +// writeJSONCache best-effort writes the parsed sections cache; a failure to +// cache should never fail the load itself. +func writeJSONCache(path string, secs []Section) { + data, err := json.Marshal(secs) + if err != nil { + return + } + _ = os.WriteFile(path, data, 0o644) +} + +// fetch GETs the day's page from baseURL with the browser User-Agent. +// Whether the page is actually published is left to the caller (Parse +// detects an unpublished date; Load re-checks the tab id to decide whether +// the result is cache-worthy). +func fetch(dateStr string) (string, error) { + url := fmt.Sprintf(baseURL, dateStr) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch %s: %w", url, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response from %s: %w", url, err) + } + return string(body), nil +} diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go new file mode 100644 index 0000000..ae8a68c --- /dev/null +++ b/internal/liturgy/fetch_test.go @@ -0,0 +1,32 @@ +package liturgy + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestLoadCaches(t *testing.T) { + html, _ := os.ReadFile("testdata/2026-06-22.html") + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Write(html) + })) + defer srv.Close() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + baseURL = srv.URL + "/liturgia/%s/Ewangelia" // test hook + + secs1, err := Load(Options{Date: "2026-06-22"}) + if err != nil || len(secs1) == 0 { + t.Fatalf("load1: %v", err) + } + secs2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache + if hits != 1 { + t.Errorf("server hit %d times, want 1 (cache miss on repeat)", hits) + } + if len(secs2) != len(secs1) { + t.Error("cache returned different section count") + } +} |
