diff --git a/sites/pizzint/cmd/pizzint/main.go b/sites/pizzint/cmd/pizzint/main.go new file mode 100644 index 0000000..5d71c87 --- /dev/null +++ b/sites/pizzint/cmd/pizzint/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "sync" + "time" + + "github.com/urfave/cli/v3" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/cmd/browser/pkg/browser" + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/pizzint" +) + +func main() { + var flags []cli.Flag + flags = append(flags, browser.Flags...) + flags = append(flags, + &cli.BoolFlag{ + Name: "serve", + Usage: "Start an HTTP server instead of printing once", + }, + &cli.StringFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "Port for the HTTP server", + DefaultText: "8080", + }, + &cli.StringFlag{ + Name: "cache-ttl", + Usage: "How long to cache results (e.g. 5m, 1h)", + DefaultText: "5m", + }, + ) + + app := &cli.Command{ + Name: "pizzint", + Usage: "Pentagon Pizza Index — DOUGHCON status tracker", + Flags: flags, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Bool("serve") { + return runServer(ctx, cmd) + } + return runOnce(ctx, cmd) + }, + } + + if err := app.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func runOnce(ctx context.Context, cmd *cli.Command) error { + b, err := browser.FromCommand(ctx, cmd) + if err != nil { + return fmt.Errorf("failed to create browser: %w", err) + } + defer extractor.DeferClose(b) + + status, err := pizzint.GetStatus(ctx, b) + if err != nil { + return fmt.Errorf("failed to get pizza status: %w", err) + } + + out, err := json.MarshalIndent(status, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal status: %w", err) + } + + fmt.Println(string(out)) + return nil +} + +func runServer(ctx context.Context, cmd *cli.Command) error { + port := cmd.String("port") + if port == "" { + port = "8080" + } + + cacheTTL := 5 * time.Minute + if ttlStr := cmd.String("cache-ttl"); ttlStr != "" { + d, err := time.ParseDuration(ttlStr) + if err != nil { + return fmt.Errorf("invalid cache-ttl %q: %w", ttlStr, err) + } + cacheTTL = d + } + + srv := &statusServer{ + cmd: cmd, + cacheTTL: cacheTTL, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /status", srv.handleStatus) + mux.HandleFunc("GET /", srv.handleIndex) + + addr := ":" + port + slog.Info("starting pizza status server", "addr", addr, "cache_ttl", cacheTTL) + fmt.Fprintf(os.Stderr, "Pizza status server listening on http://localhost%s\n", addr) + fmt.Fprintf(os.Stderr, " GET /status — JSON pizza status\n") + fmt.Fprintf(os.Stderr, " GET / — human-readable status\n") + + httpSrv := &http.Server{Addr: addr, Handler: mux} + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + }() + + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +type statusServer struct { + cmd *cli.Command + cacheTTL time.Duration + + mu sync.Mutex + cached *pizzint.PizzaStatus +} + +func (s *statusServer) fetch(ctx context.Context) (*pizzint.PizzaStatus, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cached != nil && time.Since(s.cached.FetchedAt) < s.cacheTTL { + return s.cached, nil + } + + b, err := browser.FromCommand(ctx, s.cmd) + if err != nil { + return nil, fmt.Errorf("failed to create browser: %w", err) + } + defer extractor.DeferClose(b) + + status, err := pizzint.GetStatus(ctx, b) + if err != nil { + return nil, err + } + + s.cached = status + return status, nil +} + +func (s *statusServer) handleStatus(w http.ResponseWriter, r *http.Request) { + status, err := s.fetch(r.Context()) + if err != nil { + slog.Error("failed to fetch pizza status", "err", err) + http.Error(w, `{"error": "failed to fetch pizza status"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(s.cacheTTL.Seconds()))) + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(status); err != nil { + slog.Error("failed to encode response", "err", err) + } +} + +func (s *statusServer) handleIndex(w http.ResponseWriter, r *http.Request) { + status, err := s.fetch(r.Context()) + if err != nil { + slog.Error("failed to fetch pizza status", "err", err) + http.Error(w, "Failed to fetch pizza status", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + fmt.Fprintf(w, "=== PENTAGON PIZZA INDEX ===\n\n") + fmt.Fprintf(w, " %s\n", status.DoughconLevel) + fmt.Fprintf(w, " Overall Index: %d/100\n\n", status.OverallIndex) + + fmt.Fprintf(w, "--- Monitored Locations ---\n\n") + for _, r := range status.Restaurants { + fmt.Fprintf(w, " %-30s %s", r.Name, r.Status()) + if r.CurrentPopularity > 0 { + fmt.Fprintf(w, " (popularity: %d)", r.CurrentPopularity) + } + fmt.Fprintln(w) + } + + if len(status.Events) > 0 { + fmt.Fprintf(w, "\n--- Active Events ---\n\n") + for _, e := range status.Events { + fmt.Fprintf(w, " %s (%d min ago)\n", e.Name, e.MinutesAgo) + } + } + + fmt.Fprintf(w, "\nFetched: %s\n", status.FetchedAt.Format(time.RFC3339)) +} diff --git a/sites/pizzint/pizzint.go b/sites/pizzint/pizzint.go new file mode 100644 index 0000000..35ad222 --- /dev/null +++ b/sites/pizzint/pizzint.go @@ -0,0 +1,273 @@ +package pizzint + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" +) + +const dashboardAPIURL = "https://www.pizzint.watch/api/dashboard-data" + +// DoughconLevel represents the DOUGHCON threat level (modeled after DEFCON). +// Lower numbers indicate higher activity. +type DoughconLevel int + +const ( + DoughconMaximum DoughconLevel = 1 // Maximum Alert + DoughconHigh DoughconLevel = 2 // High Activity + DoughconElevated DoughconLevel = 3 // Elevated Activity + DoughconWatch DoughconLevel = 4 // Increased Intelligence Watch + DoughconQuiet DoughconLevel = 5 // All Quiet +) + +func (d DoughconLevel) String() string { + switch d { + case DoughconQuiet: + return "DOUGHCON 5 - ALL QUIET" + case DoughconWatch: + return "DOUGHCON 4 - DOUBLE TAKE" + case DoughconElevated: + return "DOUGHCON 3 - ELEVATED" + case DoughconHigh: + return "DOUGHCON 2 - HIGH ACTIVITY" + case DoughconMaximum: + return "DOUGHCON 1 - MAXIMUM ALERT" + default: + return fmt.Sprintf("DOUGHCON %d", d) + } +} + +// Label returns a short label for the DOUGHCON level. +func (d DoughconLevel) Label() string { + switch d { + case DoughconQuiet: + return "ALL QUIET" + case DoughconWatch: + return "DOUBLE TAKE" + case DoughconElevated: + return "ELEVATED" + case DoughconHigh: + return "HIGH ACTIVITY" + case DoughconMaximum: + return "MAXIMUM ALERT" + default: + return "UNKNOWN" + } +} + +// Restaurant represents a monitored pizza restaurant near the Pentagon. +type Restaurant struct { + Name string `json:"name"` + CurrentPopularity int `json:"current_popularity"` + PercentOfUsual *int `json:"percent_of_usual,omitempty"` + IsSpike bool `json:"is_spike"` + SpikeMagnitude string `json:"spike_magnitude,omitempty"` + IsClosed bool `json:"is_closed"` + DataFreshness string `json:"data_freshness"` +} + +// Status returns a human-readable status string like "QUIET", "CLOSED", or "139% SPIKE". +func (r Restaurant) Status() string { + if r.IsClosed { + return "CLOSED" + } + if r.IsSpike && r.PercentOfUsual != nil { + return fmt.Sprintf("%d%% SPIKE", *r.PercentOfUsual) + } + if r.IsSpike { + return "SPIKE" + } + return "QUIET" +} + +// Event represents a detected spike event at a monitored location. +type Event struct { + Name string `json:"name"` + MinutesAgo int `json:"minutes_ago"` +} + +// PizzaStatus is the top-level result from the PizzINT dashboard. +type PizzaStatus struct { + DoughconLevel DoughconLevel `json:"doughcon_level"` + DoughconLabel string `json:"doughcon_label"` + OverallIndex int `json:"overall_index"` + Restaurants []Restaurant `json:"restaurants"` + Events []Event `json:"events,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +// Config holds configuration for the PizzINT extractor. +type Config struct{} + +// DefaultConfig is the default PizzINT configuration. +var DefaultConfig = Config{} + +func (c Config) validate() Config { + return c +} + +// GetStatus fetches the current pizza activity status from the PizzINT dashboard. +func (c Config) GetStatus(ctx context.Context, b extractor.Browser) (*PizzaStatus, error) { + c = c.validate() + + slog.Info("fetching pizza status", "url", dashboardAPIURL) + doc, err := b.Open(ctx, dashboardAPIURL, extractor.OpenPageOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to open pizzint API: %w", err) + } + defer extractor.DeferClose(doc) + + return extractStatus(doc) +} + +// GetStatus is a convenience function using DefaultConfig. +func GetStatus(ctx context.Context, b extractor.Browser) (*PizzaStatus, error) { + return DefaultConfig.GetStatus(ctx, b) +} + +func extractStatus(doc extractor.Document) (*PizzaStatus, error) { + // The browser renders the JSON API response as text in the page body. + // doc.Text() returns InnerText of the html element, which should + // contain the raw JSON (possibly with extra browser UI text). + body, err := doc.Text() + if err != nil { + return nil, fmt.Errorf("failed to get page text: %w", err) + } + + jsonStr, err := findJSON(body) + if err != nil { + // Fall back to Content() which returns the full HTML — the JSON + // will be embedded in it (e.g. inside a
 tag in Chromium).
+		html, herr := doc.Content()
+		if herr != nil {
+			return nil, fmt.Errorf("failed to extract JSON from text (%w) and failed to get HTML: %w", err, herr)
+		}
+		jsonStr, err = findJSON(html)
+		if err != nil {
+			return nil, fmt.Errorf("no valid JSON found in API response: %w", err)
+		}
+	}
+
+	var resp dashboardResponse
+	if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
+		return nil, fmt.Errorf("failed to parse dashboard response: %w", err)
+	}
+
+	if !resp.Success {
+		return nil, fmt.Errorf("API returned success=false")
+	}
+
+	return resp.toPizzaStatus(), nil
+}
+
+// findJSON extracts a JSON object from a string by matching braces.
+func findJSON(s string) (string, error) {
+	start := strings.Index(s, "{")
+	if start == -1 {
+		return "", fmt.Errorf("no opening brace found")
+	}
+
+	depth := 0
+	inString := false
+	escape := false
+
+	for i := start; i < len(s); i++ {
+		ch := s[i]
+
+		if escape {
+			escape = false
+			continue
+		}
+
+		if ch == '\\' && inString {
+			escape = true
+			continue
+		}
+
+		if ch == '"' {
+			inString = !inString
+			continue
+		}
+
+		if inString {
+			continue
+		}
+
+		switch ch {
+		case '{':
+			depth++
+		case '}':
+			depth--
+			if depth == 0 {
+				return s[start : i+1], nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("no matching closing brace found")
+}
+
+// dashboardResponse is the raw API response from /api/dashboard-data.
+type dashboardResponse struct {
+	Success      bool                  `json:"success"`
+	OverallIndex int                   `json:"overall_index"`
+	DefconLevel  int                   `json:"defcon_level"`
+	Data         []dashboardRestaurant `json:"data"`
+	Events       []dashboardEvent      `json:"events"`
+}
+
+type dashboardRestaurant struct {
+	PlaceID           string  `json:"place_id"`
+	Name              string  `json:"name"`
+	Address           string  `json:"address"`
+	CurrentPopularity int     `json:"current_popularity"`
+	PercentageOfUsual *int    `json:"percentage_of_usual"`
+	IsSpike           bool    `json:"is_spike"`
+	SpikeMagnitude    *string `json:"spike_magnitude"`
+	DataSource        string  `json:"data_source"`
+	DataFreshness     string  `json:"data_freshness"`
+	IsClosedNow       bool    `json:"is_closed_now"`
+}
+
+type dashboardEvent struct {
+	Name       string `json:"name"`
+	MinutesAgo int    `json:"minutes_ago"`
+}
+
+func (r dashboardResponse) toPizzaStatus() *PizzaStatus {
+	status := &PizzaStatus{
+		DoughconLevel: DoughconLevel(r.DefconLevel),
+		OverallIndex:  r.OverallIndex,
+		FetchedAt:     time.Now(),
+	}
+	status.DoughconLabel = status.DoughconLevel.Label()
+
+	for _, d := range r.Data {
+		rest := Restaurant{
+			Name:              d.Name,
+			CurrentPopularity: d.CurrentPopularity,
+			PercentOfUsual:    d.PercentageOfUsual,
+			IsSpike:           d.IsSpike,
+			IsClosed:          d.IsClosedNow,
+			DataFreshness:     d.DataFreshness,
+		}
+		if d.SpikeMagnitude != nil {
+			rest.SpikeMagnitude = *d.SpikeMagnitude
+		}
+		status.Restaurants = append(status.Restaurants, rest)
+	}
+
+	for _, e := range r.Events {
+		status.Events = append(status.Events, Event{
+			Name:       e.Name,
+			MinutesAgo: e.MinutesAgo,
+		})
+	}
+
+	return status
+}
diff --git a/sites/pizzint/pizzint_test.go b/sites/pizzint/pizzint_test.go
new file mode 100644
index 0000000..31fc710
--- /dev/null
+++ b/sites/pizzint/pizzint_test.go
@@ -0,0 +1,306 @@
+package pizzint
+
+import (
+	"context"
+	"testing"
+
+	"gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest"
+)
+
+const sampleAPIResponse = `{
+  "success": true,
+  "overall_index": 42,
+  "defcon_level": 3,
+  "data": [
+    {
+      "place_id": "abc123",
+      "name": "DOMINO'S PIZZA",
+      "address": "https://maps.google.com/test",
+      "current_popularity": 15,
+      "percentage_of_usual": null,
+      "is_spike": false,
+      "spike_magnitude": null,
+      "data_source": "live",
+      "data_freshness": "fresh",
+      "is_closed_now": false
+    },
+    {
+      "place_id": "def456",
+      "name": "EXTREME PIZZA",
+      "address": "https://maps.google.com/test2",
+      "current_popularity": 0,
+      "percentage_of_usual": null,
+      "is_spike": false,
+      "spike_magnitude": null,
+      "data_source": "live",
+      "data_freshness": "stale",
+      "is_closed_now": true
+    },
+    {
+      "place_id": "ghi789",
+      "name": "PIZZATO PIZZA",
+      "address": "https://maps.google.com/test3",
+      "current_popularity": 85,
+      "percentage_of_usual": 239,
+      "is_spike": true,
+      "spike_magnitude": "EXTREME",
+      "data_source": "live",
+      "data_freshness": "fresh",
+      "is_closed_now": false
+    }
+  ],
+  "events": [
+    {
+      "name": "PIZZATO PIZZA",
+      "minutes_ago": 5
+    }
+  ]
+}`
+
+func TestExtractStatus(t *testing.T) {
+	doc := &extractortest.MockDocument{
+		URLValue: dashboardAPIURL,
+		MockNode: extractortest.MockNode{
+			TextValue: sampleAPIResponse,
+		},
+	}
+
+	status, err := extractStatus(doc)
+	if err != nil {
+		t.Fatalf("extractStatus returned error: %v", err)
+	}
+
+	if status.DoughconLevel != DoughconElevated {
+		t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
+	}
+
+	if status.OverallIndex != 42 {
+		t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
+	}
+
+	if status.DoughconLabel != "ELEVATED" {
+		t.Errorf("DoughconLabel = %q, want %q", status.DoughconLabel, "ELEVATED")
+	}
+
+	if len(status.Restaurants) != 3 {
+		t.Fatalf("len(Restaurants) = %d, want 3", len(status.Restaurants))
+	}
+
+	// First restaurant: quiet
+	r0 := status.Restaurants[0]
+	if r0.Name != "DOMINO'S PIZZA" {
+		t.Errorf("Restaurants[0].Name = %q, want %q", r0.Name, "DOMINO'S PIZZA")
+	}
+	if r0.Status() != "QUIET" {
+		t.Errorf("Restaurants[0].Status() = %q, want %q", r0.Status(), "QUIET")
+	}
+	if r0.CurrentPopularity != 15 {
+		t.Errorf("Restaurants[0].CurrentPopularity = %d, want 15", r0.CurrentPopularity)
+	}
+
+	// Second restaurant: closed
+	r1 := status.Restaurants[1]
+	if r1.Status() != "CLOSED" {
+		t.Errorf("Restaurants[1].Status() = %q, want %q", r1.Status(), "CLOSED")
+	}
+	if !r1.IsClosed {
+		t.Error("Restaurants[1].IsClosed = false, want true")
+	}
+
+	// Third restaurant: spike
+	r2 := status.Restaurants[2]
+	if r2.Status() != "239% SPIKE" {
+		t.Errorf("Restaurants[2].Status() = %q, want %q", r2.Status(), "239% SPIKE")
+	}
+	if r2.SpikeMagnitude != "EXTREME" {
+		t.Errorf("Restaurants[2].SpikeMagnitude = %q, want %q", r2.SpikeMagnitude, "EXTREME")
+	}
+	if r2.CurrentPopularity != 85 {
+		t.Errorf("Restaurants[2].CurrentPopularity = %d, want 85", r2.CurrentPopularity)
+	}
+
+	// Events
+	if len(status.Events) != 1 {
+		t.Fatalf("len(Events) = %d, want 1", len(status.Events))
+	}
+	if status.Events[0].Name != "PIZZATO PIZZA" {
+		t.Errorf("Events[0].Name = %q, want %q", status.Events[0].Name, "PIZZATO PIZZA")
+	}
+	if status.Events[0].MinutesAgo != 5 {
+		t.Errorf("Events[0].MinutesAgo = %d, want 5", status.Events[0].MinutesAgo)
+	}
+}
+
+func TestExtractStatusFromHTML(t *testing.T) {
+	// Simulate Chromium wrapping JSON in a 
 tag. doc.Text() returns
+	// the InnerText which may include the JSON, but Content() returns the
+	// raw HTML with the JSON inside 
.
+	htmlWrapped := `
` + sampleAPIResponse + `
` + + doc := &extractortest.MockDocument{ + URLValue: dashboardAPIURL, + MockNode: extractortest.MockNode{ + // Text() might fail or return garbage + TextValue: "", + // Content() returns the HTML + ContentValue: htmlWrapped, + }, + } + + status, err := extractStatus(doc) + if err != nil { + t.Fatalf("extractStatus returned error: %v", err) + } + + if status.DoughconLevel != DoughconElevated { + t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated) + } + + if len(status.Restaurants) != 3 { + t.Errorf("len(Restaurants) = %d, want 3", len(status.Restaurants)) + } +} + +func TestExtractStatusFailure(t *testing.T) { + doc := &extractortest.MockDocument{ + URLValue: dashboardAPIURL, + MockNode: extractortest.MockNode{ + TextValue: `{"success": false}`, + ContentValue: `{"success": false}`, + }, + } + + _, err := extractStatus(doc) + if err == nil { + t.Fatal("expected error for success=false response") + } +} + +func TestGetStatus(t *testing.T) { + mock := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + dashboardAPIURL: { + URLValue: dashboardAPIURL, + MockNode: extractortest.MockNode{ + TextValue: sampleAPIResponse, + }, + }, + }, + } + + status, err := GetStatus(context.Background(), mock) + if err != nil { + t.Fatalf("GetStatus returned error: %v", err) + } + + if status.OverallIndex != 42 { + t.Errorf("OverallIndex = %d, want 42", status.OverallIndex) + } +} + +func TestFindJSON(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "plain JSON", + input: `{"key": "value"}`, + want: `{"key": "value"}`, + }, + { + name: "JSON in HTML", + input: `
{"key": "value"}
`, + want: `{"key": "value"}`, + }, + { + name: "nested braces", + input: `{"a": {"b": "c"}}`, + want: `{"a": {"b": "c"}}`, + }, + { + name: "braces in strings", + input: `{"a": "hello {world}"}`, + want: `{"a": "hello {world}"}`, + }, + { + name: "escaped quotes", + input: `{"a": "he said \"hi\""}`, + want: `{"a": "he said \"hi\""}`, + }, + { + name: "no JSON", + input: "just some text", + wantErr: true, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := findJSON(tt.input) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("findJSON() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDoughconLevelString(t *testing.T) { + tests := []struct { + level DoughconLevel + want string + }{ + {DoughconQuiet, "DOUGHCON 5 - ALL QUIET"}, + {DoughconWatch, "DOUGHCON 4 - DOUBLE TAKE"}, + {DoughconElevated, "DOUGHCON 3 - ELEVATED"}, + {DoughconHigh, "DOUGHCON 2 - HIGH ACTIVITY"}, + {DoughconMaximum, "DOUGHCON 1 - MAXIMUM ALERT"}, + {DoughconLevel(99), "DOUGHCON 99"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.level.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRestaurantStatus(t *testing.T) { + pct := 150 + tests := []struct { + name string + r Restaurant + want string + }{ + {"quiet", Restaurant{Name: "Test"}, "QUIET"}, + {"closed", Restaurant{IsClosed: true}, "CLOSED"}, + {"spike with percent", Restaurant{IsSpike: true, PercentOfUsual: &pct}, "150% SPIKE"}, + {"spike without percent", Restaurant{IsSpike: true}, "SPIKE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.r.Status(); got != tt.want { + t.Errorf("Status() = %q, want %q", got, tt.want) + } + }) + } +}