feature: add PizzINT (Pentagon Pizza Index) site extractor
All checks were successful
CI / vet (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 1m9s
CI / test (pull_request) Successful in 1m9s

Adds a new site extractor for pizzint.watch, which tracks pizza shop
activity near the Pentagon as an OSINT indicator. The extractor fetches
the dashboard API and exposes DOUGHCON levels, restaurant activity, and
spike events.

Includes a CLI tool with an HTTP server mode (--serve) for embedding
the pizza status in dashboards or status displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 05:45:55 +00:00
parent 3357972246
commit c1c1acdb00
3 changed files with 784 additions and 0 deletions

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