Scenario
You want product bundles backed by evidence. You analyze order history to find frequently co-purchased products, propose bundle concepts from top pairs, then validate with a Mavera focus group for synthetic audience feedback.Architecture
Code
import os, requests
from collections import defaultdict
from itertools import combinations
STORE = os.environ["SHOPIFY_STORE"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
SH = f"https://{STORE}.myshopify.com/admin/api/2024-10/graphql.json"
SH_H = {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"}
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
QUERY = """query ($cursor: String) {
orders(first: 250, after: $cursor) {
edges { node { lineItems(first: 20) { edges { node { title originalUnitPriceSet { shopMoney { amount } } } } } } }
pageInfo { hasNextPage endCursor }
}
}"""
orders, cursor = [], None
while True:
resp = requests.post(SH, json={"query": QUERY, "variables": {"cursor": cursor} if cursor else {}}, headers=SH_H)
resp.raise_for_status(); data = resp.json()["data"]["orders"]
for e in data["edges"]:
items = [li["node"] for li in e["node"]["lineItems"]["edges"]]
if len(items) >= 2: orders.append(items)
if not data["pageInfo"]["hasNextPage"]: break
cursor = data["pageInfo"]["endCursor"]
print(f"Analyzed {len(orders)} multi-item orders")
counts, prices = defaultdict(int), defaultdict(list)
for items in orders:
titles = sorted(set(i["title"] for i in items))
pm = {i["title"]: float(i["originalUnitPriceSet"]["shopMoney"]["amount"]) for i in items}
for a, b in combinations(titles, 2):
counts[(a, b)] += 1
prices[(a, b)].append(pm.get(a, 0) + pm.get(b, 0))
bundles = [{"products": list(p), "count": c, "bundle_price": round(sum(prices[p])/len(prices[p])*0.9, 2)}
for p, c in sorted(counts.items(), key=lambda x: -x[1])[:10] if c >= 5]
for b in bundles[:5]:
print(f" {b['products'][0]} + {b['products'][1]}: {b['count']}x → ${b['bundle_price']}")
descs = [f"Bundle {i+1}: {b['products'][0]} + {b['products'][1]} at ${b['bundle_price']} (10% off)" for i, b in enumerate(bundles[:5])]
fg = requests.post(f"{MB}/focus-groups", json={
"name": "Shopify Bundle Validation",
"questions": [f"Would you buy '{bundles[0]['products'][0]}' + '{bundles[0]['products'][1]}' at ${bundles[0]['bundle_price']}?", "Which bundle is most appealing?", "What discount makes a bundle irresistible?", "Mix-and-match or curated sets?"],
"context": "Testing bundle concepts from purchase data.\n\n" + "\n".join(descs),
}, headers=MH)
fg.raise_for_status(); result = fg.json()
print(f"\nFocus group: {result['id']}")
for r in result.get("responses", []):
print(f" Q: {r['question']}\n A: {r['answer'][:200]}\n")
const STORE = process.env.SHOPIFY_STORE, TOKEN = process.env.SHOPIFY_ACCESS_TOKEN, MV = process.env.MAVERA_API_KEY;
const SH = `https://${STORE}.myshopify.com/admin/api/2024-10/graphql.json`;
const MB = "https://app.mavera.io/api/v1";
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const QUERY = `query($cursor:String){orders(first:250,after:$cursor){edges{node{lineItems(first:20){edges{node{title originalUnitPriceSet{shopMoney{amount}}}}}}} pageInfo{hasNextPage endCursor}}}`;
(async () => {
const orders = []; let cursor = null;
while (true) {
const resp = await fetch(SH, { method: "POST", headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ query: QUERY, variables: cursor ? { cursor } : {} }) });
const data = (await resp.json()).data.orders;
for (const e of data.edges) {
const items = e.node.lineItems.edges.map(li => li.node);
if (items.length >= 2) orders.push(items);
}
if (!data.pageInfo.hasNextPage) break;
cursor = data.pageInfo.endCursor;
}
console.log(`Analyzed ${orders.length} multi-item orders`);
const counts = {}, prices = {};
for (const items of orders) {
const titles = [...new Set(items.map(i => i.title))].sort();
const pm = Object.fromEntries(items.map(i => [i.title, parseFloat(i.originalUnitPriceSet.shopMoney.amount)]));
for (let i = 0; i < titles.length; i++)
for (let j = i + 1; j < titles.length; j++) {
const k = `${titles[i]}|||${titles[j]}`;
counts[k] = (counts[k] || 0) + 1;
(prices[k] ??= []).push((pm[titles[i]] || 0) + (pm[titles[j]] || 0));
}
}
const bundles = Object.entries(counts).filter(([, c]) => c >= 5).sort((a, b) => b[1] - a[1]).slice(0, 10)
.map(([k, count]) => { const [a, b] = k.split("|||"); const avg = prices[k].reduce((s, p) => s + p, 0) / prices[k].length; return { products: [a, b], count, bundle_price: +(avg * 0.9).toFixed(2) }; });
bundles.slice(0, 5).forEach(b => console.log(` ${b.products[0]} + ${b.products[1]}: ${b.count}x → $${b.bundle_price}`));
const descs = bundles.slice(0, 5).map((b, i) => `Bundle ${i + 1}: ${b.products[0]} + ${b.products[1]} at $${b.bundle_price}`);
const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: MH, body: JSON.stringify({
name: "Shopify Bundle Validation",
questions: [`Buy '${bundles[0].products[0]}' + '${bundles[0].products[1]}' at $${bundles[0].bundle_price}?`, "Most appealing bundle?", "Irresistible discount %?", "Mix-and-match or curated?"],
context: "Testing bundles.\n\n" + descs.join("\n")
}) });
const result = await fg.json();
console.log(`\nFocus group: ${result.id}`);
(result.responses || []).forEach(r => console.log(` Q: ${r.question}\n A: ${r.answer.slice(0, 200)}\n`));
})();
Example Output
{
"id": "fg_7d3f1a9e",
"responses": [
{
"question": "Would you buy 'Merino Base Layer' + 'Trail Runner Shorts' at $112.50?",
"answer": "Absolutely — I already buy these together. Saving $12.50 is a no-brainer."
},
{
"question": "What discount makes a bundle irresistible?",
"answer": "15% is the sweet spot. At 10% I comparison-shop, at 15% I stop thinking."
}
]
}
Error Handling
Mostly single-item orders
Mostly single-item orders
Co-occurrence needs orders with 2+ distinct products. If your store has mostly single-item orders, lower
min_count or expand the date range.Combinatorial explosion on large orders
Combinatorial explosion on large orders
An order with 20 unique products generates 190 pairs. Cap unique titles per order to the top 10 by quantity to keep memory manageable.