From 6dd456ca4a6427182e8ef6fcc89d1c852fad68fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20M=C3=BChlinghaus?= Date: Sun, 12 Jul 2026 02:49:54 +0200 Subject: [PATCH] Erstes Commit --- .env.example | 4 + .gitignore | 4 + AGENTS.md | 102 +++++++++++++++++++ Makefile | 32 ++++++ PLAN.md | 138 ++++++++++++++++++++++++++ go.mod | 3 + internal/app.go | 70 +++++++++++++ internal/formatter/json.go | 27 +++++ internal/formatter/relative.go | 41 ++++++++ internal/formatter/relative_test.go | 39 ++++++++ internal/formatter/table.go | 33 +++++++ internal/pocketbase/client.go | 147 ++++++++++++++++++++++++++++ internal/pocketbase/client_test.go | 65 ++++++++++++ internal/royalroad/scraper.go | 121 +++++++++++++++++++++++ internal/royalroad/scraper_test.go | 94 ++++++++++++++++++ 15 files changed, 920 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 PLAN.md create mode 100644 go.mod create mode 100644 internal/app.go create mode 100644 internal/formatter/json.go create mode 100644 internal/formatter/relative.go create mode 100644 internal/formatter/relative_test.go create mode 100644 internal/formatter/table.go create mode 100644 internal/pocketbase/client.go create mode 100644 internal/pocketbase/client_test.go create mode 100644 internal/royalroad/scraper.go create mode 100644 internal/royalroad/scraper_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd4399a --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PB_URL=https://www.alphabreed.com/api +PB_USER=your-username +PB_PASSWORD=your-password +PB_CATEGORY=ztvvxqod5s899fk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb88fee --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +royalroadupdates +.idea/ +.pi-subagents diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ee663a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# Agent Instructions — `royalroadupdates` + +## Project Purpose +A small, self-contained Go CLI tool that checks RoyalRoad stories from a +PocketBase bookmarks collection and reports their last-update times. + +## Repository Layout + +``` +. +├── cmd/royalroadupdates/ Entry point and integration tests +│ ├── main.go - CLI bootstrap, flag parsing (--json) +│ └── e2e_test.go - End-to-end test: compiles binary, runs it, +│ asserts JSON shape and entry count +├── internal/ +│ ├── pocketbase/ +│ │ └── client.go - Authenticates and queries the +│ │ /api/collections/bookmarks/records endpoint. +│ │ Accepts filter, perPage, and field params. +│ │ Returns a list of structs with URL fields. +│ ├── royalroad/ +│ │ └── scraper.go - Given a URL, fetches the page, confirms it +│ │ is a RoyalRoad fiction page, extracts the +│ │ JSON-LD script block, parses name and +│ │ dateModified into a struct. +│ │ Returns zero-value + error for any failure +│ │ so the caller can skip silently. +│ ├── formatter/ +│ │ ├── table.go - Renders []Story as a plain-text aligned +│ │ │ table with relative-time "Last Update" +│ │ │ column. +│ │ ├── json.go - Renders []Story as JSON (used with --json). +│ │ └── relative.go - Computes "X minutes/hours/days/months/years +│ │ ago" strings from time.Time. +│ └── app.go - Orchestrates the full pipeline: +│ 1. Load credentials (compile-time vars). +│ 2. Query PocketBase for URLs. +│ 3. Iterate URLs sequentially. +│ 4. Collect valid RoyalRoad results. +│ 5. Sort by dateModified descending. +│ 6. Dispatch to appropriate formatter. +├── .env - Real credentials (gitignored). +├── .env.example - Template showing required env vars. +├── .gitignore - Must include .env and the built binary. +├── Makefile - All build, test, and check targets. +├── go.mod - Standard Go module file. +└── go.sum - Standard Go checksum file. +``` + +## Code Style Rules + +- **Formatting:** All Go source files must pass `gofmt`. The `make check` target + will reject any diff. +- **Imports:** Group standard library first, then third-party (none expected), + then local/internal. `goimports` ordering is acceptable. +- **Naming:** Exported identifiers use PascalCase; unexported use camelCase. + Acronyms like HTTP, URL, JSON keep all-caps when exported (`FetchURL`, + `ParseJSON`). +- **Error handling:** Always check errors explicitly. Do not swallow errors + silently unless the design calls for silent skipping (RoyalRoad per-item + failures). Return errors wrapped with `fmt.Errorf("...: %w", err)` for + context. +- **Comments:** Every exported type and function has a concise GoDoc comment. + Internal helpers may omit comments if their purpose is obvious from the name. +- **No globals:** Avoid package-level mutable state. Pass dependencies (HTTP + client, configuration) as struct fields or constructor parameters. +- **HTTP client reuse:** Create one `http.Client` with a configured timeout and + share it across requests. Do not use `http.DefaultClient` unwrapped. + +## Build Rules + +- **No local Go installation.** Use `make` targets which wrap + `docker run --rm -v $(pwd):/src -w /src golang:1.22`. +- **Credential injection:** The Makefile reads `.env` and passes values as + `-ldflags '-X main.PB_URL=... -X main.PB_USER=... -X main.PB_PASSWORD=...'`. +- **Output:** The compiled binary is written to `./royalroadupdates` in the + repository root. +- **Targets:** + - `make build` — compile the binary. + - `make check` — full quality loop (format, vet, test, e2e test). + - `make clean` — remove the compiled binary. + +## Testing Rules + +- **Unit tests:** Cover pure helpers (e.g., `relative.go`, URL filtering logic) + with table-driven tests. Use `t.Parallel()` where independent. +- **End-to-end test (`e2e_test.go`):** + - Uses the `.env` credentials and hits real services. + - Compiles the binary via `os/exec` so it tests the actual build path. + - Runs `./royalroadupdates --json`. + - Assertions: + 1. Exit code 0. + 2. Stderr is empty. + 3. Stdout is valid JSON. + 4. JSON is a non-empty array. + 5. Array contains at least 3 entries. + 6. Each entry has a non-empty `name` and a parseable `date`. + - Since this test hits live services, it is inherently non-deterministic in + data content but deterministic in shape. Treat network/Cloudflare flakiness + as a signal to tune delays rather than a code defect. +- **Fix-and-rerun cycle:** If `make check` fails at any step, fix the root cause + and rerun `make check` until all gates pass. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f540180 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# Makefile for royalroadupdates — builds entirely inside Docker. + +PWD := $(shell pwd) +DOCKER_RUN := docker run --rm -v $(PWD):/src -w /src golang:1.22 + +.PHONY: build check clean + +build: + $(DOCKER_RUN) sh -c '\ + set -e; \ + if [ -f .env ]; then . ./.env; fi; \ + if [ -z "$$PB_URL" ] || [ -z "$$PB_USER" ] || [ -z "$$PB_PASSWORD" ] || [ -z "$$PB_CATEGORY" ]; then \ + echo "error: .env is missing or one of PB_URL / PB_USER / PB_PASSWORD / PB_CATEGORY is not set"; \ + exit 1; \ + fi; \ + go build \ + -ldflags "-X main.PBURL=$${PB_URL} -X main.PBUser=$${PB_USER} -X main.PBPassword=$${PB_PASSWORD} -X main.PBCategory=$${PB_CATEGORY}" \ + -o royalroadupdates \ + ./cmd/royalroadupdates' + +check: + $(DOCKER_RUN) sh -c '\ + set -e; \ + if [ -f .env ]; then . ./.env; fi; \ + export PB_URL PB_USER PB_PASSWORD PB_CATEGORY; \ + d=$$(gofmt -l .); \ + if [ -n "$$d" ]; then echo "format errors:"; echo "$$d"; exit 1; fi; \ + go vet ./...; \ + go test ./...' + +clean: + rm -f royalroadupdates diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..59aafab --- /dev/null +++ b/PLAN.md @@ -0,0 +1,138 @@ +# RoyalRoad Updates — Project Plan + +## Overview + +A Go CLI tool that reads RoyalRoad story URLs from a PocketBase collection, fetches each +story's last-update time from RoyalRoad, and prints a sorted plain-text table showing +story names and how recently each was updated. + +## Behavior + +1. Authenticate to PocketBase using credentials baked in at compile time. +2. Query the `bookmarks` collection, filtering `category = "Geschichten"`. +3. Extract the `url` field from each matching record. +4. For each URL, sequentially fetch the RoyalRoad fiction page. +5. Extract the fiction name and `dateModified` from the embedded JSON-LD + `` + bodyStr := string(body) + + startIndex := strings.Index(bodyStr, startTag) + if startIndex == -1 { + return Story{}, fmt.Errorf("JSON-LD script block not found") + } + startIndex += len(startTag) + + endIndex := strings.Index(bodyStr[startIndex:], endTag) + if endIndex == -1 { + return Story{}, fmt.Errorf("JSON-LD closing tag not found") + } + endIndex += startIndex + + jsonContent := body[startIndex:endIndex] + var ld jsonLD + if err := json.Unmarshal(jsonContent, &ld); err != nil { + return Story{}, fmt.Errorf("failed to decode JSON: %w", err) + } + + if ld.Name == "" { + return Story{}, fmt.Errorf("story name missing in JSON-LD") + } + + date, err := time.Parse(time.RFC3339, ld.DateModified) + if err != nil { + return Story{}, fmt.Errorf("invalid date format %q: %w", ld.DateModified, err) + } + + return Story{ + Name: ld.Name, + DateModified: date, + }, nil +} diff --git a/internal/royalroad/scraper_test.go b/internal/royalroad/scraper_test.go new file mode 100644 index 0000000..87cd2eb --- /dev/null +++ b/internal/royalroad/scraper_test.go @@ -0,0 +1,94 @@ +package royalroad + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestParseJSONLD(t *testing.T) { + tests := []struct { + name string + body string + want Story + wantErr bool + }{ + { + name: "Success", + body: ``, + want: Story{ + Name: "Test Story", + DateModified: time.Date(2023, 7, 6, 17, 47, 41, 0, time.UTC), + }, + wantErr: false, + }, + { + name: "Missing Script", + body: `No JSON-LD here`, + wantErr: true, + }, + { + name: "Malformed JSON", + body: ``, + wantErr: true, + }, + { + name: "Missing Name", + body: ``, + wantErr: true, + }, + { + name: "Invalid Date", + body: ``, + wantErr: true, + }, + } + + s := NewScraper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := s.parseJSONLD([]byte(tt.body)) + if (err != nil) != tt.wantErr { + t.Errorf("parseJSONLD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("parseJSONLD() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchStory_NonRoyalRoadHost(t *testing.T) { + s := NewScraper() + _, err := s.FetchStory(context.Background(), "https://google.com/fiction/123") + if err == nil { + t.Error("Expected error for non-RoyalRoad host, got nil") + } +} + +func TestFetchStory_ChapterURL(t *testing.T) { + s := NewScraper() + _, err := s.FetchStory(context.Background(), "https://www.royalroad.com/fiction/123/chapter/456") + if err == nil { + t.Error("Expected error for chapter URL, got nil") + } +} + +func TestFetchStory_HTTPError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + s := NewScraper() + // We can't easily mock the host check for the server URL without custom logic, + // but we can verify that a non-RR URL (the test server) fails at the host check + // before it even hits the server. + _, err := s.FetchStory(context.Background(), ts.URL+"/fiction/123") + if err == nil { + t.Error("Expected error for non-RoyalRoad host, got nil") + } +}