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>
274 lines
7.1 KiB
Go
274 lines
7.1 KiB
Go
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
|
|
}
|