Erstes Commit

This commit is contained in:
2026-07-12 02:49:54 +02:00
commit 6dd456ca4a
15 changed files with 920 additions and 0 deletions

70
internal/app.go Normal file
View File

@@ -0,0 +1,70 @@
package internal
import (
"context"
"fmt"
"io"
"os"
"sort"
"royalroadupdates/internal/formatter"
"royalroadupdates/internal/pocketbase"
"royalroadupdates/internal/royalroad"
)
// Config holds the compile-time settings for the application.
type Config struct {
PBURL string
PBUser string
PBPassword string
PBCategory string
}
// App orchestrates the full pipeline.
type App struct {
pocketbase *pocketbase.Client
scraper *royalroad.Scraper
outW io.Writer
errW io.Writer
}
// New creates a new App with the given configuration.
func New(cfg Config) *App {
return &App{
pocketbase: pocketbase.New(cfg.PBURL, cfg.PBUser, cfg.PBPassword, cfg.PBCategory),
scraper: royalroad.NewScraper(),
outW: os.Stdout,
errW: os.Stderr,
}
}
// Run executes the full pipeline and outputs results.
func (a *App) Run(ctx context.Context, jsonOutput bool) error {
urls, err := a.pocketbase.GetBookmarkURLs(ctx)
if err != nil {
return fmt.Errorf("fetching bookmarks: %w", err)
}
var results []royalroad.Story
for _, u := range urls {
story, err := a.scraper.FetchStory(ctx, u)
if err != nil {
continue // silently skip
}
results = append(results, story)
}
sort.Slice(results, func(i, j int) bool {
return results[i].DateModified.After(results[j].DateModified)
})
if jsonOutput {
if err := formatter.PrintJSON(a.outW, results); err != nil {
return fmt.Errorf("encoding JSON: %w", err)
}
} else {
formatter.PrintTable(a.outW, results)
}
return nil
}

View File

@@ -0,0 +1,27 @@
package formatter
import (
"encoding/json"
"io"
"royalroadupdates/internal/royalroad"
)
// jsonEntry is the JSON output shape.
type jsonEntry struct {
Name string `json:"name"`
Date string `json:"date"`
}
// PrintJSON writes stories as JSON to w.
func PrintJSON(w io.Writer, stories []royalroad.Story) error {
entries := make([]jsonEntry, 0, len(stories))
for _, s := range stories {
entries = append(entries, jsonEntry{
Name: s.Name,
Date: s.DateModified.UTC().Format("2006-01-02T15:04:05Z"),
})
}
enc := json.NewEncoder(w)
return enc.Encode(entries)
}

View File

@@ -0,0 +1,41 @@
package formatter
import (
"fmt"
"time"
)
// Relative returns a human-friendly relative time string like "3 hours ago".
func Relative(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
m := int(d.Minutes())
return pluralize(m, "minute") + " ago"
}
if d < 24*time.Hour {
h := int(d.Hours())
return pluralize(h, "hour") + " ago"
}
if d < 30*24*time.Hour {
day := int(d.Hours() / 24)
return pluralize(day, "day") + " ago"
}
if d < 365*24*time.Hour {
month := int(d.Hours() / 24 / 30)
return pluralize(month, "month") + " ago"
}
year := int(d.Hours() / 24 / 365)
return pluralize(year, "year") + " ago"
}
func pluralize(n int, unit string) string {
if n == 1 {
return fmt.Sprintf("1 %s", unit)
}
return fmt.Sprintf("%d %ss", n, unit)
}

View File

@@ -0,0 +1,39 @@
package formatter
import (
"testing"
"time"
)
func TestRelative(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
t time.Time
want string
}{
{"just now", now.Add(-5 * time.Second), "just now"},
{"1 minute ago", now.Add(-1 * time.Minute), "1 minute ago"},
{"5 minutes ago", now.Add(-5 * time.Minute), "5 minutes ago"},
{"1 hour ago", now.Add(-1 * time.Hour), "1 hour ago"},
{"3 hours ago", now.Add(-3 * time.Hour), "3 hours ago"},
{"1 day ago", now.Add(-24 * time.Hour), "1 day ago"},
{"2 days ago", now.Add(-48 * time.Hour), "2 days ago"},
{"1 month ago", now.Add(-30 * 24 * time.Hour), "1 month ago"},
{"3 months ago", now.Add(-90 * 24 * time.Hour), "3 months ago"},
{"1 year ago", now.Add(-365 * 24 * time.Hour), "1 year ago"},
{"2 years ago", now.Add(-730 * 24 * time.Hour), "2 years ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Relative(tt.t)
if got != tt.want {
t.Errorf("Relative() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,33 @@
package formatter
import (
"fmt"
"io"
"strings"
"royalroadupdates/internal/royalroad"
)
// PrintTable writes a plain-text aligned table of stories to w.
func PrintTable(w io.Writer, stories []royalroad.Story) {
if len(stories) == 0 {
return
}
nameHeader := "Story Name"
updateHeader := "Last Update"
maxNameLen := len(nameHeader)
for _, s := range stories {
if l := len(s.Name); l > maxNameLen {
maxNameLen = l
}
}
fmt.Fprintf(w, "%-*s %s\n", maxNameLen, nameHeader, updateHeader)
fmt.Fprintf(w, "%s %s\n", strings.Repeat("-", maxNameLen), strings.Repeat("-", len(updateHeader)))
for _, s := range stories {
fmt.Fprintf(w, "%-*s %s\n", maxNameLen, s.Name, Relative(s.DateModified))
}
}

View File

@@ -0,0 +1,147 @@
package pocketbase
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Client handles interactions with the PocketBase API.
type Client struct {
baseURL string
user string
password string
category string
http *http.Client
}
// New creates a new PocketBase client.
func New(baseURL, user, password, category string) *Client {
// Normalise: ensure baseURL does not already end with /api.
baseURL = strings.TrimSuffix(baseURL, "/api")
baseURL = strings.TrimSuffix(baseURL, "/")
return &Client{
baseURL: baseURL,
user: user,
password: password,
category: category,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type authRequest struct {
Identity string `json:"identity"`
Password string `json:"password"`
}
type authResponse struct {
Token string `json:"token"`
}
type record struct {
URL string `json:"url"`
}
type recordsResponse struct {
Items []record `json:"items"`
}
// GetBookmarkURLs authenticates and retrieves URLs from the bookmarks collection.
func (c *Client) GetBookmarkURLs(ctx context.Context) ([]string, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, fmt.Errorf("pocketbase auth: %w", err)
}
urls, err := c.fetchURLs(ctx, token)
if err != nil {
return nil, fmt.Errorf("pocketbase fetch URLs: %w", err)
}
return urls, nil
}
func (c *Client) authenticate(ctx context.Context) (string, error) {
reqBody, err := json.Marshal(authRequest{
Identity: c.user,
Password: c.password,
})
if err != nil {
return "", err
}
authURL := c.baseURL + "/api/collections/users/auth-with-password"
req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var authResp authResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return "", err
}
return authResp.Token, nil
}
func (c *Client) fetchURLs(ctx context.Context, token string) ([]string, error) {
// Filter: category=<value>, perPage=200, fields=url
filter := fmt.Sprintf(`category="%s"`, c.category)
u, err := url.Parse(c.baseURL + "/api/collections/bookmarks/records")
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
q := u.Query()
q.Set("filter", filter)
q.Set("perPage", "200")
q.Set("fields", "url")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var recs recordsResponse
if err := json.NewDecoder(resp.Body).Decode(&recs); err != nil {
return nil, err
}
var urls []string
for _, r := range recs.Items {
if r.URL != "" {
urls = append(urls, r.URL)
}
}
return urls, nil
}

View File

@@ -0,0 +1,65 @@
package pocketbase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetBookmarkURLs_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/collections/users/auth-with-password" {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(authResponse{Token: "test-token"})
return
}
if r.URL.Path == "/api/collections/bookmarks/records" {
if r.Header.Get("Authorization") != "test-token" {
t.Errorf("Expected Authorization header 'test-token', got '%s'", r.Header.Get("Authorization"))
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(recordsResponse{
Items: []record{
{URL: "https://royalroad.com/fiction/1"},
{URL: "https://royalroad.com/fiction/2"},
},
})
return
}
t.Errorf("Unexpected request to %s", r.URL.Path)
}))
defer server.Close()
client := New(server.URL, "user", "pass", "Geschichten")
urls, err := client.GetBookmarkURLs(context.Background())
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(urls) != 2 {
t.Errorf("Expected 2 URLs, got %d", len(urls))
}
if urls[0] != "https://royalroad.com/fiction/1" {
t.Errorf("Unexpected first URL: %s", urls[0])
}
}
func TestGetBookmarkURLs_AuthFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/collections/users/auth-with-password" {
w.WriteHeader(http.StatusUnauthorized)
return
}
t.Errorf("Unexpected request to %s", r.URL.Path)
}))
defer server.Close()
client := New(server.URL, "user", "pass", "Geschichten")
_, err := client.GetBookmarkURLs(context.Background())
if err == nil {
t.Error("Expected error on auth failure, got nil")
}
}

View File

@@ -0,0 +1,121 @@
package royalroad
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Story represents the metadata extracted from a RoyalRoad fiction page.
type Story struct {
Name string
DateModified time.Time
}
// jsonLD represents the structured data found in RoyalRoad fiction pages.
type jsonLD struct {
Name string `json:"name"`
DateModified string `json:"dateModified"`
}
// Scraper handles fetching and parsing RoyalRoad fiction pages.
type Scraper struct {
client *http.Client
}
// NewScraper creates a new Scraper with a configured HTTP client.
func NewScraper() *Scraper {
return &Scraper{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// FetchStory extracts the story name and modification date from a RoyalRoad fiction page.
func (s *Scraper) FetchStory(ctx context.Context, rawURL string) (Story, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return Story{}, fmt.Errorf("invalid URL %q: %w", rawURL, err)
}
host := strings.ToLower(parsedURL.Hostname())
if host != "royalroad.com" && host != "www.royalroad.com" {
return Story{}, fmt.Errorf("URL host %q is not royalroad.com", host)
}
if strings.Contains(parsedURL.Path, "/chapter/") {
return Story{}, fmt.Errorf("URL %q points to a chapter, not a main fiction page", rawURL)
}
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return Story{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "royalroadupdates/1.0")
resp, err := s.client.Do(req)
if err != nil {
return Story{}, fmt.Errorf("failed to fetch page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Story{}, fmt.Errorf("unexpected response status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return Story{}, fmt.Errorf("failed to read body: %w", err)
}
story, err := s.parseJSONLD(body)
if err != nil {
return Story{}, fmt.Errorf("failed to parse JSON-LD: %w", err)
}
return story, nil
}
func (s *Scraper) parseJSONLD(body []byte) (Story, error) {
startTag := `<script type="application/ld+json">`
endTag := `</script>`
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
}

View File

@@ -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: `<html><script type="application/ld+json">{"name":"Test Story","dateModified":"2023-07-06T17:47:41Z"}</script></html>`,
want: Story{
Name: "Test Story",
DateModified: time.Date(2023, 7, 6, 17, 47, 41, 0, time.UTC),
},
wantErr: false,
},
{
name: "Missing Script",
body: `<html><body>No JSON-LD here</body></html>`,
wantErr: true,
},
{
name: "Malformed JSON",
body: `<script type="application/ld+json">{invalid json}</script>`,
wantErr: true,
},
{
name: "Missing Name",
body: `<script type="application/ld+json">{"dateModified":"2023-07-06T17:47:41Z"}</script>`,
wantErr: true,
},
{
name: "Invalid Date",
body: `<script type="application/ld+json">{"name":"Story","dateModified":"not-a-date"}</script>`,
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")
}
}