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

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
}