catalogue images
This commit is contained in:
@@ -83,3 +83,74 @@ func TestCoreColumnsAreAlwaysSelectedPlainly(t *testing.T) {
|
|||||||
t.Errorf("core columns should lead the select list, got:\n%s", got)
|
t.Errorf("core columns should lead the select list, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Where product photos come from.
|
||||||
|
|
||||||
|
The owning team records what its image search found, per product, in
|
||||||
|
`image_url` and `image_urls`. Most of those are EXTERNAL — bigbasket, amazon, a
|
||||||
|
shop's own CDN — and only a subset were ever mirrored into our bucket. Reading
|
||||||
|
the bucket alone found photos for 2 of britannia's 6 products while they had
|
||||||
|
URLs for all 6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestJsonImageListIsRead(t *testing.T) {
|
||||||
|
// The shape their API returns today.
|
||||||
|
raw := `["https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg",` +
|
||||||
|
`"https://m.media-amazon.com/images/I/71n1Q3cQL3L.jpg"]`
|
||||||
|
got := parseImageList(raw)
|
||||||
|
if len(got) != 2 || got[0] != "https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg" {
|
||||||
|
t.Fatalf("json list not parsed: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same column stored as a Postgres text[] rather than jsonb. Which one it
|
||||||
|
// is belongs to the owning team, and this side should not break when it moves.
|
||||||
|
func TestPostgresArrayImageListIsRead(t *testing.T) {
|
||||||
|
got := parseImageList(`{"https://a.example/1.jpg","https://b.example/2.jpg"}`)
|
||||||
|
if len(got) != 2 || got[1] != "https://b.example/2.jpg" {
|
||||||
|
t.Fatalf("pg array not parsed: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyImageListsAreNotPhotos(t *testing.T) {
|
||||||
|
for _, raw := range []string{"", " ", "{}", "[]", "null"} {
|
||||||
|
if got := parseImageList(raw); len(got) != 0 {
|
||||||
|
t.Errorf("%q should yield no photos, got %#v", raw, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed value must cost the photos, not the product.
|
||||||
|
func TestMalformedImageListDoesNotBreakTheRow(t *testing.T) {
|
||||||
|
if got := parseImageList(`["unterminated`); len(got) != 0 {
|
||||||
|
t.Errorf("expected no photos from malformed json, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListedUrlWinsAndDuplicatesCollapse(t *testing.T) {
|
||||||
|
row := catalogueProductRow{
|
||||||
|
ImageID: "britannia_britannia_marie_gold_250g",
|
||||||
|
ImageURL: "https://a.example/1.jpg",
|
||||||
|
ImageURLs: `["https://a.example/1.jpg","https://a.example/2.jpg"]`,
|
||||||
|
}
|
||||||
|
got := imagesFor("britannia", row)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("the primary url repeated in the list should appear once: %#v", got)
|
||||||
|
}
|
||||||
|
if got[0] != "https://a.example/1.jpg" || got[1] != "https://a.example/2.jpg" {
|
||||||
|
t.Errorf("order should follow image_urls: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no URLs recorded, the bucket listing is still consulted — it is the only
|
||||||
|
// source for anything ingested before these columns existed.
|
||||||
|
func TestNoRecordedUrlsFallsBackToTheBucket(t *testing.T) {
|
||||||
|
row := catalogueProductRow{ImageID: "cadbury_cadbury_5_star_5_gm"}
|
||||||
|
// db.GetImages returns nil when the image store was never initialised,
|
||||||
|
// which is the case here — the point is that it does not panic and does not
|
||||||
|
// invent a photo.
|
||||||
|
if got := imagesFor("cadbury", row); len(got) != 0 {
|
||||||
|
t.Errorf("expected no photos without an image store, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -69,6 +70,24 @@ var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
|
|||||||
{"search_query", "search_query", "NULL::text AS search_query"},
|
{"search_query", "search_query", "NULL::text AS search_query"},
|
||||||
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
|
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
|
||||||
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
|
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
|
||||||
|
|
||||||
|
// Where the photos actually are.
|
||||||
|
//
|
||||||
|
// The catalogue pipeline records what it found per product: `image_url` for
|
||||||
|
// the primary and `image_urls` for the rest. Most are EXTERNAL — bigbasket,
|
||||||
|
// amazon, a shop's own CDN — because stage 6 searches the web and only some
|
||||||
|
// results get mirrored into our bucket.
|
||||||
|
//
|
||||||
|
// This side ignored both columns and instead listed bucket objects under
|
||||||
|
// `daily/brands/{brand}/{image_id}/`, so a product showed a photo only if it
|
||||||
|
// happened to have been mirrored. Measured on britannia: 2 of 6 products had
|
||||||
|
// images here while the owning team had URLs for all 6.
|
||||||
|
//
|
||||||
|
// Cast to text and parsed by hand: the column is an array, and GORM's raw
|
||||||
|
// scan silently drops slice-kind destination fields — the same reason
|
||||||
|
// providers, highlights and nutrients are read this way.
|
||||||
|
{"image_url", "image_url", "NULL::text AS image_url"},
|
||||||
|
{"image_urls", "image_urls::text AS image_urls", "NULL::text AS image_urls"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// columnsFor builds the SELECT list for one table from the columns it has.
|
// columnsFor builds the SELECT list for one table from the columns it has.
|
||||||
@@ -118,6 +137,65 @@ type catalogueProductRow struct {
|
|||||||
SearchQuery string
|
SearchQuery string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
ImageURL string
|
||||||
|
ImageURLs string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagesFor decides which photos a catalogue product carries.
|
||||||
|
//
|
||||||
|
// The pipeline's own `image_urls` first, then its single `image_url`, and the
|
||||||
|
// bucket listing last. That order is deliberate: the owning team records what
|
||||||
|
// it found for every product, and only a subset of those files were ever
|
||||||
|
// mirrored into our bucket — so listing the bucket alone finds photos for a
|
||||||
|
// minority of the catalogue and reports the rest as having none.
|
||||||
|
//
|
||||||
|
// The bucket stays as the fallback rather than being dropped. It is the only
|
||||||
|
// source for anything ingested before these columns existed, and it is the one
|
||||||
|
// source that cannot rot: a external URL is somebody else's CDN and will
|
||||||
|
// eventually 404, at which point the card falls through to the next photo.
|
||||||
|
func imagesFor(brand string, row catalogueProductRow) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
add := func(url string) {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if url == "" || seen[url] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[url] = true
|
||||||
|
out = append(out, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range parseImageList(row.ImageURLs) {
|
||||||
|
add(url)
|
||||||
|
}
|
||||||
|
add(row.ImageURL)
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return db.GetImages(brand, row.ImageID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageList reads the list whichever way it was stored.
|
||||||
|
//
|
||||||
|
// A Postgres text[] casts to `{a,b}` and a jsonb column to `["a","b"]`, and the
|
||||||
|
// column type is the owning team's to change. Handling both here costs four
|
||||||
|
// lines and removes a whole class of "it worked until they migrated".
|
||||||
|
func parseImageList(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "{}" || raw == "[]" || raw == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "[") {
|
||||||
|
var urls []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &urls); err == nil {
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
// Falls through on a malformed value rather than dropping the row.
|
||||||
|
log.Printf("catalogue: could not parse image_urls as JSON: %.120s", raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return models.ParsePGArray(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
||||||
@@ -129,7 +207,7 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
|||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
Category: row.Category,
|
Category: row.Category,
|
||||||
ImageID: row.ImageID,
|
ImageID: row.ImageID,
|
||||||
Images: db.GetImages(brand, row.ImageID),
|
Images: imagesFor(brand, row),
|
||||||
Size: row.Size,
|
Size: row.Size,
|
||||||
VariantKey: row.VariantKey,
|
VariantKey: row.VariantKey,
|
||||||
ProductSKU: row.ProductSKU,
|
ProductSKU: row.ProductSKU,
|
||||||
|
|||||||
Reference in New Issue
Block a user