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

View File

@@ -0,0 +1,306 @@
package pizzint
import (
"context"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest"
)
const sampleAPIResponse = `{
"success": true,
"overall_index": 42,
"defcon_level": 3,
"data": [
{
"place_id": "abc123",
"name": "DOMINO'S PIZZA",
"address": "https://maps.google.com/test",
"current_popularity": 15,
"percentage_of_usual": null,
"is_spike": false,
"spike_magnitude": null,
"data_source": "live",
"data_freshness": "fresh",
"is_closed_now": false
},
{
"place_id": "def456",
"name": "EXTREME PIZZA",
"address": "https://maps.google.com/test2",
"current_popularity": 0,
"percentage_of_usual": null,
"is_spike": false,
"spike_magnitude": null,
"data_source": "live",
"data_freshness": "stale",
"is_closed_now": true
},
{
"place_id": "ghi789",
"name": "PIZZATO PIZZA",
"address": "https://maps.google.com/test3",
"current_popularity": 85,
"percentage_of_usual": 239,
"is_spike": true,
"spike_magnitude": "EXTREME",
"data_source": "live",
"data_freshness": "fresh",
"is_closed_now": false
}
],
"events": [
{
"name": "PIZZATO PIZZA",
"minutes_ago": 5
}
]
}`
func TestExtractStatus(t *testing.T) {
doc := &extractortest.MockDocument{
URLValue: dashboardAPIURL,
MockNode: extractortest.MockNode{
TextValue: sampleAPIResponse,
},
}
status, err := extractStatus(doc)
if err != nil {
t.Fatalf("extractStatus returned error: %v", err)
}
if status.DoughconLevel != DoughconElevated {
t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
}
if status.OverallIndex != 42 {
t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
}
if status.DoughconLabel != "ELEVATED" {
t.Errorf("DoughconLabel = %q, want %q", status.DoughconLabel, "ELEVATED")
}
if len(status.Restaurants) != 3 {
t.Fatalf("len(Restaurants) = %d, want 3", len(status.Restaurants))
}
// First restaurant: quiet
r0 := status.Restaurants[0]
if r0.Name != "DOMINO'S PIZZA" {
t.Errorf("Restaurants[0].Name = %q, want %q", r0.Name, "DOMINO'S PIZZA")
}
if r0.Status() != "QUIET" {
t.Errorf("Restaurants[0].Status() = %q, want %q", r0.Status(), "QUIET")
}
if r0.CurrentPopularity != 15 {
t.Errorf("Restaurants[0].CurrentPopularity = %d, want 15", r0.CurrentPopularity)
}
// Second restaurant: closed
r1 := status.Restaurants[1]
if r1.Status() != "CLOSED" {
t.Errorf("Restaurants[1].Status() = %q, want %q", r1.Status(), "CLOSED")
}
if !r1.IsClosed {
t.Error("Restaurants[1].IsClosed = false, want true")
}
// Third restaurant: spike
r2 := status.Restaurants[2]
if r2.Status() != "239% SPIKE" {
t.Errorf("Restaurants[2].Status() = %q, want %q", r2.Status(), "239% SPIKE")
}
if r2.SpikeMagnitude != "EXTREME" {
t.Errorf("Restaurants[2].SpikeMagnitude = %q, want %q", r2.SpikeMagnitude, "EXTREME")
}
if r2.CurrentPopularity != 85 {
t.Errorf("Restaurants[2].CurrentPopularity = %d, want 85", r2.CurrentPopularity)
}
// Events
if len(status.Events) != 1 {
t.Fatalf("len(Events) = %d, want 1", len(status.Events))
}
if status.Events[0].Name != "PIZZATO PIZZA" {
t.Errorf("Events[0].Name = %q, want %q", status.Events[0].Name, "PIZZATO PIZZA")
}
if status.Events[0].MinutesAgo != 5 {
t.Errorf("Events[0].MinutesAgo = %d, want 5", status.Events[0].MinutesAgo)
}
}
func TestExtractStatusFromHTML(t *testing.T) {
// Simulate Chromium wrapping JSON in a <pre> tag. doc.Text() returns
// the InnerText which may include the JSON, but Content() returns the
// raw HTML with the JSON inside <pre>.
htmlWrapped := `<html><head></head><body><pre style="word-wrap: break-word;">` + sampleAPIResponse + `</pre></body></html>`
doc := &extractortest.MockDocument{
URLValue: dashboardAPIURL,
MockNode: extractortest.MockNode{
// Text() might fail or return garbage
TextValue: "",
// Content() returns the HTML
ContentValue: htmlWrapped,
},
}
status, err := extractStatus(doc)
if err != nil {
t.Fatalf("extractStatus returned error: %v", err)
}
if status.DoughconLevel != DoughconElevated {
t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
}
if len(status.Restaurants) != 3 {
t.Errorf("len(Restaurants) = %d, want 3", len(status.Restaurants))
}
}
func TestExtractStatusFailure(t *testing.T) {
doc := &extractortest.MockDocument{
URLValue: dashboardAPIURL,
MockNode: extractortest.MockNode{
TextValue: `{"success": false}`,
ContentValue: `{"success": false}`,
},
}
_, err := extractStatus(doc)
if err == nil {
t.Fatal("expected error for success=false response")
}
}
func TestGetStatus(t *testing.T) {
mock := &extractortest.MockBrowser{
Documents: map[string]*extractortest.MockDocument{
dashboardAPIURL: {
URLValue: dashboardAPIURL,
MockNode: extractortest.MockNode{
TextValue: sampleAPIResponse,
},
},
},
}
status, err := GetStatus(context.Background(), mock)
if err != nil {
t.Fatalf("GetStatus returned error: %v", err)
}
if status.OverallIndex != 42 {
t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
}
}
func TestFindJSON(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "plain JSON",
input: `{"key": "value"}`,
want: `{"key": "value"}`,
},
{
name: "JSON in HTML",
input: `<html><pre>{"key": "value"}</pre></html>`,
want: `{"key": "value"}`,
},
{
name: "nested braces",
input: `{"a": {"b": "c"}}`,
want: `{"a": {"b": "c"}}`,
},
{
name: "braces in strings",
input: `{"a": "hello {world}"}`,
want: `{"a": "hello {world}"}`,
},
{
name: "escaped quotes",
input: `{"a": "he said \"hi\""}`,
want: `{"a": "he said \"hi\""}`,
},
{
name: "no JSON",
input: "just some text",
wantErr: true,
},
{
name: "empty string",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := findJSON(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("findJSON() = %q, want %q", got, tt.want)
}
})
}
}
func TestDoughconLevelString(t *testing.T) {
tests := []struct {
level DoughconLevel
want string
}{
{DoughconQuiet, "DOUGHCON 5 - ALL QUIET"},
{DoughconWatch, "DOUGHCON 4 - DOUBLE TAKE"},
{DoughconElevated, "DOUGHCON 3 - ELEVATED"},
{DoughconHigh, "DOUGHCON 2 - HIGH ACTIVITY"},
{DoughconMaximum, "DOUGHCON 1 - MAXIMUM ALERT"},
{DoughconLevel(99), "DOUGHCON 99"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := tt.level.String(); got != tt.want {
t.Errorf("String() = %q, want %q", got, tt.want)
}
})
}
}
func TestRestaurantStatus(t *testing.T) {
pct := 150
tests := []struct {
name string
r Restaurant
want string
}{
{"quiet", Restaurant{Name: "Test"}, "QUIET"},
{"closed", Restaurant{IsClosed: true}, "CLOSED"},
{"spike with percent", Restaurant{IsSpike: true, PercentOfUsual: &pct}, "150% SPIKE"},
{"spike without percent", Restaurant{IsSpike: true}, "SPIKE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.r.Status(); got != tt.want {
t.Errorf("Status() = %q, want %q", got, tt.want)
}
})
}
}