{"name":"Product research automation workflow","nodes":[{"id":"0001aaaa-1111-4222-8333-444455556600","name":"Weekly Research Run","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-360,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":7}]}}},{"id":"0002aaaa-1111-4222-8333-444455556600","name":"Fetch Candidate Products","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-100,300],"parameters":{"url":"https://api.productcatalog.io/v2/products/trending","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,title,category,cost_price_usd,suggested_retail_usd,orders_last_30_days,review_count,review_average,supplier_count,avg_shipping_days,images,url"},{"name":"sort","value":"orders_last_30_days:desc"},{"name":"min_orders_30d","value":"100"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"0003aaaa-1111-4222-8333-444455556600","name":"Normalize + Dedupe Candidates","type":"n8n-nodes-base.code","typeVersion":2,"position":[160,300],"parameters":{"jsCode":"// Normalize the supplier feed into one flat candidate shape, dedupe, and pre-qualify.\n// Source: catalog API returns { products: [...] } per page; n8n gives us one item per page.\nconst pages = $input.all().map(i => i.json);\nconst raw = pages.flatMap(p => p.products || p.data || []);\n\nconst BLOCKED_CATEGORIES = ['weapons', 'tobacco', 'supplements-rx', 'adult'];\nconst MIN_MARGIN = 0.45;        // 45% gross margin floor\nconst MAX_SHIP_DAYS = 14;       // anything slower kills CVR\nconst MIN_ORDERS_30D = 120;     // proof the market already buys it\n\nconst seen = new Map();\n\nfor (const p of raw) {\n  // dedupe on normalized title + first supplier image hash-ish key\n  const key = String(p.title || '')\n    .toLowerCase()\n    .replace(/[^a-z0-9 ]/g, '')\n    .replace(/\\b(new|hot|2024|2025|pcs|set|pack)\\b/g, '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n  if (!key) continue;\n\n  const cost = Number(p.cost_price_usd ?? p.cost ?? 0);\n  const retail = Number(p.suggested_retail_usd ?? p.msrp ?? cost * 3);\n  const margin = retail > 0 ? (retail - cost) / retail : 0;\n\n  const cand = {\n    product_id: p.id || p.product_id,\n    title: String(p.title || '').trim(),\n    dedupe_key: key,\n    category: String(p.category || 'uncategorized').toLowerCase(),\n    cost_price_usd: Number(cost.toFixed(2)),\n    retail_price_usd: Number(retail.toFixed(2)),\n    gross_margin: Number(margin.toFixed(3)),\n    orders_30d: Number(p.orders_last_30_days ?? p.orders_30d ?? 0),\n    review_count: Number(p.review_count ?? 0),\n    review_avg: Number(p.review_average ?? 0),\n    supplier_count: Number(p.supplier_count ?? 1),\n    ship_days: Number(p.avg_shipping_days ?? 99),\n    supplier_url: p.url || null,\n    image_url: (p.images && p.images[0]) || p.image_url || null,\n  };\n\n  // hard disqualifiers -> flagged, not silently dropped, so the Filter can report them\n  const fails = [];\n  if (BLOCKED_CATEGORIES.includes(cand.category)) fails.push('blocked_category');\n  if (cand.gross_margin < MIN_MARGIN) fails.push('margin_below_45pct');\n  if (cand.ship_days > MAX_SHIP_DAYS) fails.push('shipping_too_slow');\n  if (cand.orders_30d < MIN_ORDERS_30D) fails.push('insufficient_demand');\n  cand.disqualifiers = fails;\n  cand.prequalified = fails.length === 0;\n\n  // saturation proxy: many suppliers + huge review count = late to the trend\n  cand.saturation_index = Number(\n    Math.min(1, (cand.supplier_count / 40) * 0.6 + (cand.review_count / 8000) * 0.4).toFixed(3)\n  );\n\n  // keep the cheapest-cost duplicate (best margin) when the same product appears twice\n  const prev = seen.get(key);\n  if (!prev || cand.cost_price_usd < prev.cost_price_usd) seen.set(key, cand);\n}\n\nconst out = [...seen.values()].sort((a, b) => b.orders_30d - a.orders_30d).slice(0, 40);\nreturn out.map(r => ({ json: { ...r, batch_scanned: raw.length, after_dedupe: seen.size } }));"}},{"id":"0004aaaa-1111-4222-8333-444455556600","name":"Keep Prequalified Only","type":"n8n-nodes-base.filter","typeVersion":2,"position":[420,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.prequalified }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}},{"id":"f2","leftValue":"={{ $json.saturation_index }}","rightValue":0.85,"operator":{"type":"number","operation":"lt"}}]},"options":{}}},{"id":"0005aaaa-1111-4222-8333-444455556600","name":"Loop Each Product","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[680,300],"parameters":{"batchSize":1,"options":{}}},{"id":"0006aaaa-1111-4222-8333-444455556600","name":"Fetch Search Trend Curve","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[940,500],"parameters":{"url":"https://api.trendsdata.io/v1/interest","sendQuery":true,"queryParameters":{"parameters":[{"name":"keyword","value":"={{ $json.title }}"},{"name":"timeframe","value":"today 3-m"},{"name":"geo","value":"US"}]},"options":{}}},{"id":"0007aaaa-1111-4222-8333-444455556600","name":"Combine Product + Trend","type":"n8n-nodes-base.merge","typeVersion":3,"position":[1200,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"0008aaaa-1111-4222-8333-444455556600","name":"LLM Market Summary","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1460,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a dropshipping product researcher. Reply with STRICT JSON only, no prose, no code fences. Schema: {\"market_summary\": string (max 60 words), \"demand_outlook\": \"rising\"|\"flat\"|\"declining\", \"competition_level\": \"low\"|\"medium\"|\"high\"|\"extreme\", \"angles\": string[3], \"risks\": string[3]}"},{"content":"=Product: {{ $json.title }}\nCategory: {{ $json.category }}\nCost ${{ $json.cost_price_usd }} / retail ${{ $json.retail_price_usd }} (margin {{ $json.gross_margin }})\nOrders last 30d: {{ $json.orders_30d }} · suppliers: {{ $json.supplier_count }} · reviews: {{ $json.review_count }} at {{ $json.review_avg }}\n90d search interest series: {{ JSON.stringify($json.interest_over_time) }}\n\nAssess the market and return the JSON."}]},"options":{}}},{"id":"0009aaaa-1111-4222-8333-444455556600","name":"Score Viability","type":"n8n-nodes-base.code","typeVersion":2,"position":[1720,300],"parameters":{"jsCode":"// Blend the catalog signals, the trend curve and the LLM read into one viability score (0-100).\nconst item = $input.first().json;\n\n// The LLM node returns its answer on message.content; it was asked for strict JSON.\nlet llm = {};\nconst rawText = item.message?.content ?? item.text ?? item.output ?? '{}';\ntry {\n  llm = typeof rawText === 'string'\n    ? JSON.parse(rawText.replace(/```json|```/g, '').trim())\n    : rawText;\n} catch (e) {\n  llm = { market_summary: String(rawText).slice(0, 600), demand_outlook: 'unknown', competition_level: 'unknown' };\n}\n\n// The OpenAI node replaces the item with its own output, so the product + trend\n// fields come back from the merge node (same run index) rather than from $json.\nlet merged = item;\ntry { merged = $('Combine Product + Trend').item.json; } catch (e) { merged = item; }\nconst p = merged;\nconst trend = merged;\n\n// --- 1. demand (0-30): 30d orders on a log curve so 10k doesn't dwarf everything\nconst orders = Number(p.orders_30d || 0);\nconst demandScore = Math.min(30, (Math.log10(orders + 1) / Math.log10(5000)) * 30);\n\n// --- 2. margin (0-25): 45% floor -> 0 pts, 75%+ -> full marks\nconst margin = Number(p.gross_margin || 0);\nconst marginScore = Math.max(0, Math.min(25, ((margin - 0.45) / 0.30) * 25));\n\n// --- 3. trend slope (0-20): 90d search interest, last 4 weeks vs the prior 8\nconst series = Array.isArray(trend.interest_over_time) ? trend.interest_over_time.map(Number) : [];\nlet slope = 0;\nif (series.length >= 12) {\n  const recent = series.slice(-4).reduce((a, b) => a + b, 0) / 4;\n  const base = series.slice(-12, -4).reduce((a, b) => a + b, 0) / 8;\n  slope = base > 0 ? (recent - base) / base : 0;      // e.g. +0.35 = growing 35%\n}\nconst trendScore = Math.max(0, Math.min(20, (slope + 0.15) / 0.65 * 20));\n\n// --- 4. competition (0-15): saturation index inverted, nudged by the LLM's read\nconst compPenalty = { low: 0, medium: 0.25, high: 0.6, extreme: 0.85 }[String(llm.competition_level).toLowerCase()] ?? 0.4;\nconst competitionScore = Math.max(0, 15 * (1 - Math.max(Number(p.saturation_index || 0.5), compPenalty)));\n\n// --- 5. social proof quality (0-10): rating weighted by how many reviews back it\nconst reviewConfidence = Math.min(1, Number(p.review_count || 0) / 500);\nconst proofScore = Math.max(0, ((Number(p.review_avg || 0) - 3.5) / 1.5)) * 10 * reviewConfidence;\n\nconst viability = Math.round(demandScore + marginScore + trendScore + competitionScore + proofScore);\n\n// unit economics at a realistic 3.0 blended CPA assumption\nconst contribution = Number(p.retail_price_usd || 0) - Number(p.cost_price_usd || 0);\nconst breakevenRoas = contribution > 0 ? Number((p.retail_price_usd / contribution).toFixed(2)) : null;\nconst maxCpa = Number((contribution * 0.65).toFixed(2));   // leave 35% of contribution as profit\n\nconst tier = viability >= 78 ? 'A-test-now' : viability >= 62 ? 'B-watchlist' : 'C-reject';\n\nreturn [{\n  json: {\n    product_id: p.product_id,\n    title: p.title,\n    category: p.category,\n    cost_price_usd: p.cost_price_usd,\n    retail_price_usd: p.retail_price_usd,\n    gross_margin: p.gross_margin,\n    orders_30d: orders,\n    saturation_index: p.saturation_index,\n    trend_slope_pct: Number((slope * 100).toFixed(1)),\n    scores: {\n      demand: Number(demandScore.toFixed(1)),\n      margin: Number(marginScore.toFixed(1)),\n      trend: Number(trendScore.toFixed(1)),\n      competition: Number(competitionScore.toFixed(1)),\n      social_proof: Number(proofScore.toFixed(1)),\n    },\n    viability_score: viability,\n    tier,\n    breakeven_roas: breakevenRoas,\n    max_cpa_usd: maxCpa,\n    market_summary: llm.market_summary || '',\n    demand_outlook: llm.demand_outlook || 'unknown',\n    competition_level: llm.competition_level || 'unknown',\n    angles: Array.isArray(llm.angles) ? llm.angles.slice(0, 3) : [],\n    risks: Array.isArray(llm.risks) ? llm.risks.slice(0, 3) : [],\n    supplier_url: p.supplier_url,\n    image_url: p.image_url,\n    researched_at: new Date().toISOString(),\n  },\n}];"}},{"id":"000aaaaa-1111-4222-8333-444455556600","name":"Viability >= 78?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1980,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.viability_score }}","rightValue":78,"operator":{"type":"number","operation":"gte"}},{"id":"c2","leftValue":"={{ $json.trend_slope_pct }}","rightValue":-10,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"000baaaa-1111-4222-8333-444455556600","name":"Build Research Card","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2240,100],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"Product","value":"={{ $json.title }}","type":"string"},{"id":"a2","name":"Category","value":"={{ $json.category }}","type":"string"},{"id":"a3","name":"Viability Score","value":"={{ $json.viability_score }}","type":"number"},{"id":"a4","name":"Tier","value":"={{ $json.tier }}","type":"string"},{"id":"a5","name":"Cost","value":"={{ $json.cost_price_usd }}","type":"number"},{"id":"a6","name":"Retail","value":"={{ $json.retail_price_usd }}","type":"number"},{"id":"a7","name":"Breakeven ROAS","value":"={{ $json.breakeven_roas }}","type":"number"},{"id":"a8","name":"Max CPA","value":"={{ $json.max_cpa_usd }}","type":"number"},{"id":"a9","name":"Trend Slope %","value":"={{ $json.trend_slope_pct }}","type":"number"},{"id":"a10","name":"Market Summary","value":"={{ $json.market_summary }}","type":"string"},{"id":"a11","name":"Angles","value":"={{ ($json.angles || []).join(\" | \") }}","type":"string"},{"id":"a12","name":"Risks","value":"={{ ($json.risks || []).join(\" | \") }}","type":"string"},{"id":"a13","name":"Supplier URL","value":"={{ $json.supplier_url }}","type":"string"},{"id":"a14","name":"Researched At","value":"={{ $json.researched_at }}","type":"string"}]},"options":{}}},{"id":"000caaaa-1111-4222-8333-444455556600","name":"Save Card to Airtable","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2500,100],"parameters":{"operation":"create","base":{"__rl":true,"value":"appPRODUCTRSRCH","mode":"id"},"table":{"__rl":true,"value":"tblResearchCards","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"000daaaa-1111-4222-8333-444455556600","name":"Alert Buying Team","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2760,100],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#product-research","mode":"name"},"text":"=🟢 A-tier product: *{{ $json.Product }}* — score {{ $json[\"Viability Score\"] }}/100\nMargin play: ${{ $json.Cost }} → ${{ $json.Retail }} · breakeven ROAS {{ $json[\"Breakeven ROAS\"] }} · max CPA ${{ $json[\"Max CPA\"] }}\nTrend {{ $json[\"Trend Slope %\"] }}% · {{ $json[\"Market Summary\"] }}\nAngles: {{ $json.Angles }}","otherOptions":{}}},{"id":"000eaaaa-1111-4222-8333-444455556600","name":"Log Rejected Candidate","type":"n8n-nodes-base.code","typeVersion":2,"position":[2240,500],"parameters":{"jsCode":"// Losers still get logged: the reject ledger is what stops us re-researching the same product monthly.\nconst rows = $input.all().map(i => i.json);\nreturn rows.map(r => {\n  const s = r.scores || {};\n  // name the single weakest pillar as the human-readable reason\n  const weakest = Object.entries({\n    demand: s.demand / 30, margin: s.margin / 25, trend: s.trend / 20,\n    competition: s.competition / 15, social_proof: s.social_proof / 10,\n  }).sort((a, b) => a[1] - b[1])[0];\n\n  const recheckDays = r.viability_score >= 55 ? 30 : 120;\n  const recheck = new Date(Date.now() + recheckDays * 86400000).toISOString().slice(0, 10);\n\n  return {\n    json: {\n      product_id: r.product_id,\n      title: r.title,\n      category: r.category,\n      viability_score: r.viability_score,\n      tier: r.tier,\n      reject_reason: weakest ? `weakest pillar: ${weakest[0]} (${Math.round(weakest[1] * 100)}% of max)` : 'below threshold',\n      trend_slope_pct: r.trend_slope_pct,\n      breakeven_roas: r.breakeven_roas,\n      recheck_after: recheck,\n      logged_at: new Date().toISOString(),\n    },\n  };\n});"}},{"id":"000faaaa-1111-4222-8333-444455556600","name":"Append Reject Ledger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2500,500],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Reject Ledger"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"0010aaaa-1111-4222-8333-444455556600","name":"Build Run Digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[940,80],"parameters":{"jsCode":"// Loop finished: roll every researched product of this run into one HTML digest.\n// The loop's \"done\" output carries whatever each branch last emitted (cards / reject rows),\n// so read the scored records straight off the scoring node instead.\nlet rows = [];\ntry { rows = $('Score Viability').all().map(i => i.json); } catch (e) { rows = $input.all().map(i => i.json); }\nrows = rows.filter(r => r && r.title && typeof r.viability_score === 'number');\nconst winners = rows.filter(r => r.tier === 'A-test-now').sort((a, b) => b.viability_score - a.viability_score);\nconst watch = rows.filter(r => r.tier === 'B-watchlist').sort((a, b) => b.viability_score - a.viability_score);\n\nconst avg = rows.length ? Math.round(rows.reduce((a, r) => a + r.viability_score, 0) / rows.length) : 0;\n\n// pacing projection: at this hit rate, how many runs to fill a 10-product test slate?\nconst hitRate = rows.length ? winners.length / rows.length : 0;\nconst runsToSlate = hitRate > 0 ? Math.ceil(10 / (hitRate * rows.length)) : null;\n\nconst row = r => `<tr><td>${r.title}</td><td align=\"right\">${r.viability_score}</td><td align=\"right\">${r.gross_margin}</td><td align=\"right\">${r.trend_slope_pct}%</td><td align=\"right\">${r.max_cpa_usd}</td></tr>`;\n\nconst html = `<h2>Product research run — ${new Date().toISOString().slice(0, 10)}</h2>\n<p>${rows.length} products researched · avg viability ${avg} · ${winners.length} A-tier · ${watch.length} watchlist${runsToSlate ? ` · ~${runsToSlate} run(s) to fill a 10-product slate` : ''}</p>\n<h3>Test now</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\"><tr><th>Product</th><th>Score</th><th>Margin</th><th>Trend</th><th>Max CPA</th></tr>${winners.map(row).join('')}</table>\n<h3>Watchlist</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\"><tr><th>Product</th><th>Score</th><th>Margin</th><th>Trend</th><th>Max CPA</th></tr>${watch.map(row).join('')}</table>`;\n\nreturn [{ json: { html, researched: rows.length, a_tier: winners.length, b_tier: watch.length, avg_score: avg } }];"}},{"id":"0011aaaa-1111-4222-8333-444455556600","name":"Email Research Digest","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1200,80],"parameters":{"sendTo":"buying@brand.com","subject":"=Product research digest {{ $now.format('yyyy-LL-dd') }} — {{ $json.a_tier }} A-tier of {{ $json.researched }}","message":"={{ $json.html }}","options":{}}},{"id":"0012aaaa-1111-4222-8333-444455556600","name":"Section: Sourcing","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-400,40],"parameters":{"content":"## 1. SOURCE + PREQUALIFY\nPull 200 trending catalog products weekly, flatten to one shape, dedupe on a normalized title (keeping the cheapest supplier), then drop anything under 45% margin, over 14 shipping days, under 120 orders/30d, or in a blocked category.","height":240,"width":460,"color":4}},{"id":"0013aaaa-1111-4222-8333-444455556600","name":"Section: Enrich + Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[920,40],"parameters":{"content":"## 2. ENRICH + SCORE\nOne product at a time: pull the 90d search-interest curve, ask the LLM for a strict-JSON market read, then blend into a 0-100 viability score — demand 30 / margin 25 / trend slope 20 / competition 15 / social proof 10. Also derives breakeven ROAS and max CPA.","height":240,"width":500,"color":5}},{"id":"0014aaaa-1111-4222-8333-444455556600","name":"Section: Cards + Ledger","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2220,40],"parameters":{"content":"## 3. WRITE THE CARD\n>= 78 and not collapsing → structured research card to Airtable + Slack ping.\nEverything else → reject ledger with the weakest pillar named and a recheck date (30d if borderline, 120d if dead). Both branches loop back for the next product.","height":240,"width":460,"color":3}}],"connections":{"Weekly Research Run":{"main":[[{"node":"Fetch Candidate Products","type":"main","index":0}]]},"Fetch Candidate Products":{"main":[[{"node":"Normalize + Dedupe Candidates","type":"main","index":0}]]},"Normalize + Dedupe Candidates":{"main":[[{"node":"Keep Prequalified Only","type":"main","index":0}]]},"Keep Prequalified Only":{"main":[[{"node":"Loop Each Product","type":"main","index":0}]]},"Loop Each Product":{"main":[[{"node":"Build Run Digest","type":"main","index":0}],[{"node":"Fetch Search Trend Curve","type":"main","index":0},{"node":"Combine Product + Trend","type":"main","index":0}]]},"Fetch Search Trend Curve":{"main":[[{"node":"Combine Product + Trend","type":"main","index":1}]]},"Combine Product + Trend":{"main":[[{"node":"LLM Market Summary","type":"main","index":0}]]},"LLM Market Summary":{"main":[[{"node":"Score Viability","type":"main","index":0}]]},"Score Viability":{"main":[[{"node":"Viability >= 78?","type":"main","index":0}]]},"Viability >= 78?":{"main":[[{"node":"Build Research Card","type":"main","index":0}],[{"node":"Log Rejected Candidate","type":"main","index":0}]]},"Build Research Card":{"main":[[{"node":"Save Card to Airtable","type":"main","index":0}]]},"Save Card to Airtable":{"main":[[{"node":"Alert Buying Team","type":"main","index":0}]]},"Alert Buying Team":{"main":[[{"node":"Loop Each Product","type":"main","index":0}]]},"Log Rejected Candidate":{"main":[[{"node":"Append Reject Ledger","type":"main","index":0}]]},"Append Reject Ledger":{"main":[[{"node":"Loop Each Product","type":"main","index":0}]]},"Build Run Digest":{"main":[[{"node":"Email Research Digest","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}