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