Skip to content

Commit f9591bf

Browse files
committed
feat: package for handling ACP product feeds
1 parent fa19eb7 commit f9591bf

8 files changed

Lines changed: 583 additions & 0 deletions

File tree

feed/csv.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package feed
2+
3+
import (
4+
"compress/gzip"
5+
"encoding/csv"
6+
"fmt"
7+
"io"
8+
"strconv"
9+
)
10+
11+
// csvHeader defines the ordered CSV column list for ACP feeds.
12+
var csvHeader = []string{
13+
"enable_search",
14+
"enable_checkout",
15+
"id",
16+
"gtin",
17+
"mpn",
18+
"title",
19+
"description",
20+
"link",
21+
"condition",
22+
"product_category",
23+
"brand",
24+
"material",
25+
"dimensions",
26+
"length",
27+
"width",
28+
"height",
29+
"weight",
30+
"age_group",
31+
"image_link",
32+
"additional_image_link",
33+
"video_link",
34+
"model_3d_link",
35+
"price",
36+
"sale_price",
37+
"sale_price_effective_date",
38+
"unit_pricing_measure",
39+
"base_measure",
40+
"pricing_trend",
41+
"availability",
42+
"availability_date",
43+
"inventory_quantity",
44+
"expiration_date",
45+
"pickup_method",
46+
"pickup_sla",
47+
"item_group_id",
48+
"item_group_title",
49+
"color",
50+
"size",
51+
"size_system",
52+
"gender",
53+
"offer_id",
54+
"custom_variant1_category",
55+
"custom_variant1_option",
56+
"custom_variant2_category",
57+
"custom_variant2_option",
58+
"custom_variant3_category",
59+
"custom_variant3_option",
60+
"shipping",
61+
"delivery_estimate",
62+
"seller_name",
63+
"seller_url",
64+
"seller_privacy_policy",
65+
"seller_tos",
66+
"return_policy",
67+
"return_window",
68+
"popularity_score",
69+
"return_rate",
70+
"warning",
71+
"warning_url",
72+
"age_restriction",
73+
"product_review_count",
74+
"product_review_rating",
75+
"store_review_count",
76+
"store_review_rating",
77+
"q_and_a",
78+
"raw_review_data",
79+
"related_product_id",
80+
"relationship_type",
81+
"geo_price",
82+
"geo_availability",
83+
}
84+
85+
// csvRecord formats a product into a CSV record matching csvHeader order.
86+
func (p Product) csvRecord() []string {
87+
return []string{
88+
strconv.FormatBool(p.EnableSearch),
89+
strconv.FormatBool(p.EnableCheckout),
90+
p.ID,
91+
p.GTIN,
92+
p.MPN,
93+
p.Title,
94+
p.Description,
95+
p.Link,
96+
p.Condition,
97+
p.ProductCategory,
98+
p.Brand,
99+
p.Material,
100+
p.Dimensions,
101+
p.Length,
102+
p.Width,
103+
p.Height,
104+
p.Weight,
105+
p.AgeGroup,
106+
p.ImageLink,
107+
joinStrings(p.AdditionalImageLink),
108+
p.VideoLink,
109+
p.Model3DLink,
110+
p.Price,
111+
p.SalePrice,
112+
p.SalePriceEffectiveDate,
113+
p.UnitPricingMeasure,
114+
p.BaseMeasure,
115+
p.PricingTrend,
116+
p.Availability,
117+
p.AvailabilityDate,
118+
formatInt(p.InventoryQuantity),
119+
p.ExpirationDate,
120+
p.PickupMethod,
121+
p.PickupSLA,
122+
p.ItemGroupID,
123+
p.ItemGroupTitle,
124+
p.Color,
125+
p.Size,
126+
p.SizeSystem,
127+
p.Gender,
128+
p.OfferID,
129+
p.CustomVariant1Category,
130+
p.CustomVariant1Option,
131+
p.CustomVariant2Category,
132+
p.CustomVariant2Option,
133+
p.CustomVariant3Category,
134+
p.CustomVariant3Option,
135+
joinStrings(p.Shipping),
136+
p.DeliveryEstimate,
137+
p.SellerName,
138+
p.SellerURL,
139+
p.SellerPrivacyPolicy,
140+
p.SellerTOS,
141+
p.ReturnPolicy,
142+
formatInt(p.ReturnWindow),
143+
formatFloat(p.PopularityScore),
144+
p.ReturnRate,
145+
p.Warning,
146+
p.WarningURL,
147+
formatInt(p.AgeRestriction),
148+
formatInt(p.ProductReviewCount),
149+
formatFloat(p.ProductReviewRating),
150+
formatInt(p.StoreReviewCount),
151+
formatFloat(p.StoreReviewRating),
152+
p.QAndA,
153+
p.RawReviewData,
154+
joinStrings(p.RelatedProductID),
155+
p.RelationshipType,
156+
joinStrings(p.GeoPrice),
157+
joinStrings(p.GeoAvailability),
158+
}
159+
}
160+
161+
// WriteCSVGz writes the feed as CSV, compressed with gzip.
162+
func (f Feed) WriteCSVGz(w io.Writer) error {
163+
gz := gzip.NewWriter(w)
164+
writer := csv.NewWriter(gz)
165+
if err := writer.Write(csvHeader); err != nil {
166+
_ = gz.Close()
167+
return fmt.Errorf("write csv header: %w", err)
168+
}
169+
for i := range f {
170+
if err := writer.Write(f[i].csvRecord()); err != nil {
171+
_ = gz.Close()
172+
return fmt.Errorf("write csv row: %w", err)
173+
}
174+
}
175+
writer.Flush()
176+
if err := writer.Error(); err != nil {
177+
_ = gz.Close()
178+
return fmt.Errorf("flush csv: %w", err)
179+
}
180+
if err := gz.Close(); err != nil {
181+
return fmt.Errorf("close gzip: %w", err)
182+
}
183+
return nil
184+
}

feed/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Package feed provides types and helpers for exporting ACP product feeds.
2+
// See https://developers.openai.com/commerce/specs/feed for the feed specification.
3+
package feed

feed/feed.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package feed
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
)
7+
8+
// Feed is a collection of products n the ACP product feed.
9+
type Feed []Product
10+
11+
// New creates new feed from a list of products.
12+
func New(products []Product) Feed {
13+
return Feed(products)
14+
}
15+
16+
// Product describes a single product entry in an ACP product feed.
17+
// Field names follow the feed specification for JSONL and CSV export.
18+
type Product struct {
19+
// EnableSearch controls whether the product can be surfaced in ChatGPT search results.
20+
EnableSearch bool `json:"enable_search" csv:"enable_search"`
21+
// EnableCheckout allows direct purchase inside ChatGPT when EnableSearch is true.
22+
EnableCheckout bool `json:"enable_checkout" csv:"enable_checkout"`
23+
// ID is the merchant product identifier and must remain stable over time.
24+
ID string `json:"id" csv:"id"`
25+
// GTIN is a universal product identifier such as GTIN, UPC, or ISBN.
26+
GTIN string `json:"gtin,omitempty" csv:"gtin"`
27+
// MPN is the manufacturer part number, required if GTIN is absent.
28+
MPN string `json:"mpn,omitempty" csv:"mpn"`
29+
// Title is the product title shown to shoppers.
30+
Title string `json:"title" csv:"title"`
31+
// Description is the full product description, plain text.
32+
Description string `json:"description" csv:"description"`
33+
// Link is the product detail page URL.
34+
Link string `json:"link" csv:"link"`
35+
// Condition is the product condition such as new, refurbished, or used.
36+
Condition string `json:"condition,omitempty" csv:"condition"`
37+
// ProductCategory is the taxonomy path using a ">" separator.
38+
ProductCategory string `json:"product_category" csv:"product_category"`
39+
// Brand is the product brand name.
40+
Brand string `json:"brand,omitempty" csv:"brand"`
41+
// Material describes the primary material.
42+
Material string `json:"material,omitempty" csv:"material"`
43+
// Dimensions is the overall size formatted as LxWxH with a unit.
44+
Dimensions string `json:"dimensions,omitempty" csv:"dimensions"`
45+
// Length is the individual length dimension with a unit.
46+
Length string `json:"length,omitempty" csv:"length"`
47+
// Width is the individual width dimension with a unit.
48+
Width string `json:"width,omitempty" csv:"width"`
49+
// Height is the individual height dimension with a unit.
50+
Height string `json:"height,omitempty" csv:"height"`
51+
// Weight is the product weight with a unit.
52+
Weight string `json:"weight,omitempty" csv:"weight"`
53+
// AgeGroup is the target demographic such as newborn, infant, toddler, kids, or adult.
54+
AgeGroup string `json:"age_group,omitempty" csv:"age_group"`
55+
// ImageLink is the main product image URL.
56+
ImageLink string `json:"image_link" csv:"image_link"`
57+
// AdditionalImageLink lists extra image URLs (comma-separated in CSV).
58+
AdditionalImageLink []string `json:"additional_image_link,omitempty" csv:"additional_image_link"`
59+
// VideoLink is a publicly accessible product video URL.
60+
VideoLink string `json:"video_link,omitempty" csv:"video_link"`
61+
// Model3DLink is a 3D model URL (GLB/GLTF preferred).
62+
Model3DLink string `json:"model_3d_link,omitempty" csv:"model_3d_link"`
63+
// Price is the regular price with ISO 4217 currency code.
64+
Price string `json:"price" csv:"price"`
65+
// SalePrice is the discounted price with currency code.
66+
SalePrice string `json:"sale_price,omitempty" csv:"sale_price"`
67+
// SalePriceEffectiveDate is the ISO 8601 sale window date range.
68+
SalePriceEffectiveDate string `json:"sale_price_effective_date,omitempty" csv:"sale_price_effective_date"`
69+
// UnitPricingMeasure and BaseMeasure describe unit pricing (both required together).
70+
UnitPricingMeasure string `json:"unit_pricing_measure,omitempty" csv:"unit_pricing_measure"`
71+
// BaseMeasure is the base unit used for unit pricing.
72+
BaseMeasure string `json:"base_measure,omitempty" csv:"base_measure"`
73+
// PricingTrend is a short string like "Lowest price in N months".
74+
PricingTrend string `json:"pricing_trend,omitempty" csv:"pricing_trend"`
75+
// Availability is the stock status: in_stock, out_of_stock, or preorder.
76+
Availability string `json:"availability" csv:"availability"`
77+
// AvailabilityDate is the ISO 8601 availability date for preorder items.
78+
AvailabilityDate string `json:"availability_date,omitempty" csv:"availability_date"`
79+
// InventoryQuantity is the non-negative stock count.
80+
InventoryQuantity *int `json:"inventory_quantity,omitempty" csv:"inventory_quantity"`
81+
// ExpirationDate is the ISO 8601 date to remove the product after.
82+
ExpirationDate string `json:"expiration_date,omitempty" csv:"expiration_date"`
83+
// PickupMethod specifies pickup options: in_store, reserve, or not_supported.
84+
PickupMethod string `json:"pickup_method,omitempty" csv:"pickup_method"`
85+
// PickupSLA is the pickup service-level agreement, like "1 day".
86+
PickupSLA string `json:"pickup_sla,omitempty" csv:"pickup_sla"`
87+
// ItemGroupID groups variants under a canonical product listing.
88+
ItemGroupID string `json:"item_group_id,omitempty" csv:"item_group_id"`
89+
// ItemGroupTitle is the title for the variant group.
90+
ItemGroupTitle string `json:"item_group_title,omitempty" csv:"item_group_title"`
91+
// Color is the variant color.
92+
Color string `json:"color,omitempty" csv:"color"`
93+
// Size is the variant size.
94+
Size string `json:"size,omitempty" csv:"size"`
95+
// SizeSystem is the ISO 3166 size system code, such as US.
96+
SizeSystem string `json:"size_system,omitempty" csv:"size_system"`
97+
// Gender is the target gender: male, female, or unisex.
98+
Gender string `json:"gender,omitempty" csv:"gender"`
99+
// OfferID identifies a specific offer (SKU + seller + price), unique within the feed.
100+
OfferID string `json:"offer_id,omitempty" csv:"offer_id"`
101+
// CustomVariant1Category names the first custom variant dimension.
102+
CustomVariant1Category string `json:"custom_variant1_category,omitempty" csv:"custom_variant1_category"`
103+
// CustomVariant1Option provides the option value for custom variant 1.
104+
CustomVariant1Option string `json:"custom_variant1_option,omitempty" csv:"custom_variant1_option"`
105+
// CustomVariant2Category names the second custom variant dimension.
106+
CustomVariant2Category string `json:"custom_variant2_category,omitempty" csv:"custom_variant2_category"`
107+
// CustomVariant2Option provides the option value for custom variant 2.
108+
CustomVariant2Option string `json:"custom_variant2_option,omitempty" csv:"custom_variant2_option"`
109+
// CustomVariant3Category names the third custom variant dimension.
110+
CustomVariant3Category string `json:"custom_variant3_category,omitempty" csv:"custom_variant3_category"`
111+
// CustomVariant3Option provides the option value for custom variant 3.
112+
CustomVariant3Option string `json:"custom_variant3_option,omitempty" csv:"custom_variant3_option"`
113+
// Shipping lists shipping entries in country:region:service_class:price format.
114+
Shipping []string `json:"shipping,omitempty" csv:"shipping"`
115+
// DeliveryEstimate is the ISO 8601 estimated arrival date.
116+
DeliveryEstimate string `json:"delivery_estimate,omitempty" csv:"delivery_estimate"`
117+
// SellerName is the merchant display name.
118+
SellerName string `json:"seller_name" csv:"seller_name"`
119+
// SellerURL is the merchant storefront URL.
120+
SellerURL string `json:"seller_url" csv:"seller_url"`
121+
// SellerPrivacyPolicy is the seller-specific privacy policy URL.
122+
SellerPrivacyPolicy string `json:"seller_privacy_policy,omitempty" csv:"seller_privacy_policy"`
123+
// SellerTOS is the seller-specific terms of service URL.
124+
SellerTOS string `json:"seller_tos,omitempty" csv:"seller_tos"`
125+
// ReturnPolicy is the return policy URL.
126+
ReturnPolicy string `json:"return_policy,omitempty" csv:"return_policy"`
127+
// ReturnWindow is the number of days allowed for returns.
128+
ReturnWindow *int `json:"return_window,omitempty" csv:"return_window"`
129+
// PopularityScore is a popularity indicator (for example, 0-5 scale).
130+
PopularityScore *float64 `json:"popularity_score,omitempty" csv:"popularity_score"`
131+
// ReturnRate is the percentage of returns, 0-100%.
132+
ReturnRate string `json:"return_rate,omitempty" csv:"return_rate"`
133+
// Warning is a product disclaimer or regulatory warning.
134+
Warning string `json:"warning,omitempty" csv:"warning"`
135+
// WarningURL links to warning details and must resolve.
136+
WarningURL string `json:"warning_url,omitempty" csv:"warning_url"`
137+
// AgeRestriction is the minimum purchase age.
138+
AgeRestriction *int `json:"age_restriction,omitempty" csv:"age_restriction"`
139+
// ProductReviewCount is the number of product reviews.
140+
ProductReviewCount *int `json:"product_review_count,omitempty" csv:"product_review_count"`
141+
// ProductReviewRating is the average product review score.
142+
ProductReviewRating *float64 `json:"product_review_rating,omitempty" csv:"product_review_rating"`
143+
// StoreReviewCount is the number of brand or store reviews.
144+
StoreReviewCount *int `json:"store_review_count,omitempty" csv:"store_review_count"`
145+
// StoreReviewRating is the average brand or store rating.
146+
StoreReviewRating *float64 `json:"store_review_rating,omitempty" csv:"store_review_rating"`
147+
// QAndA is FAQ content in plain text.
148+
QAndA string `json:"q_and_a,omitempty" csv:"q_and_a"`
149+
// RawReviewData contains raw review payloads and may include JSON blobs.
150+
RawReviewData string `json:"raw_review_data,omitempty" csv:"raw_review_data"`
151+
// RelatedProductID lists associated product IDs (comma-separated in CSV).
152+
RelatedProductID []string `json:"related_product_id,omitempty" csv:"related_product_id"`
153+
// RelationshipType describes how related products connect (for example, part_of_set).
154+
RelationshipType string `json:"relationship_type,omitempty" csv:"relationship_type"`
155+
// GeoPrice lists country-specific prices using ISO 3166-1 country codes.
156+
GeoPrice []string `json:"geo_price,omitempty" csv:"geo_price"`
157+
// GeoAvailability lists country-specific availability using ISO 3166-1 country codes.
158+
GeoAvailability []string `json:"geo_availability,omitempty" csv:"geo_availability"`
159+
}
160+
161+
// joinStrings combines list values using a comma, returning empty for nil slices.
162+
func joinStrings(values []string) string {
163+
if len(values) == 0 {
164+
return ""
165+
}
166+
return strings.Join(values, ",")
167+
}
168+
169+
// formatInt converts an optional int to a string, returning empty when nil.
170+
func formatInt(value *int) string {
171+
if value == nil {
172+
return ""
173+
}
174+
return strconv.Itoa(*value)
175+
}
176+
177+
// formatFloat converts an optional float to a string, returning empty when nil.
178+
func formatFloat(value *float64) string {
179+
if value == nil {
180+
return ""
181+
}
182+
return strconv.FormatFloat(*value, 'f', -1, 64)
183+
}

0 commit comments

Comments
 (0)