feat(world/finance): live alt-asset feeds — Christie's auctions + LuxuryEstate

The finance-terminal "Art & Collectibles — Auctions" and "Luxury Real Estate"
panels rendered "live feed pending" because /v1/world/auctions and
/v1/world/luxury-realestate did not exist. Make them return REAL data.

world-gw (cmd/world) now scrapes two public sources server-side, hourly, and
serves every caller from the SWR cache:

- GET /v1/world/auctions — Christie's public results: realized SALE totals
  (title, inferred category, price+currency, date, location, image, link).
  Sotheby's (the requested headline) gates realized prices behind a login —
  its /data/api/asset.saleresults.json returns 401 "Not signed in" — so the
  honest public major house is Christie's, exactly the fallback the panel names.

- GET /v1/world/luxury-realestate — LuxuryEstate.com listings (title, location,
  price+currency, type, image, link). JamesEdition (the requested headline)
  sits behind a Cloudflare JS challenge that blocks datacenter egress, so it
  cannot be fetched from the DOKS pod; LuxuryEstate is reachable and
  robots-permitted.

Robust + respectful + honest:
- One request per source per hour (StartAltAssets warmer + 1h TTL / 24h stale).
- On a source failure: serve last-good; with no cache yet, honest empty
  {items:[]} (panel shows "live feed pending"). NEVER fabricated data.
- Payload is exactly {items: AltFeedItem[]} — matches AltFeedPanel.ts
  field-for-field. Source attributed per feed; SPA source labels updated to the
  real sources (Christie's / LuxuryEstate).
- Parsers extract the embedded results JSON (robust to DOM churn); unit-tested
  against real trimmed captures in testdata/.
This commit is contained in:
zeekay
2026-07-21 22:05:09 -07:00
parent 38531fba17
commit 630236ff4a
10 changed files with 693 additions and 14 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to World Monitor are documented here.
## [2.4.43]
### Added
- **Finance-terminal alt-asset feeds are live** (`?variant=finance`). The "Art & Collectibles — Auctions" and "Luxury Real Estate" panels now render REAL data instead of "live feed pending":
- `GET /v1/world/auctions` — Christie's public auction results (realized **sale totals**: title, inferred category, price + currency, sale date, location, image, link). Sotheby's gates realized prices behind a login (its sale-results API returns `401 Not signed in`), so the honest public major house is the source.
- `GET /v1/world/luxury-realestate` — LuxuryEstate.com luxury listings (title, location, price + currency, type, image, link). JamesEdition sits behind a Cloudflare JS challenge that blocks datacenter egress, so it cannot be fetched from the pod.
- Both are scraped server-side (`internal/world/handlers_altassets.go`) at most **once an hour** and served from cache to every caller (respectful). On a source failure the last-good copy is served; with no cache yet, an honest empty `{items:[]}` (the panel shows "live feed pending") — **never fabricated data**. The payload attributes its source per feed.
## [2.4.19]
### Fixed
+1 -1
View File
@@ -1 +1 @@
2.4.37
2.4.43
+1
View File
@@ -50,6 +50,7 @@ func main() {
srv.StartModel(rootCtx) // continuously-folded world-state engine
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
srv.StartFund(rootCtx) // autonomous PAPER-only multi-asset fund brain
srv.StartAltAssets(rootCtx) // hourly Christie's auctions + LuxuryEstate warmer
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
+489
View File
@@ -0,0 +1,489 @@
package world
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// Alt-asset feeds behind the finance-terminal "Art & Collectibles — Auctions"
// and "Luxury Real Estate" panels (src/components/finance/AltFeedPanel.ts).
// Neither headline source is usable server-side, so each panel is served by a
// reachable, public, respectfully-scraped equivalent — fetched once an hour and
// served from cache to every caller. Both degrade to an honest empty
// {items:[]} (the SPA then renders "live feed pending") and NEVER fabricate a
// row.
//
// /v1/world/auctions → Christie's public results (realized SALE
// totals). Sotheby's — the requested headline — gates realized prices behind
// a login (its /data/api/asset.saleresults.json returns 401 "Not signed in"),
// so the honest public major house is Christie's, exactly the fallback the
// panel names ("Sotheby's / major-house results").
//
// /v1/world/luxury-realestate → LuxuryEstate.com listings. JamesEdition — the
// requested headline — sits behind a Cloudflare JS challenge that blocks
// datacenter egress (the pod runs in DOKS), so it cannot be fetched
// server-side; LuxuryEstate is a reachable, robots-permitted real source of
// the same thing (luxury listings with a price, place, type, image, link).
//
// The SPA reads exactly {items: AltFeedItem[]} where AltFeedItem is
// {title, subtitle?, price?, href?, imageUrl?, meta?}. Extra top-level fields
// (source, asOf, count, pending) are ignored by the panel and kept for
// humans/debugging + honest attribution.
const (
christiesResultsURL = "https://www.christies.com/en/results"
luxuryEstateURL = "https://www.luxuryestate.com/united-states?currency=USD"
auctionsCacheKey = "auctions:v1"
luxuryRealtyCacheKey = "luxury-realestate:v1"
altAssetsTTL = time.Hour // fresh window: at most one scrape/source/hour
altAssetsStale = 24 * time.Hour // serve last-good for a day through any source outage
altAssetsRefresh = time.Hour // background warmer cadence
altFeedMaxItems = 12 // the panel itself renders at most 12
altFeedCacheControl = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"
)
// altFeedItem mirrors the SPA's AltFeedItem exactly (see AltFeedPanel.ts). JSON
// tags match field-for-field; omitempty keeps optional fields absent, not empty.
type altFeedItem struct {
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Price string `json:"price,omitempty"`
Href string `json:"href,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Meta string `json:"meta,omitempty"`
}
// handleAuctions serves /v1/world/auctions (Christie's realized sale totals).
func (s *Server) handleAuctions(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, auctionsCacheKey, altFeedCacheControl, altAssetsTTL, altAssetsStale,
func(ctx context.Context) (any, error) { return s.computeAuctions(ctx) },
func(w http.ResponseWriter, _ error) { writeAltFeedPending(w, "Christie's") })
}
// handleLuxuryRealestate serves /v1/world/luxury-realestate (LuxuryEstate listings).
func (s *Server) handleLuxuryRealestate(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, luxuryRealtyCacheKey, altFeedCacheControl, altAssetsTTL, altAssetsStale,
func(ctx context.Context) (any, error) { return s.computeLuxuryRealestate(ctx) },
func(w http.ResponseWriter, _ error) { writeAltFeedPending(w, "LuxuryEstate") })
}
// writeAltFeedPending is the honest cold-miss fallback (no cache yet AND the
// source is unreachable): an empty item list with a pending flag. The SPA
// renders "Live feed pending" for an empty list. Never a fabricated row, never a
// 5xx — so the routes smoke sweep and the panel both stay green.
func writeAltFeedPending(w http.ResponseWriter, source string) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "source": source, "pending": true, "count": 0,
"items": []altFeedItem{},
})
}
// altFeedPayload is the shaped, cacheable success value. produce returns an error
// (not this) when it parses zero rows, so an empty list is never cached — the
// caller then serves the last-good copy or the pending fallback.
func altFeedPayload(source string, items []altFeedItem) map[string]any {
if len(items) > altFeedMaxItems {
items = items[:altFeedMaxItems]
}
return map[string]any{
"asOf": nowISO(), "source": source, "count": len(items), "items": items,
}
}
// ── auctions: Christie's ──────────────────────────────────────────────────────
func (s *Server) computeAuctions(ctx context.Context) (any, error) {
body, status, err := s.get(ctx, christiesResultsURL, map[string]string{
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": browserUA,
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("christies status %d", status)
}
items := parseChristiesAuctions(body)
if len(items) == 0 {
return nil, errUnavailable
}
return altFeedPayload("Christie's", items), nil
}
// christieSale is the subset of Christie's embedded results JSON we render. Only
// scalars are unmarshalled; the image URL is pulled from the raw object bytes
// (its nested srcset schema is picked over by regex, so a schema tweak can't
// break the whole parse).
type christieSale struct {
TitleTxt string `json:"title_txt"`
SubtitleTxt string `json:"subtitle_txt"`
DateDisplayTxt string `json:"date_display_txt"`
LocationTxt string `json:"location_txt"`
SaleTotalValueTxt string `json:"sale_total_value_txt"`
LandingURL string `json:"landing_url"`
}
// realizedTotalRe matches a realized total like "USD 1,135,380" / "HKD 10,319,750"
// — a 23 letter ISO currency code, a space, then a grouped amount. It filters
// out upcoming/estimate-only cards that carry no realized figure.
var realizedTotalRe = regexp.MustCompile(`^[A-Z]{2,3}\s[\d,]+$`)
// christiesImgRe matches a Christie's sale image URL and captures its width, so
// pickChristiesImage can choose a mid-size variant.
var christiesImgRe = regexp.MustCompile(`https://www\.christies\.com/img/SaleImages/[^"\\ ]+?\.jpg\?w=(\d+)`)
// parseChristiesAuctions extracts the embedded "events":[…] results array from
// the SSR HTML and maps each realized sale to an altFeedItem. Malformed input
// yields an empty slice, never a panic.
func parseChristiesAuctions(body []byte) []altFeedItem {
arr := extractBracketed(body, `"events":`, '[', ']')
if arr == nil {
return nil
}
var sales []json.RawMessage
if err := json.Unmarshal(arr, &sales); err != nil {
return nil
}
out := make([]altFeedItem, 0, len(sales))
for _, raw := range sales {
var sale christieSale
if err := json.Unmarshal(raw, &sale); err != nil {
continue
}
price := strings.TrimSpace(sale.SaleTotalValueTxt)
title := strings.TrimSpace(sale.TitleTxt)
if title == "" || !realizedTotalRe.MatchString(price) {
continue // upcoming / estimate-only / malformed — not a realized result
}
cat := classifyAuction(title + " " + sale.SubtitleTxt)
item := altFeedItem{
Title: title,
Price: price,
Href: absURL(sale.LandingURL, "https://www.christies.com"),
ImageURL: pickChristiesImage(raw),
Subtitle: "Christie's",
}
if loc := strings.TrimSpace(sale.LocationTxt); loc != "" {
item.Subtitle = "Christie's · " + loc
}
if d := strings.TrimSpace(sale.DateDisplayTxt); d != "" {
item.Meta = cat + " · " + d
} else {
item.Meta = cat
}
out = append(out, item)
}
return out
}
// pickChristiesImage returns the sale image whose width is closest to 640px (a
// crisp thumbnail without pulling the largest hero), scanning the raw object.
func pickChristiesImage(raw []byte) string {
best := ""
bestDiff := 1 << 30
for _, m := range christiesImgRe.FindAllSubmatch(raw, -1) {
w, err := strconv.Atoi(string(m[1]))
if err != nil || w <= 0 {
continue
}
d := w - 640
if d < 0 {
d = -d
}
if d < bestDiff {
bestDiff, best = d, string(m[0])
}
}
return best
}
// auctionCategory pairs a substring probe (lowercased title+subtitle) with the
// category label shown to the user. Ordered: first match wins.
var auctionCategories = []struct{ probe, label string }{
{"cellar", "Wine & Spirits"}, {"wine", "Wine & Spirits"}, {"whisky", "Wine & Spirits"},
{"whiskey", "Wine & Spirits"}, {"spirit", "Wine & Spirits"}, {"vintage", "Wine & Spirits"},
{"watch", "Watches"}, {"rolex", "Watches"}, {"patek", "Watches"}, {"horolog", "Watches"},
{"jewel", "Jewellery"}, {"diamond", "Jewellery"}, {"gemstone", "Jewellery"},
{"handbag", "Handbags & Accessories"}, {"hermès", "Handbags & Accessories"},
{"hermes", "Handbags & Accessories"}, {"birkin", "Handbags & Accessories"},
{"coin", "Gold & Coins"}, {"numismat", "Gold & Coins"}, {"bullion", "Gold & Coins"},
{"gold", "Gold & Coins"},
}
// classifyAuction infers a human category from the sale name (the source has no
// stable category label). It is a best-effort hint, never a fabricated fact;
// anything unmatched is the honest generic "Art & Collectibles".
func classifyAuction(text string) string {
t := strings.ToLower(text)
for _, c := range auctionCategories {
if strings.Contains(t, c.probe) {
return c.label
}
}
return "Art & Collectibles"
}
// ── luxury real estate: LuxuryEstate ──────────────────────────────────────────
func (s *Server) computeLuxuryRealestate(ctx context.Context) (any, error) {
body, status, err := s.get(ctx, luxuryEstateURL, map[string]string{
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": browserUA,
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("luxuryestate status %d", status)
}
items := parseLuxuryEstateListings(body)
if len(items) == 0 {
return nil, errUnavailable
}
return altFeedPayload("LuxuryEstate", items), nil
}
// luxuryListing is the subset of a LuxuryEstate propertiesList entry we render.
type luxuryListing struct {
Title string `json:"title"`
Label string `json:"label"`
Type string `json:"type"`
URL string `json:"url"`
Surface string `json:"surface"`
Bedrooms string `json:"bedrooms"`
PictureThumb string `json:"pictureThumb"`
Picture string `json:"picture"`
Price struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
CurrencySymbol string `json:"currencySymbol"`
} `json:"price"`
GeoInfo struct {
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
} `json:"geoInfo"`
}
// parseLuxuryEstateListings extracts the embedded "propertiesList":[…] array and
// maps each listing to an altFeedItem. Malformed input yields an empty slice.
func parseLuxuryEstateListings(body []byte) []altFeedItem {
arr := extractBracketed(body, `"propertiesList":`, '[', ']')
if arr == nil {
return nil
}
var listings []luxuryListing
if err := json.Unmarshal(arr, &listings); err != nil {
return nil
}
out := make([]altFeedItem, 0, len(listings))
for _, l := range listings {
title := strings.TrimSpace(l.Title)
if title == "" {
title = strings.TrimSpace(l.Label)
}
if title == "" {
continue
}
img := strings.TrimSpace(l.PictureThumb)
if img == "" {
img = strings.TrimSpace(l.Picture)
}
out = append(out, altFeedItem{
Title: title,
Price: luxuryPrice(l),
Subtitle: luxuryLocation(l),
Href: absURL(l.URL, "https://www.luxuryestate.com"),
ImageURL: absScheme(img),
Meta: luxuryMeta(l),
})
}
return out
}
// luxuryPrice renders the listing's real price + currency, preferring the
// currency symbol ("US$5,950,000"), falling back to the currency code.
func luxuryPrice(l luxuryListing) string {
amt := strings.TrimSpace(l.Price.Amount)
if amt == "" {
return ""
}
if sym := strings.TrimSpace(l.Price.CurrencySymbol); sym != "" {
return sym + amt
}
if c := strings.TrimSpace(l.Price.Currency); c != "" {
return amt + " " + c
}
return amt
}
// luxuryLocation joins city with region (else country): "Banner Elk, North Carolina".
func luxuryLocation(l luxuryListing) string {
parts := make([]string, 0, 2)
if c := strings.TrimSpace(l.GeoInfo.City); c != "" {
parts = append(parts, c)
}
if r := strings.TrimSpace(l.GeoInfo.Region); r != "" {
parts = append(parts, r)
} else if c := strings.TrimSpace(l.GeoInfo.Country); c != "" {
parts = append(parts, c)
}
return strings.Join(parts, ", ")
}
// luxuryMeta renders "House · 5 bd · 655 m²" from whatever fields are present.
func luxuryMeta(l luxuryListing) string {
parts := make([]string, 0, 3)
if t := strings.TrimSpace(l.Type); t != "" {
parts = append(parts, t)
}
if b := strings.TrimSpace(l.Bedrooms); b != "" && b != "0" {
parts = append(parts, b+" bd")
}
if s := strings.TrimSpace(l.Surface); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, " · ")
}
// ── shared parsing helpers ────────────────────────────────────────────────────
// extractBracketed returns the substring from the first `open` bracket that
// follows key through its matching `close` bracket, honoring JSON string escapes
// so brackets inside string values don't unbalance the scan. It is a targeted
// extractor for one embedded array/object inside a larger HTML/JS document — so
// the whole page never has to be parsed. Only whitespace or a colon may sit
// between key and the bracket. Returns nil when absent or unbalanced.
func extractBracketed(body []byte, key string, open, close byte) []byte {
i := bytes.Index(body, []byte(key))
if i < 0 {
return nil
}
j := i + len(key)
for j < len(body) && body[j] != open {
switch body[j] {
case ' ', '\t', '\n', '\r', ':':
j++
default:
return nil
}
}
if j >= len(body) {
return nil
}
depth, inStr, esc := 0, false, false
for k := j; k < len(body); k++ {
c := body[k]
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = !inStr
case inStr:
// inside a string: ignore brackets
case c == open:
depth++
case c == close:
depth--
if depth == 0 {
return body[j : k+1]
}
}
}
return nil
}
// absURL resolves a possibly-relative URL against base: "//host/…" → https://host/…,
// "/path" → base+/path, anything absolute is returned unchanged.
func absURL(u, base string) string {
u = strings.TrimSpace(u)
switch {
case u == "":
return ""
case strings.HasPrefix(u, "//"):
return "https:" + u
case strings.HasPrefix(u, "/"):
return base + u
default:
return u
}
}
// absScheme prepends https: to a scheme-relative ("//host/…") URL; leaves an
// already-absolute URL untouched.
func absScheme(u string) string {
u = strings.TrimSpace(u)
if strings.HasPrefix(u, "//") {
return "https:" + u
}
return u
}
// ── background warmer ─────────────────────────────────────────────────────────
// StartAltAssets warms the two alt-asset feeds on boot and refreshes them every
// hour until ctx is cancelled, so the panels serve a fresh cached hit instead of
// blocking on a cold scrape, and stay fresh even with sporadic traffic. These
// sources change at most daily; hourly is respectful — one request per source
// per hour — and the SWR cache keeps serving the last-good copy between
// refreshes and through any source outage. Warmer and handler share the same
// cache key + produce path + TTLs (the constants above), so they can never
// drift. Call once from main after the server is built.
func (s *Server) StartAltAssets(ctx context.Context) {
go func() {
s.warmAltAssets(ctx)
t := time.NewTicker(altAssetsRefresh)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.warmAltAssets(ctx)
}
}
}()
}
// warmAltAssets (re)produces both feeds and stores each under the exact key its
// handler reads. A failure is logged and skipped; the next cycle retries and the
// last-good value keeps serving in the meantime.
func (s *Server) warmAltAssets(ctx context.Context) {
specs := []struct {
key string
produce func(context.Context) (any, error)
}{
{auctionsCacheKey, s.computeAuctions},
{luxuryRealtyCacheKey, s.computeLuxuryRealestate},
}
for _, spec := range specs {
if ctx.Err() != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 24*time.Second)
v, err := spec.produce(wctx)
cancel()
if err != nil {
logf("world-altassets: %s warm failed: %v", spec.key, err)
continue
}
s.cache.Set(spec.key, v, altAssetsTTL, altAssetsStale)
}
}
+166
View File
@@ -0,0 +1,166 @@
package world
import (
"encoding/json"
"net/http/httptest"
"os"
"strings"
"testing"
)
// The parsers are tested against REAL (trimmed) source captures in testdata, so
// a schema change at the source is caught here rather than in production. No
// network — deterministic. Live fetching is exercised by the routes smoke sweep
// (routes_test.go).
func TestParseChristiesAuctions(t *testing.T) {
body, err := os.ReadFile("testdata/christies_results.html")
if err != nil {
t.Fatal(err)
}
items := parseChristiesAuctions(body)
if len(items) != 2 {
t.Fatalf("want 2 items, got %d: %+v", len(items), items)
}
got := items[0]
want := altFeedItem{
Title: "The Cellar of an Obsessive Collector Part III: Online",
Subtitle: "Christie's · Hong Kong",
Price: "HKD 10,319,750",
Href: "https://onlineonly.christies.com/sso?SaleID=31386&SaleNumber=24946",
ImageURL: "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600", // closest to 640
Meta: "Wine & Spirits · 9 21 July", // "Cellar" → wine
}
if got != want {
t.Errorf("item0 mismatch:\n got %+v\nwant %+v", got, want)
}
// A non-keyword sale falls back to the honest generic category.
if !strings.HasPrefix(items[1].Meta, "Art & Collectibles") {
t.Errorf("item1 category: want 'Art & Collectibles …', got %q", items[1].Meta)
}
if items[1].Price != "USD 1,135,380" {
t.Errorf("item1 price: got %q", items[1].Price)
}
}
func TestParseLuxuryEstateListings(t *testing.T) {
body, err := os.ReadFile("testdata/luxuryestate_results.html")
if err != nil {
t.Fatal(err)
}
items := parseLuxuryEstateListings(body)
if len(items) != 2 {
t.Fatalf("want 2 items, got %d: %+v", len(items), items)
}
got := items[0]
want := altFeedItem{
Title: "Luxury home in Banner Elk, Avery County",
Subtitle: "Banner Elk, North Carolina",
Price: "US$5,950,000",
Href: "https://www.luxuryestate.com/p131998951-luxury-home-for-sale-banner-elk",
ImageURL: "https://pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg",
Meta: "House · 5 bd · 655 m²",
}
if got != want {
t.Errorf("item0 mismatch:\n got %+v\nwant %+v", got, want)
}
if items[1].Price != "US$2,995,000" || items[1].Meta != "House · 4 bd · 403 m²" {
t.Errorf("item1: got price=%q meta=%q", items[1].Price, items[1].Meta)
}
}
func TestClassifyAuction(t *testing.T) {
cases := map[string]string{
"The Cellar of an Obsessive Collector": "Wine & Spirits",
"Fine and Rare Wines": "Wine & Spirits",
"Important Watches": "Watches",
"Magnificent Jewels and Diamonds": "Jewellery",
"Handbags & Accessories: Online": "Handbags & Accessories",
"Gold Coins of the Ancient World": "Gold & Coins",
"Tom Wesselmann: American Beauty": "Art & Collectibles", // no keyword → generic
}
for in, want := range cases {
if got := classifyAuction(in); got != want {
t.Errorf("classifyAuction(%q) = %q, want %q", in, got, want)
}
}
}
func TestExtractBracketed(t *testing.T) {
cases := []struct {
name, body, key string
open, close byte
want string
}{
{"array", `x "events": [1,2,3] y`, `"events":`, '[', ']', `[1,2,3]`},
{"nested-obj", `p "a":{"b":{"c":1}} q`, `"a":`, '{', '}', `{"b":{"c":1}}`},
{"bracket-in-string", `"k": ["a]b", "c"]`, `"k":`, '[', ']', `["a]b", "c"]`},
{"escaped-quote-in-string", `"k": ["a\"]b"]`, `"k":`, '[', ']', `["a\"]b"]`},
{"missing-key", `"other": [1]`, `"k":`, '[', ']', ""},
{"unbalanced", `"k": [1,2`, `"k":`, '[', ']', ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(extractBracketed([]byte(c.body), c.key, c.open, c.close))
if got != c.want {
t.Errorf("extractBracketed = %q, want %q", got, c.want)
}
})
}
}
// TestAltFeedItemMatchesSPAShape locks the JSON contract with the SPA's
// AltFeedItem interface (AltFeedPanel.ts): the exact key names, and omitempty so
// an item with only a title carries no empty optional keys.
func TestAltFeedItemMatchesSPAShape(t *testing.T) {
full, _ := json.Marshal(altFeedItem{
Title: "T", Subtitle: "S", Price: "P", Href: "H", ImageURL: "I", Meta: "M",
})
for _, k := range []string{`"title"`, `"subtitle"`, `"price"`, `"href"`, `"imageUrl"`, `"meta"`} {
if !strings.Contains(string(full), k) {
t.Errorf("marshalled item missing key %s: %s", k, full)
}
}
min, _ := json.Marshal(altFeedItem{Title: "only"})
if string(min) != `{"title":"only"}` {
t.Errorf("omitempty broken: %s", min)
}
}
// TestAltFeedPendingShape: the cold-miss fallback carries an empty ARRAY (never
// null) so the SPA's Array.isArray/length check renders "live feed pending"
// instead of throwing, and never a fabricated row.
func TestAltFeedPendingShape(t *testing.T) {
rec := httptest.NewRecorder()
writeAltFeedPending(rec, "Christie's")
var out struct {
Items []altFeedItem `json:"items"`
Pending bool `json:"pending"`
Source string `json:"source"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Items == nil || len(out.Items) != 0 {
t.Errorf("items must be empty array, got %#v", out.Items)
}
if !out.Pending || out.Source != "Christie's" {
t.Errorf("want pending+source, got %+v", out)
}
if !strings.Contains(rec.Body.String(), `"items":[]`) {
t.Errorf("items must serialize as [] not null: %s", rec.Body.String())
}
}
// TestParseGarbageNoPanic: malformed / empty input yields an empty slice, never
// a panic — the source can rot without taking the endpoint down.
func TestParseGarbageNoPanic(t *testing.T) {
for _, b := range [][]byte{nil, {}, []byte("not html"), []byte(`{"events": "oops"}`), []byte(`"propertiesList": [`)} {
if got := parseChristiesAuctions(b); len(got) != 0 {
t.Errorf("christies garbage → %d items", len(got))
}
if got := parseLuxuryEstateListings(b); len(got) != 0 {
t.Errorf("luxe garbage → %d items", len(got))
}
}
}
+7
View File
@@ -54,6 +54,13 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/layoffs", s.handleLayoffs)
mux.HandleFunc("/v1/world/congress", s.handleCongress)
// alt assets — art/collectibles auction results (Christie's public realized
// sale totals) + luxury real-estate listings (LuxuryEstate). Scraped hourly +
// cached; honest empty {items:[]} on a source failure, never fabricated. These
// power the finance-terminal AltFeed panels (src/components/finance/AltFeedPanel.ts).
mux.HandleFunc("/v1/world/auctions", s.handleAuctions)
mux.HandleFunc("/v1/world/luxury-realestate", s.handleLuxuryRealestate)
// flights / geo / hazards
mux.HandleFunc("/v1/world/opensky", s.handleOpenSky)
mux.HandleFunc("/v1/world/ais-snapshot", s.handleAISSnapshot)
+1
View File
@@ -0,0 +1 @@
<html><body><script>window.chrComponents.results={"events":[{"title_txt": "The Cellar of an Obsessive Collector Part III: Online", "subtitle_txt": "Online Auction 24946 | CLOSED", "date_display_txt": "9 \u2013 21 July", "location_txt": "Hong Kong", "sale_total_value_txt": "HKD 10,319,750", "landing_url": "https://onlineonly.christies.com/sso?SaleID=31386&SaleNumber=24946", "image": {"alt_text": "The Cellar of an Obsessive Collector Part III: Online", "sizes": [{"name": "md", "srcset": [{"width": 400, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=400"}, {"width": 800, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=800"}]}, {"name": "xl", "srcset": [{"width": 600, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600"}]}]}}, {"title_txt": "American Beauty: Tom Wesselmann Online", "subtitle_txt": "Online Auction 24850 | CLOSED", "date_display_txt": "1 \u2013 17 July", "location_txt": "New York", "sale_total_value_txt": "USD 1,135,380", "landing_url": "https://onlineonly.christies.com/sso?SaleID=31337&SaleNumber=24850", "image": {"alt_text": "American Beauty: Tom Wesselmann Online", "sizes": [{"name": "md", "srcset": [{"width": 400, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=400"}, {"width": 800, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=800"}]}, {"name": "xl", "srcset": [{"width": 600, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600"}]}]}}]};</script></body></html>
+1
View File
@@ -0,0 +1 @@
<html><body><script type="application/json">{"propertiesList": [{"title": "Luxury home in Banner Elk, Avery County", "label": "Luxury home", "type": "House", "url": "/p131998951-luxury-home-for-sale-banner-elk", "surface": "655 m\u00b2", "bedrooms": "5", "pictureThumb": "//pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg", "picture": "//pic.le-cdn.com/thumbs/272x204/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg", "price": {"amount": "5,950,000", "currency": "US$ USD", "currencySymbol": "US$", "raw": 5950000}, "geoInfo": {"country": "United States", "region": "North Carolina", "province": "Avery County", "city": "Banner Elk"}}, {"title": "Luxury home in Littleton, Halifax County", "label": "Luxury home", "type": "House", "url": "/p131999422-luxury-home-for-sale-littleton", "surface": "403 m\u00b2", "bedrooms": "4", "pictureThumb": "//pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-be260000000007de000169127725-131999422.jpg", "picture": "//pic.le-cdn.com/thumbs/272x204/04/1/properties/Property-be260000000007de000169127725-131999422.jpg", "price": {"amount": "2,995,000", "currency": "US$ USD", "currencySymbol": "US$", "raw": 2995000}, "geoInfo": {"country": "United States", "region": "North Carolina", "province": "Halifax County", "city": "Littleton"}}]}</script></body></html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@hanzo/world",
"description": "Hanzo World — real-time global intelligence dashboard.",
"private": true,
"version": "2.4.42",
"version": "2.4.43",
"type": "module",
"scripts": {
"lint:md": "markdownlint-cli2 '**/*.md'",
+17 -12
View File
@@ -1,11 +1,16 @@
// Alternative-asset feed panel (art auctions, luxury real estate).
//
// Sotheby's / collectibles auctions and JamesEdition luxury listings have no
// CORS-open public API, so the live data is served by the world backend
// (world-gw) which scrapes + caches them server-side. This panel fetches that
// endpoint and renders real items; if the backend hasn't been provisioned yet
// (or is unreachable) it shows an honest "live feed connecting" state rather
// than any fabricated listing. One component, configured per feed — DRY.
// High-end auction results and luxury real-estate listings have no CORS-open
// public API, so the live data is served by the world backend (world-gw =
// cmd/world) which scrapes + caches the real public source hourly, server-side.
// Auctions come from Christie's public "results" (realized sale totals) —
// Sotheby's gates realized prices behind a login, so the public major house is
// the honest source. Luxury real estate comes from LuxuryEstate.com — JamesEdition
// sits behind a Cloudflare challenge that blocks datacenter egress, so it can't
// be fetched from the pod. This panel fetches the endpoint and renders real
// items; if the backend hasn't been provisioned yet (or is unreachable) it shows
// an honest "live feed connecting" state rather than any fabricated listing.
// One component, configured per feed — DRY.
import { escapeHtml } from '@/utils/sanitize';
@@ -93,9 +98,9 @@ export class AuctionsPanel {
private p = new AltFeedPanel({
title: 'Art & Collectibles — Auctions',
endpoint: '/v1/world/auctions',
sourceUrl: 'https://www.sothebys.com/en/results',
sourceLabel: "Sotheby's",
emptyHint: 'Recent Sothebys / major-house results stream in here once the world auctions feed is live.',
sourceUrl: 'https://www.christies.com/en/results',
sourceLabel: "Christie's",
emptyHint: 'Recent Christies / major-house realized results stream in here once the world auctions feed is live.',
});
getElement(): HTMLElement { return this.p.getElement(); }
}
@@ -104,9 +109,9 @@ export class LuxuryRealEstatePanel {
private p = new AltFeedPanel({
title: 'Luxury Real Estate',
endpoint: '/v1/world/luxury-realestate',
sourceUrl: 'https://www.jamesedition.com/real_estate',
sourceLabel: 'JamesEdition',
emptyHint: 'Featured JamesEdition luxury listings stream in here once the world real-estate feed is live.',
sourceUrl: 'https://www.luxuryestate.com/',
sourceLabel: 'LuxuryEstate',
emptyHint: 'Featured LuxuryEstate luxury listings stream in here once the world real-estate feed is live.',
});
getElement(): HTMLElement { return this.p.getElement(); }
}