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>
This commit is contained in:
205
sites/pizzint/cmd/pizzint/main.go
Normal file
205
sites/pizzint/cmd/pizzint/main.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user