[object Object]

← back to Cli Printing Press

feat(websniff): add HAR and enriched capture parser

6c6c9cf59a55b6975ddd30bc39b7b8344ca0c9b6 · 2026-03-28 21:26:15 -0700 · Matt Van Horn

New internal/websniff/ package with types for HAR 1.2 and enriched
capture formats. ParseCapture() auto-detects format and normalizes
both into EnrichedEntry slice for downstream processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6c6c9cf59a55b6975ddd30bc39b7b8344ca0c9b6
Author: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Date:   Sat Mar 28 21:26:15 2026 -0700

    feat(websniff): add HAR and enriched capture parser
    
    New internal/websniff/ package with types for HAR 1.2 and enriched
    capture formats. ParseCapture() auto-detects format and normalizes
    both into EnrichedEntry slice for downstream processing.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/websniff/parser.go         |  95 +++++++++
 internal/websniff/parser_test.go    | 154 ++++++++++++++
 internal/websniff/types.go          |  61 ++++++
 testdata/sniff/sample-enriched.json |  42 ++++
 testdata/sniff/sample.har           | 408 ++++++++++++++++++++++++++++++++++++
 5 files changed, 760 insertions(+)

diff --git a/internal/websniff/parser.go b/internal/websniff/parser.go
new file mode 100644
index 00000000..7d1ba2f2
--- /dev/null
+++ b/internal/websniff/parser.go
@@ -0,0 +1,95 @@
+package websniff
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+)
+
+func ParseHAR(path string) (*HAR, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("reading file: %w", err)
+	}
+
+	var har HAR
+	if err := json.Unmarshal(data, &har); err != nil {
+		return nil, fmt.Errorf("parsing har json: %w", err)
+	}
+
+	return &har, nil
+}
+
+func ParseEnriched(path string) (*EnrichedCapture, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("reading file: %w", err)
+	}
+
+	var capture EnrichedCapture
+	if err := json.Unmarshal(data, &capture); err != nil {
+		return nil, fmt.Errorf("parsing enriched json: %w", err)
+	}
+
+	return &capture, nil
+}
+
+func ParseCapture(path string) ([]EnrichedEntry, string, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, "", fmt.Errorf("reading file: %w", err)
+	}
+
+	if bytes.Contains(data, []byte(`"log"`)) {
+		var har HAR
+		if err := json.Unmarshal(data, &har); err != nil {
+			return nil, "", fmt.Errorf("parsing har json: %w", err)
+		}
+
+		entries := make([]EnrichedEntry, 0, len(har.Log.Entries))
+		for _, entry := range har.Log.Entries {
+			entries = append(entries, convertHAREntry(entry))
+		}
+
+		targetURL := ""
+		if len(har.Log.Entries) > 0 {
+			targetURL = har.Log.Entries[0].Request.URL
+		}
+
+		return entries, targetURL, nil
+	}
+
+	if bytes.Contains(data, []byte(`"target_url"`)) {
+		var capture EnrichedCapture
+		if err := json.Unmarshal(data, &capture); err != nil {
+			return nil, "", fmt.Errorf("parsing enriched json: %w", err)
+		}
+
+		return capture.Entries, capture.TargetURL, nil
+	}
+
+	return nil, "", fmt.Errorf("unknown capture format")
+}
+
+func convertHAREntry(entry HAREntry) EnrichedEntry {
+	headers := make(map[string]string, len(entry.Request.Headers))
+	for _, header := range entry.Request.Headers {
+		headers[header.Name] = header.Value
+	}
+
+	requestBody := ""
+	if entry.Request.PostData != nil {
+		requestBody = entry.Request.PostData.Text
+	}
+
+	return EnrichedEntry{
+		Method:              entry.Request.Method,
+		URL:                 entry.Request.URL,
+		RequestBody:         requestBody,
+		ResponseBody:        entry.Response.Content.Text,
+		ResponseStatus:      entry.Response.Status,
+		ResponseContentType: entry.Response.Content.MimeType,
+		RequestHeaders:      headers,
+	}
+}
diff --git a/internal/websniff/parser_test.go b/internal/websniff/parser_test.go
new file mode 100644
index 00000000..1f85acb6
--- /dev/null
+++ b/internal/websniff/parser_test.go
@@ -0,0 +1,154 @@
+package websniff
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseHAR(t *testing.T) {
+	t.Parallel()
+
+	har, err := ParseHAR(filepath.Join("..", "..", "testdata", "sniff", "sample.har"))
+	require.NoError(t, err)
+
+	assert.Len(t, har.Log.Entries, 5)
+	assert.Equal(t, "GET", har.Log.Entries[1].Request.Method)
+	assert.Equal(t, "https://httpbin.org/get", har.Log.Entries[1].Request.URL)
+}
+
+func TestParseEnriched(t *testing.T) {
+	t.Parallel()
+
+	capture, err := ParseEnriched(filepath.Join("..", "..", "testdata", "sniff", "sample-enriched.json"))
+	require.NoError(t, err)
+
+	assert.Len(t, capture.Entries, 3)
+	assert.NotEmpty(t, capture.Entries[0].ResponseBody)
+	assert.Equal(t, "https://hn.algolia.com", capture.TargetURL)
+}
+
+func TestParseCapture(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name           string
+		path           string
+		wantEntries    int
+		wantTargetURL  string
+		wantFirstURL   string
+		wantFirstBody  string
+		wantStatusCode int
+	}{
+		{
+			name:           "auto-detects har format",
+			path:           filepath.Join("..", "..", "testdata", "sniff", "sample.har"),
+			wantEntries:    5,
+			wantTargetURL:  "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwcHgiICBoZWlnaHQ9IjIwMHB4IiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIGNsYXNzPSJsZHMtcm9sbGluZyIgc3R5bGU9ImJhY2tncm91bmQtaW1hZ2U6IG5vbmU7IGJhY2tncm91bmQtcG9zaXRpb246IGluaXRpYWwgaW5pdGlhbDsgYmFja2dyb3VuZC1yZXBlYXQ6IGluaXRpYWwgaW5pdGlhbDsiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIGZpbGw9Im5vbmUiIG5nLWF0dHItc3Ryb2tlPSJ7e2NvbmZpZy5jb2xvcn19IiBuZy1hdHRyLXN0cm9rZS13aWR0aD0ie3tjb25maWcud2lkdGh9fSIgbmctYXR0ci1yPSJ7e2NvbmZpZy5yYWRpdXN9fSIgbmctYXR0ci1zdHJva2UtZGFzaGFycmF5PSJ7e2NvbmZpZy5kYXNoYXJyYXl9fSIgc3Ryb2tlPSIjNTU1NTU1IiBzdHJva2Utd2lkdGg9IjEwIiByPSIzNSIgc3Ryb2tlLWRhc2hhcnJheT0iMTY0LjkzMzYxNDMxMzQ2NDE1IDU2Ljk3Nzg3MTQzNzgyMTM4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgY2FsY01vZGU9ImxpbmVhciIgdmFsdWVzPSIwIDUwIDUwOzM2MCA1MCA1MCIga2V5VGltZXM9IjA7MSIgZHVyPSIxcyIgYmVnaW49IjBzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPjwvY2lyY2xlPjwvc3ZnPgo=",
+			wantFirstURL:   "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwcHgiICBoZWlnaHQ9IjIwMHB4IiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIGNsYXNzPSJsZHMtcm9sbGluZyIgc3R5bGU9ImJhY2tncm91bmQtaW1hZ2U6IG5vbmU7IGJhY2tncm91bmQtcG9zaXRpb246IGluaXRpYWwgaW5pdGlhbDsgYmFja2dyb3VuZC1yZXBlYXQ6IGluaXRpYWwgaW5pdGlhbDsiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIGZpbGw9Im5vbmUiIG5nLWF0dHItc3Ryb2tlPSJ7e2NvbmZpZy5jb2xvcn19IiBuZy1hdHRyLXN0cm9rZS13aWR0aD0ie3tjb25maWcud2lkdGh9fSIgbmctYXR0ci1yPSJ7e2NvbmZpZy5yYWRpdXN9fSIgbmctYXR0ci1zdHJva2UtZGFzaGFycmF5PSJ7e2NvbmZpZy5kYXNoYXJyYXl9fSIgc3Ryb2tlPSIjNTU1NTU1IiBzdHJva2Utd2lkdGg9IjEwIiByPSIzNSIgc3Ryb2tlLWRhc2hhcnJheT0iMTY0LjkzMzYxNDMxMzQ2NDE1IDU2Ljk3Nzg3MTQzNzgyMTM4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgY2FsY01vZGU9ImxpbmVhciIgdmFsdWVzPSIwIDUwIDUwOzM2MCA1MCA1MCIga2V5VGltZXM9IjA7MSIgZHVyPSIxcyIgYmVnaW49IjBzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPjwvY2lyY2xlPjwvc3ZnPgo=",
+			wantStatusCode: 200,
+		},
+		{
+			name:           "auto-detects enriched format",
+			path:           filepath.Join("..", "..", "testdata", "sniff", "sample-enriched.json"),
+			wantEntries:    3,
+			wantTargetURL:  "https://hn.algolia.com",
+			wantFirstURL:   "https://uj5wyc0l7x-dsn.algolia.net/1/indexes/Item_dev/query?x-algolia-api-key=28f0e1ec37a5e792e6845e67da5f20dd&x-algolia-application-id=UJ5WYC0L7X",
+			wantFirstBody:  "{\"hits\": [{\"objectID\": \"21711748\", \"title\": \"Troubleshooting K8s\", \"url\": \"https://example.com\", \"author\": \"test\", \"points\": 240, \"num_comments\": 53, \"created_at\": \"2019-12-05T12:22:28Z\", \"created_at_i\": 1575548548}, {\"objectID\": \"21711749\", \"title\": \"K8s Best Practices\", \"url\": \"https://example2.com\", \"author\": \"test2\", \"points\": 180, \"num_comments\": 30, \"created_at\": \"2020-01-10T08:00:00Z\", \"created_at_i\": 1578643200}], \"nbHits\": 4804, \"page\": 0, \"nbPages\": 334, \"hitsPerPage\": 3, \"processingTimeMS\": 16, \"query\": \"kubernetes\"}",
+			wantStatusCode: 200,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			entries, targetURL, err := ParseCapture(tt.path)
+			require.NoError(t, err)
+
+			assert.Len(t, entries, tt.wantEntries)
+			assert.Equal(t, tt.wantTargetURL, targetURL)
+			assert.Equal(t, tt.wantFirstURL, entries[0].URL)
+			assert.Equal(t, tt.wantFirstBody, entries[0].ResponseBody)
+			assert.Equal(t, tt.wantStatusCode, entries[0].ResponseStatus)
+		})
+	}
+}
+
+func TestParseHAR_EdgeCases(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name        string
+		content     string
+		path        string
+		parse       func(string) (*HAR, error)
+		assertError func(*testing.T, *HAR, error)
+	}{
+		{
+			name:    "empty har",
+			content: `{"log":{"entries":[]}}`,
+			parse:   ParseHAR,
+			assertError: func(t *testing.T, har *HAR, err error) {
+				require.NoError(t, err)
+				assert.NotNil(t, har)
+				assert.Len(t, har.Log.Entries, 0)
+			},
+		},
+		{
+			name:    "invalid json",
+			content: `{`,
+			parse:   ParseHAR,
+			assertError: func(t *testing.T, har *HAR, err error) {
+				require.Error(t, err)
+				assert.Nil(t, har)
+			},
+		},
+		{
+			name:  "missing file",
+			path:  filepath.Join(t.TempDir(), "missing.har"),
+			parse: ParseHAR,
+			assertError: func(t *testing.T, har *HAR, err error) {
+				require.Error(t, err)
+				assert.Nil(t, har)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			path := tt.path
+			if tt.content != "" {
+				path = filepath.Join(t.TempDir(), "capture.har")
+				err := os.WriteFile(path, []byte(tt.content), 0o600)
+				require.NoError(t, err)
+			}
+
+			har, err := tt.parse(path)
+			tt.assertError(t, har, err)
+		})
+	}
+}
+
+func TestParseCapture_InvalidJSON(t *testing.T) {
+	t.Parallel()
+
+	path := filepath.Join(t.TempDir(), "capture.json")
+	err := os.WriteFile(path, []byte(`{`), 0o600)
+	require.NoError(t, err)
+
+	entries, targetURL, err := ParseCapture(path)
+	require.Error(t, err)
+	assert.Nil(t, entries)
+	assert.Empty(t, targetURL)
+}
+
+func TestParseCapture_MissingFile(t *testing.T) {
+	t.Parallel()
+
+	entries, targetURL, err := ParseCapture(filepath.Join(t.TempDir(), "missing.json"))
+	require.Error(t, err)
+	assert.Nil(t, entries)
+	assert.Empty(t, targetURL)
+}
diff --git a/internal/websniff/types.go b/internal/websniff/types.go
new file mode 100644
index 00000000..48597dc2
--- /dev/null
+++ b/internal/websniff/types.go
@@ -0,0 +1,61 @@
+package websniff
+
+type HAR struct {
+	Log HARLog `json:"log"`
+}
+
+type HARLog struct {
+	Entries []HAREntry `json:"entries"`
+}
+
+type HAREntry struct {
+	Request  HARRequest  `json:"request"`
+	Response HARResponse `json:"response"`
+}
+
+type HARRequest struct {
+	Method   string       `json:"method"`
+	URL      string       `json:"url"`
+	Headers  []HARHeader  `json:"headers"`
+	PostData *HARPostData `json:"postData,omitempty"`
+}
+
+type HARPostData struct {
+	MimeType string `json:"mimeType"`
+	Text     string `json:"text"`
+}
+
+type HARResponse struct {
+	Status  int                `json:"status"`
+	Content HARResponseContent `json:"content"`
+}
+
+type HARResponseContent struct {
+	MimeType string `json:"mimeType"`
+	Size     int    `json:"size"`
+	Text     string `json:"text"`
+}
+
+type HARHeader struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
+type EnrichedCapture struct {
+	TargetURL         string          `json:"target_url"`
+	CapturedAt        string          `json:"captured_at"`
+	InteractionRounds int             `json:"interaction_rounds"`
+	Entries           []EnrichedEntry `json:"entries"`
+}
+
+type EnrichedEntry struct {
+	Method              string            `json:"method"`
+	URL                 string            `json:"url"`
+	RequestBody         string            `json:"request_body"`
+	ResponseBody        string            `json:"response_body"`
+	ResponseStatus      int               `json:"response_status"`
+	ResponseContentType string            `json:"response_content_type"`
+	RequestHeaders      map[string]string `json:"request_headers"`
+	Classification      string            `json:"classification"`
+	IsNoise             bool              `json:"is_noise"`
+}
diff --git a/testdata/sniff/sample-enriched.json b/testdata/sniff/sample-enriched.json
new file mode 100644
index 00000000..0b0918ca
--- /dev/null
+++ b/testdata/sniff/sample-enriched.json
@@ -0,0 +1,42 @@
+{
+  "target_url": "https://hn.algolia.com",
+  "captured_at": "2026-03-28T16:00:00Z",
+  "interaction_rounds": 2,
+  "entries": [
+    {
+      "method": "POST",
+      "url": "https://uj5wyc0l7x-dsn.algolia.net/1/indexes/Item_dev/query?x-algolia-api-key=28f0e1ec37a5e792e6845e67da5f20dd&x-algolia-application-id=UJ5WYC0L7X",
+      "request_headers": {
+        "Content-Type": "application/json"
+      },
+      "request_body": "{\"query\": \"kubernetes\", \"page\": 0, \"hitsPerPage\": 3}",
+      "response_status": 200,
+      "response_content_type": "application/json",
+      "response_body": "{\"hits\": [{\"objectID\": \"21711748\", \"title\": \"Troubleshooting K8s\", \"url\": \"https://example.com\", \"author\": \"test\", \"points\": 240, \"num_comments\": 53, \"created_at\": \"2019-12-05T12:22:28Z\", \"created_at_i\": 1575548548}, {\"objectID\": \"21711749\", \"title\": \"K8s Best Practices\", \"url\": \"https://example2.com\", \"author\": \"test2\", \"points\": 180, \"num_comments\": 30, \"created_at\": \"2020-01-10T08:00:00Z\", \"created_at_i\": 1578643200}], \"nbHits\": 4804, \"page\": 0, \"nbPages\": 334, \"hitsPerPage\": 3, \"processingTimeMS\": 16, \"query\": \"kubernetes\"}",
+      "classification": "api",
+      "is_noise": false
+    },
+    {
+      "method": "GET",
+      "url": "https://www.google-analytics.com/collect?v=1&tid=UA-12345",
+      "request_headers": {},
+      "request_body": "",
+      "response_status": 200,
+      "response_content_type": "image/gif",
+      "response_body": "",
+      "classification": "noise",
+      "is_noise": true
+    },
+    {
+      "method": "GET",
+      "url": "https://hn.algolia.com/assets/app-abc123.js",
+      "request_headers": {},
+      "request_body": "",
+      "response_status": 200,
+      "response_content_type": "application/javascript",
+      "response_body": "",
+      "classification": "noise",
+      "is_noise": true
+    }
+  ]
+}
\ No newline at end of file
diff --git a/testdata/sniff/sample.har b/testdata/sniff/sample.har
new file mode 100644
index 00000000..7deca133
--- /dev/null
+++ b/testdata/sniff/sample.har
@@ -0,0 +1,408 @@
+{
+  "log": {
+    "browser": {
+      "name": "Chrome",
+      "version": "147.0.7727.24"
+    },
+    "creator": {
+      "name": "agent-browser",
+      "version": "0.23.0"
+    },
+    "entries": [
+      {
+        "_resourceType": "Image",
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Referer",
+              "value": ""
+            },
+            {
+              "name": "User-Agent",
+              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/1.1",
+          "method": "GET",
+          "queryString": [],
+          "url": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwcHgiICBoZWlnaHQ9IjIwMHB4IiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIGNsYXNzPSJsZHMtcm9sbGluZyIgc3R5bGU9ImJhY2tncm91bmQtaW1hZ2U6IG5vbmU7IGJhY2tncm91bmQtcG9zaXRpb246IGluaXRpYWwgaW5pdGlhbDsgYmFja2dyb3VuZC1yZXBlYXQ6IGluaXRpYWwgaW5pdGlhbDsiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIGZpbGw9Im5vbmUiIG5nLWF0dHItc3Ryb2tlPSJ7e2NvbmZpZy5jb2xvcn19IiBuZy1hdHRyLXN0cm9rZS13aWR0aD0ie3tjb25maWcud2lkdGh9fSIgbmctYXR0ci1yPSJ7e2NvbmZpZy5yYWRpdXN9fSIgbmctYXR0ci1zdHJva2UtZGFzaGFycmF5PSJ7e2NvbmZpZy5kYXNoYXJyYXl9fSIgc3Ryb2tlPSIjNTU1NTU1IiBzdHJva2Utd2lkdGg9IjEwIiByPSIzNSIgc3Ryb2tlLWRhc2hhcnJheT0iMTY0LjkzMzYxNDMxMzQ2NDE1IDU2Ljk3Nzg3MTQzNzgyMTM4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgY2FsY01vZGU9ImxpbmVhciIgdmFsdWVzPSIwIDUwIDUwOzM2MCA1MCA1MCIga2V5VGltZXM9IjA7MSIgZHVyPSIxcyIgYmVnaW49IjBzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPjwvY2lyY2xlPjwvc3ZnPgo="
+        },
+        "response": {
+          "bodySize": 0,
+          "content": {
+            "mimeType": "image/svg+xml",
+            "size": 0
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Content-Type",
+              "value": "image/svg+xml"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/1.1",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": "OK"
+        },
+        "startedDateTime": "2026-03-28T22:46:42.118844928Z",
+        "time": 0.0,
+        "timings": {
+          "receive": 0,
+          "send": 0,
+          "wait": 0
+        }
+      },
+      {
+        "_resourceType": "Document",
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Accept-Language",
+              "value": "en-US,en;q=0.9"
+            },
+            {
+              "name": "Upgrade-Insecure-Requests",
+              "value": "1"
+            },
+            {
+              "name": "User-Agent",
+              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36"
+            },
+            {
+              "name": "sec-ch-ua",
+              "value": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\""
+            },
+            {
+              "name": "sec-ch-ua-mobile",
+              "value": "?0"
+            },
+            {
+              "name": "sec-ch-ua-platform",
+              "value": "\"macOS\""
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "method": "GET",
+          "queryString": [],
+          "url": "https://httpbin.org/get"
+        },
+        "response": {
+          "bodySize": 1071,
+          "content": {
+            "mimeType": "application/json",
+            "size": 1071
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "access-control-allow-credentials",
+              "value": "true"
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "content-length",
+              "value": "931"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json"
+            },
+            {
+              "name": "date",
+              "value": "Sat, 28 Mar 2026 22:47:08 GMT"
+            },
+            {
+              "name": "server",
+              "value": "gunicorn/19.9.0"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": ""
+        },
+        "startedDateTime": "2026-03-28T22:47:08.268548096Z",
+        "time": 304.0609999927804,
+        "timings": {
+          "blocked": 0.115,
+          "receive": 0.4729999927803874,
+          "send": 0.09899999999999999,
+          "wait": 303.374
+        }
+      },
+      {
+        "_resourceType": "Other",
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Accept-Language",
+              "value": "en-US,en;q=0.9"
+            },
+            {
+              "name": "Referer",
+              "value": "https://httpbin.org/get"
+            },
+            {
+              "name": "User-Agent",
+              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36"
+            },
+            {
+              "name": "sec-ch-ua",
+              "value": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\""
+            },
+            {
+              "name": "sec-ch-ua-mobile",
+              "value": "?0"
+            },
+            {
+              "name": "sec-ch-ua-platform",
+              "value": "\"macOS\""
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "method": "GET",
+          "queryString": [],
+          "url": "https://httpbin.org/favicon.ico"
+        },
+        "response": {
+          "bodySize": 369,
+          "content": {
+            "mimeType": "text/html",
+            "size": 369
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "access-control-allow-credentials",
+              "value": "true"
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "content-length",
+              "value": "233"
+            },
+            {
+              "name": "content-type",
+              "value": "text/html"
+            },
+            {
+              "name": "date",
+              "value": "Sat, 28 Mar 2026 22:47:08 GMT"
+            },
+            {
+              "name": "server",
+              "value": "gunicorn/19.9.0"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "redirectURL": "",
+          "status": 404,
+          "statusText": ""
+        },
+        "startedDateTime": "2026-03-28T22:47:08.59478912Z",
+        "time": 111.62999998887442,
+        "timings": {
+          "blocked": 0.222,
+          "receive": 0.3539999888744205,
+          "send": 0.13699999999999998,
+          "wait": 110.917
+        }
+      },
+      {
+        "_resourceType": "Document",
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Accept-Language",
+              "value": "en-US,en;q=0.9"
+            },
+            {
+              "name": "Upgrade-Insecure-Requests",
+              "value": "1"
+            },
+            {
+              "name": "User-Agent",
+              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36"
+            },
+            {
+              "name": "sec-ch-ua",
+              "value": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\""
+            },
+            {
+              "name": "sec-ch-ua-mobile",
+              "value": "?0"
+            },
+            {
+              "name": "sec-ch-ua-platform",
+              "value": "\"macOS\""
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "method": "GET",
+          "queryString": [],
+          "url": "https://httpbin.org/headers"
+        },
+        "response": {
+          "bodySize": 988,
+          "content": {
+            "mimeType": "application/json",
+            "size": 988
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "access-control-allow-credentials",
+              "value": "true"
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "content-length",
+              "value": "848"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json"
+            },
+            {
+              "name": "date",
+              "value": "Sat, 28 Mar 2026 22:47:11 GMT"
+            },
+            {
+              "name": "server",
+              "value": "gunicorn/19.9.0"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": ""
+        },
+        "startedDateTime": "2026-03-28T22:47:10.793214976Z",
+        "time": 326.80599998972565,
+        "timings": {
+          "blocked": 0.19,
+          "receive": 1.8179999897256494,
+          "send": 0.17099999999999999,
+          "wait": 324.627
+        }
+      },
+      {
+        "_resourceType": "Document",
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "Accept-Language",
+              "value": "en-US,en;q=0.9"
+            },
+            {
+              "name": "Upgrade-Insecure-Requests",
+              "value": "1"
+            },
+            {
+              "name": "User-Agent",
+              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36"
+            },
+            {
+              "name": "sec-ch-ua",
+              "value": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\""
+            },
+            {
+              "name": "sec-ch-ua-mobile",
+              "value": "?0"
+            },
+            {
+              "name": "sec-ch-ua-platform",
+              "value": "\"macOS\""
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "method": "GET",
+          "queryString": [],
+          "url": "https://httpbin.org/ip"
+        },
+        "response": {
+          "bodySize": 172,
+          "content": {
+            "mimeType": "application/json",
+            "size": 172
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "access-control-allow-credentials",
+              "value": "true"
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "content-length",
+              "value": "33"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json"
+            },
+            {
+              "name": "date",
+              "value": "Sat, 28 Mar 2026 22:47:12 GMT"
+            },
+            {
+              "name": "server",
+              "value": "gunicorn/19.9.0"
+            }
+          ],
+          "headersSize": -1,
+          "httpVersion": "HTTP/2.0",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": ""
+        },
+        "startedDateTime": "2026-03-28T22:47:12.3217152Z",
+        "time": 145.02099997888504,
+        "timings": {
+          "blocked": 0.201,
+          "receive": 0.3029999788850546,
+          "send": 0.16099999999999998,
+          "wait": 144.356
+        }
+      }
+    ],
+    "version": "1.2"
+  }
+}
\ No newline at end of file

← c779648a feat(ci): automated releases, linting, and commit convention  ·  back to Cli Printing Press  ·  feat(websniff): add API endpoint classifier with analytics b 07f158f6 →