Files
go-extractor/sites/pizzint/cmd/pizzint/main.go
Steve Dudenhoeffer c1c1acdb00
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
feature: add PizzINT (Pentagon Pizza Index) site extractor
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>
2026-02-22 05:45:55 +00:00

206 lines
5.1 KiB
Go

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