feature: add PizzINT site extractor with HTTP API #67

Merged
steve merged 1 commits from feature/pizzint-site-extractor into main 2026-02-22 05:52:56 +00:00
3 changed files with 784 additions and 0 deletions
Showing only changes of commit c1c1acdb00 - Show all commits

View File

@@ -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))
}

273
sites/pizzint/pizzint.go Normal file
View File

@@ -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 <pre> 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
}

View File

@@ -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 <pre> tag. doc.Text() returns
// the InnerText which may include the JSON, but Content() returns the
// raw HTML with the JSON inside <pre>.
htmlWrapped := `<html><head></head><body><pre style="word-wrap: break-word;">` + sampleAPIResponse + `</pre></body></html>`
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: `<html><pre>{"key": "value"}</pre></html>`,
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)
}
})
}
}