shop-client 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,246 @@ import {
4
4
 
5
5
  // src/ai/enrich.ts
6
6
  import TurndownService from "turndown";
7
- import { gfm } from "turndown-plugin-gfm";
8
- function ensureOpenRouter(apiKey) {
9
- const key = apiKey || process.env.OPENROUTER_API_KEY;
10
- if (!key) {
11
- throw new Error(
12
- "Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
13
- );
7
+ var DEFAULT_OPENROUTER_MODEL = "openai/gpt-4o-mini";
8
+ var DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
9
+ var DEFAULT_OPENROUTER_APP_TITLE = "Shop Client";
10
+ var SHOPIFY_PRODUCT_IMAGE_PATTERNS = [
11
+ "cdn.shopify.com",
12
+ "/products/",
13
+ "%2Fproducts%2F",
14
+ "_large",
15
+ "_grande",
16
+ "_1024x1024",
17
+ "_2048x"
18
+ ];
19
+ var TURNDOWN_REMOVE_TAGS = ["script", "style", "nav", "footer"];
20
+ var TURNDOWN_REMOVE_CLASSNAMES = [
21
+ "product-form",
22
+ "shopify-payment-button",
23
+ "shopify-payment-buttons",
24
+ "product__actions",
25
+ "product__media-wrapper",
26
+ "loox-rating",
27
+ "jdgm-widget",
28
+ "stamped-reviews",
29
+ "quantity-selector",
30
+ "product-atc-wrapper"
31
+ ];
32
+ var TURNDOWN_REMOVE_NODE_NAMES = ["button", "input", "select", "label"];
33
+ var cachedGfmPlugin;
34
+ var gfmPluginPromise;
35
+ var cachedTurndownPlain;
36
+ var cachedTurndownGfm;
37
+ async function loadGfmPlugin() {
38
+ if (cachedGfmPlugin) return cachedGfmPlugin;
39
+ if (gfmPluginPromise) return gfmPluginPromise;
40
+ gfmPluginPromise = import("turndown-plugin-gfm").then((mod) => {
41
+ var _a, _b, _c, _d;
42
+ const resolved = (_d = (_c = (_b = mod == null ? void 0 : mod.gfm) != null ? _b : (_a = mod == null ? void 0 : mod.default) == null ? void 0 : _a.gfm) != null ? _c : mod == null ? void 0 : mod.default) != null ? _d : mod;
43
+ cachedGfmPlugin = resolved;
44
+ return resolved;
45
+ }).finally(() => {
46
+ gfmPluginPromise = void 0;
47
+ });
48
+ return gfmPluginPromise;
49
+ }
50
+ function configureTurndown(td) {
51
+ for (const tag of TURNDOWN_REMOVE_TAGS) {
52
+ td.remove((node) => {
53
+ var _a;
54
+ return ((_a = node.nodeName) == null ? void 0 : _a.toLowerCase()) === tag;
55
+ });
56
+ }
57
+ const removeByClass = (className) => td.remove((node) => {
58
+ const cls = typeof node.getAttribute === "function" ? node.getAttribute("class") || "" : "";
59
+ return cls.split(/\s+/).includes(className);
60
+ });
61
+ for (const className of TURNDOWN_REMOVE_CLASSNAMES) {
62
+ removeByClass(className);
63
+ }
64
+ for (const nodeName of TURNDOWN_REMOVE_NODE_NAMES) {
65
+ td.remove((node) => {
66
+ var _a;
67
+ return ((_a = node.nodeName) == null ? void 0 : _a.toLowerCase()) === nodeName;
68
+ });
14
69
  }
15
- return key;
70
+ }
71
+ async function getTurndownService(useGfm) {
72
+ if (useGfm && cachedTurndownGfm) return cachedTurndownGfm;
73
+ if (!useGfm && cachedTurndownPlain) return cachedTurndownPlain;
74
+ const td = new TurndownService({
75
+ headingStyle: "atx",
76
+ codeBlockStyle: "fenced",
77
+ bulletListMarker: "-",
78
+ emDelimiter: "*",
79
+ strongDelimiter: "**",
80
+ linkStyle: "inlined"
81
+ });
82
+ if (useGfm) {
83
+ const gfm = await loadGfmPlugin();
84
+ if (gfm) td.use(gfm);
85
+ }
86
+ configureTurndown(td);
87
+ if (useGfm) cachedTurndownGfm = td;
88
+ else cachedTurndownPlain = td;
89
+ return td;
90
+ }
91
+ function buildEnrichPrompt(args) {
92
+ const bodyLabel = args.inputType === "html" ? "BODY HTML" : "BODY MARKDOWN";
93
+ const pageLabel = args.inputType === "html" ? "PAGE HTML" : "PAGE MARKDOWN";
94
+ if (args.outputFormat === "json") {
95
+ return {
96
+ system: "You are a product-data extraction engine. Combine two sources (Shopify body_html and product page) into a single structured summary. Return ONLY valid JSON (no markdown, no code fences, no extra text).",
97
+ user: `Inputs:
98
+ 1) ${bodyLabel}: ${args.inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
99
+ 2) ${pageLabel}: ${args.inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
100
+
101
+ Return ONLY valid JSON with this shape (include ALL keys; use null/[] when unknown):
102
+ {
103
+ "title": null | string,
104
+ "description": null | string,
105
+ "highlights": string[] | [],
106
+ "features": string[] | [],
107
+ "specs": Record<string, string> | {},
108
+ "materials": string[] | [],
109
+ "care": string[] | [],
110
+ "fit": null | string,
111
+ "sizeGuide": null | string,
112
+ "shipping": null | string,
113
+ "warranty": null | string,
114
+ "images": null | string[],
115
+ "returnPolicy": null | string
116
+ }
117
+
118
+ Rules:
119
+ - Use BOTH sources and deduplicate overlapping content.
120
+ - Prefer the more specific / more recent details when sources differ; never invent facts.
121
+ - Do not invent facts; if a field is unavailable, use null or []
122
+ - Prefer concise, factual statements (avoid marketing fluff).
123
+ - Keep units and measurements as written (e.g., inches/cm); do not convert unless explicitly provided.
124
+ - Do NOT include product gallery/hero images in "images"; include only documentation images like size charts or measurement guides. If none, set "images": null.
125
+ - "specs" is a flat key/value map for concrete attributes (e.g., "Made in": "Portugal", "Weight": "320g"). Use {} if none.
126
+
127
+ ${bodyLabel}:
128
+ ${args.bodyInput}
129
+
130
+ ${pageLabel}:
131
+ ${args.pageInput}`
132
+ };
133
+ }
134
+ return {
135
+ system: "You merge Shopify product content into a single buyer-ready markdown description. Output ONLY markdown (no code fences, no commentary).",
136
+ user: `Inputs:
137
+ 1) ${bodyLabel}: ${args.inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
138
+ 2) ${pageLabel}: ${args.inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
139
+
140
+ Tasks:
141
+ - Merge both sources into a single clean markdown document and deduplicate overlap.
142
+ - Keep only buyer-useful info (concrete details); remove theme/UI junk, menus, buttons, upsells, reviews widgets, legal boilerplate.
143
+ - Do NOT include product gallery/hero images. If documentation images exist (size chart, measurement guide, care diagram), include them.
144
+ - Do NOT list interactive option selectors (e.g., "Choose Size" buttons), but DO keep meaningful option information (e.g., sizing notes, fit guidance, measurement tables, what's included).
145
+ - If sources disagree, prefer the more specific detail; never invent facts.
146
+ - Do not write "information not available" or similar.
147
+ - Use this section order when content exists (omit empty sections):
148
+ - ## Overview (2\u20134 sentences)
149
+ - ## Key Features (bullets)
150
+ - ## Materials
151
+ - ## Care
152
+ - ## Fit & Sizing
153
+ - ## Size Guide (include documentation images + key measurements)
154
+ - ## Shipping & Returns
155
+
156
+ ${bodyLabel}:
157
+ ${args.bodyInput}
158
+
159
+ ${pageLabel}:
160
+ ${args.pageInput}`
161
+ };
162
+ }
163
+ function buildClassifyPrompt(productContent) {
164
+ return {
165
+ system: "You classify products using a three-tier hierarchy. Return only valid JSON without markdown or code fences.",
166
+ user: `Classify the following product using a three-tiered hierarchy:
167
+
168
+ Product Content:
169
+ ${productContent}
170
+
171
+ Classification Rules:
172
+ 1. First determine the vertical (main product category)
173
+ 2. Then determine the category (specific type within that vertical)
174
+ 3. Finally determine the subCategory (sub-type within that category)
175
+
176
+ Vertical must be one of: clothing, beauty, accessories, home-decor, food-and-beverages
177
+ Audience must be one of: adult_male, adult_female, kid_male, kid_female, generic
178
+
179
+ Hierarchy Examples:
180
+ - Clothing \u2192 tops \u2192 t-shirts
181
+ - Clothing \u2192 footwear \u2192 sneakers
182
+ - Beauty \u2192 skincare \u2192 moisturizers
183
+ - Accessories \u2192 bags \u2192 backpacks
184
+ - Home-decor \u2192 furniture \u2192 chairs
185
+ - Food-and-beverages \u2192 snacks \u2192 chips
186
+
187
+ IMPORTANT CONSTRAINTS:
188
+ - Category must be relevant to the chosen vertical
189
+ - subCategory must be relevant to both vertical and category
190
+ - subCategory must be a single word or hyphenated words (no spaces)
191
+ - subCategory should NOT be material (e.g., "cotton", "leather") or color (e.g., "red", "blue")
192
+ - Focus on product type/function, not attributes
193
+
194
+ If you're not confident about category or sub-category, you can leave them optional.
195
+
196
+ Return ONLY valid JSON (no markdown, no code fences) with keys:
197
+ {
198
+ "audience": "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic",
199
+ "vertical": "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages",
200
+ "category": null | string,
201
+ "subCategory": null | string
202
+ }`
203
+ };
204
+ }
205
+ async function buildEnrichPromptForProduct(domain, handle, options) {
206
+ var _a, _b;
207
+ const [ajaxProduct, pageHtml] = await Promise.all([
208
+ fetchAjaxProduct(domain, handle),
209
+ fetchProductPage(domain, handle)
210
+ ]);
211
+ const bodyHtml = ajaxProduct.description || "";
212
+ const extractedHtml = extractMainSection(pageHtml);
213
+ const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
214
+ const outputFormat = (_b = options == null ? void 0 : options.outputFormat) != null ? _b : "markdown";
215
+ const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
216
+ const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
217
+ useGfm: options == null ? void 0 : options.useGfm
218
+ });
219
+ return buildEnrichPrompt({ bodyInput, pageInput, inputType, outputFormat });
220
+ }
221
+ async function buildClassifyPromptForProduct(domain, handle, options) {
222
+ var _a;
223
+ const [ajaxProduct, pageHtml] = await Promise.all([
224
+ fetchAjaxProduct(domain, handle),
225
+ fetchProductPage(domain, handle)
226
+ ]);
227
+ const bodyHtml = ajaxProduct.description || "";
228
+ const extractedHtml = extractMainSection(pageHtml);
229
+ const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
230
+ const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
231
+ const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
232
+ useGfm: options == null ? void 0 : options.useGfm
233
+ });
234
+ const header = [
235
+ `Title: ${String(ajaxProduct.title || "")}`.trim(),
236
+ ajaxProduct.vendor ? `Vendor: ${String(ajaxProduct.vendor)}` : null,
237
+ Array.isArray(ajaxProduct.tags) && ajaxProduct.tags.length ? `Tags: ${ajaxProduct.tags.join(", ")}` : null
238
+ ].filter((s) => Boolean(s && s.trim())).join("\n");
239
+ const productContent = [
240
+ header,
241
+ `Body:
242
+ ${bodyInput}`.trim(),
243
+ `Page:
244
+ ${pageInput}`.trim()
245
+ ].filter((s) => Boolean(s && s.trim())).join("\n\n");
246
+ return buildClassifyPrompt(productContent);
16
247
  }
17
248
  function normalizeDomainToBase(domain) {
18
249
  if (domain.startsWith("http://") || domain.startsWith("https://")) {
@@ -51,111 +282,41 @@ function extractMainSection(html) {
51
282
  if (endIndex === -1) return null;
52
283
  return html.substring(startIndex, endIndex + "</section>".length);
53
284
  }
54
- function htmlToMarkdown(html, options) {
285
+ async function htmlToMarkdown(html, options) {
55
286
  var _a;
56
287
  if (!html) return "";
57
- const td = new TurndownService({
58
- headingStyle: "atx",
59
- codeBlockStyle: "fenced",
60
- bulletListMarker: "-",
61
- emDelimiter: "*",
62
- strongDelimiter: "**",
63
- linkStyle: "inlined"
64
- });
65
288
  const useGfm = (_a = options == null ? void 0 : options.useGfm) != null ? _a : true;
66
- if (useGfm) {
67
- td.use(gfm);
68
- }
69
- ["script", "style", "nav", "footer"].forEach((tag) => {
70
- td.remove((node) => {
71
- var _a2;
72
- return ((_a2 = node.nodeName) == null ? void 0 : _a2.toLowerCase()) === tag;
73
- });
74
- });
75
- const removeByClass = (className) => td.remove((node) => {
76
- const cls = typeof node.getAttribute === "function" ? node.getAttribute("class") || "" : "";
77
- return cls.split(/\s+/).includes(className);
78
- });
79
- [
80
- "product-form",
81
- "shopify-payment-button",
82
- "shopify-payment-buttons",
83
- "product__actions",
84
- "product__media-wrapper",
85
- "loox-rating",
86
- "jdgm-widget",
87
- "stamped-reviews"
88
- ].forEach(removeByClass);
89
- ["button", "input", "select", "label"].forEach((tag) => {
90
- td.remove((node) => {
91
- var _a2;
92
- return ((_a2 = node.nodeName) == null ? void 0 : _a2.toLowerCase()) === tag;
93
- });
94
- });
95
- ["quantity-selector", "product-atc-wrapper"].forEach(removeByClass);
289
+ const td = await getTurndownService(useGfm);
96
290
  return td.turndown(html);
97
291
  }
98
292
  async function mergeWithLLM(bodyInput, pageInput, options) {
99
- var _a, _b;
293
+ var _a, _b, _c, _d, _e, _f;
100
294
  const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
101
- const bodyLabel = inputType === "html" ? "BODY HTML" : "BODY MARKDOWN";
102
- const pageLabel = inputType === "html" ? "PAGE HTML" : "PAGE MARKDOWN";
103
- const prompt = (options == null ? void 0 : options.outputFormat) === "json" ? `You are extracting structured buyer-useful information from Shopify product content.
104
-
105
- Inputs:
106
- 1) ${bodyLabel}: ${inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
107
- 2) ${pageLabel}: ${inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
108
-
109
- Return ONLY valid JSON (no markdown, no code fences) with this shape:
110
- {
111
- "title": null | string,
112
- "description": null | string,
113
- "materials": string[] | [],
114
- "care": string[] | [],
115
- "fit": null | string,
116
- "images": null | string[],
117
- "returnPolicy": null | string
118
- }
119
-
120
- Rules:
121
- - Do not invent facts; if a field is unavailable, use null or []
122
- - Prefer concise, factual statements
123
- - Do NOT include product gallery/hero images in "images"; include only documentation images like size charts or measurement guides. If none, set "images": null.
124
-
125
- ${bodyLabel}:
126
- ${bodyInput}
127
-
128
- ${pageLabel}:
129
- ${pageInput}
130
- ` : `
131
- You are enriching a Shopify product for a modern shopping-discovery app.
132
-
133
- Inputs:
134
- 1) ${bodyLabel}: ${inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
135
- 2) ${pageLabel}: ${inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
136
-
137
- Your tasks:
138
- - Merge them into a single clean markdown document
139
- - Remove duplicate content
140
- - Remove product images
141
- - Remove UI text, buttons, menus, review widgets, theme junk
142
- - Remove product options
143
- - Keep only available buyer-useful info: features, materials, care, fit, size chart, return policy, size chart, care instructions
144
- - Include image of size-chart if present
145
- - Don't include statements like information not available.
146
- - Maintain structured headings (## Description, ## Materials, etc.)
147
- - Output ONLY markdown (no commentary)
148
-
149
- ${bodyLabel}:
150
- ${bodyInput}
151
-
152
- ${pageLabel}:
153
- ${pageInput}
154
- `;
155
- const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
156
- const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
157
- const model = (_b = options == null ? void 0 : options.model) != null ? _b : defaultModel;
158
- const result = await callOpenRouter(model, prompt, apiKey);
295
+ const outputFormat = (_b = options == null ? void 0 : options.outputFormat) != null ? _b : "markdown";
296
+ const prompts = buildEnrichPrompt({
297
+ bodyInput,
298
+ pageInput,
299
+ inputType,
300
+ outputFormat
301
+ });
302
+ const openRouter = options == null ? void 0 : options.openRouter;
303
+ const offline = (_c = openRouter == null ? void 0 : openRouter.offline) != null ? _c : false;
304
+ const apiKey = (_d = options == null ? void 0 : options.apiKey) != null ? _d : openRouter == null ? void 0 : openRouter.apiKey;
305
+ if (!offline && !apiKey) {
306
+ throw new Error(
307
+ "Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
308
+ );
309
+ }
310
+ const model = (_f = (_e = options == null ? void 0 : options.model) != null ? _e : openRouter == null ? void 0 : openRouter.model) != null ? _f : DEFAULT_OPENROUTER_MODEL;
311
+ const result = await callOpenRouter({
312
+ model,
313
+ messages: [
314
+ { role: "system", content: prompts.system },
315
+ { role: "user", content: prompts.user }
316
+ ],
317
+ apiKey,
318
+ openRouter
319
+ });
159
320
  if ((options == null ? void 0 : options.outputFormat) === "json") {
160
321
  const cleaned = result.replace(/```json|```/g, "").trim();
161
322
  const obj = safeParseJson(cleaned);
@@ -171,16 +332,7 @@ ${pageInput}
171
332
  const filtered = value.images.filter((url) => {
172
333
  if (typeof url !== "string") return false;
173
334
  const u = url.toLowerCase();
174
- const productPatterns = [
175
- "cdn.shopify.com",
176
- "/products/",
177
- "%2Fproducts%2F",
178
- "_large",
179
- "_grande",
180
- "_1024x1024",
181
- "_2048x"
182
- ];
183
- const looksLikeProductImage = productPatterns.some(
335
+ const looksLikeProductImage = SHOPIFY_PRODUCT_IMAGE_PATTERNS.some(
184
336
  (p) => u.includes(p)
185
337
  );
186
338
  return !looksLikeProductImage;
@@ -244,30 +396,44 @@ function validateStructuredJson(obj) {
244
396
  }
245
397
  return { ok: true };
246
398
  }
247
- async function callOpenRouter(model, prompt, apiKey) {
248
- var _a, _b, _c;
249
- if (process.env.OPENROUTER_OFFLINE === "1") {
250
- return mockOpenRouterResponse(prompt);
399
+ async function callOpenRouter(args) {
400
+ var _a, _b, _c, _d, _e, _f, _g, _h;
401
+ const openRouter = args.openRouter;
402
+ if (openRouter == null ? void 0 : openRouter.offline) {
403
+ return mockOpenRouterResponse(
404
+ args.messages.map((m) => m.content).join("\n")
405
+ );
406
+ }
407
+ const apiKey = (_a = args.apiKey) != null ? _a : openRouter == null ? void 0 : openRouter.apiKey;
408
+ if (!apiKey) {
409
+ throw new Error(
410
+ "Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
411
+ );
251
412
  }
252
413
  const headers = {
253
414
  "Content-Type": "application/json",
254
415
  Authorization: `Bearer ${apiKey}`
255
416
  };
256
- const referer = process.env.OPENROUTER_SITE_URL || process.env.SITE_URL;
257
- const title = process.env.OPENROUTER_APP_TITLE || "Shop Client";
417
+ const referer = openRouter == null ? void 0 : openRouter.siteUrl;
418
+ const title = (_b = openRouter == null ? void 0 : openRouter.appTitle) != null ? _b : DEFAULT_OPENROUTER_APP_TITLE;
258
419
  if (referer) headers["HTTP-Referer"] = referer;
259
420
  if (title) headers["X-Title"] = title;
260
421
  const buildPayload = (m) => ({
261
422
  model: m,
262
- messages: [{ role: "user", content: prompt }],
423
+ messages: args.messages,
263
424
  temperature: 0.2
264
425
  });
265
- const base = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/$/, "");
426
+ const base = ((_c = openRouter == null ? void 0 : openRouter.baseUrl) != null ? _c : DEFAULT_OPENROUTER_BASE_URL).replace(
427
+ /\/$/,
428
+ ""
429
+ );
266
430
  const endpoints = [`${base}/chat/completions`];
267
- const fallbackEnv = (process.env.OPENROUTER_FALLBACK_MODELS || "").split(",").map((s) => s.trim()).filter(Boolean);
268
- const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
431
+ const fallbackModels = ((_d = openRouter == null ? void 0 : openRouter.fallbackModels) != null ? _d : []).filter(
432
+ (s) => typeof s === "string" && Boolean(s.trim())
433
+ );
434
+ const defaultModel = (_e = openRouter == null ? void 0 : openRouter.model) != null ? _e : DEFAULT_OPENROUTER_MODEL;
269
435
  const modelsToTry = Array.from(
270
- /* @__PURE__ */ new Set([model, ...fallbackEnv, defaultModel])
436
+ /* @__PURE__ */ new Set([args.model, ...fallbackModels, defaultModel])
271
437
  ).filter(Boolean);
272
438
  let lastErrorText = "";
273
439
  for (const m of modelsToTry) {
@@ -290,7 +456,7 @@ async function callOpenRouter(model, prompt, apiKey) {
290
456
  continue;
291
457
  }
292
458
  const data = await response.json();
293
- const content = (_c = (_b = (_a = data == null ? void 0 : data.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
459
+ const content = (_h = (_g = (_f = data == null ? void 0 : data.choices) == null ? void 0 : _f[0]) == null ? void 0 : _g.message) == null ? void 0 : _h.content;
294
460
  if (typeof content === "string") return content;
295
461
  lastErrorText = JSON.stringify(data);
296
462
  await new Promise((r) => setTimeout(r, 200));
@@ -333,18 +499,23 @@ function mockOpenRouterResponse(prompt) {
333
499
  }
334
500
  async function enrichProduct(domain, handle, options) {
335
501
  var _a;
336
- const ajaxProduct = await fetchAjaxProduct(domain, handle);
502
+ const [ajaxProduct, pageHtml] = await Promise.all([
503
+ fetchAjaxProduct(domain, handle),
504
+ fetchProductPage(domain, handle)
505
+ ]);
337
506
  const bodyHtml = ajaxProduct.description || "";
338
- const pageHtml = await fetchProductPage(domain, handle);
339
507
  const extractedHtml = extractMainSection(pageHtml);
340
508
  const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
341
- const bodyInput = inputType === "html" ? bodyHtml : htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
342
- const pageInput = inputType === "html" ? extractedHtml || pageHtml : htmlToMarkdown(extractedHtml, { useGfm: options == null ? void 0 : options.useGfm });
509
+ const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
510
+ const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
511
+ useGfm: options == null ? void 0 : options.useGfm
512
+ });
343
513
  const mergedMarkdown = await mergeWithLLM(bodyInput, pageInput, {
344
514
  apiKey: options == null ? void 0 : options.apiKey,
345
515
  inputType,
346
516
  model: options == null ? void 0 : options.model,
347
- outputFormat: options == null ? void 0 : options.outputFormat
517
+ outputFormat: options == null ? void 0 : options.outputFormat,
518
+ openRouter: options == null ? void 0 : options.openRouter
348
519
  });
349
520
  if ((options == null ? void 0 : options.outputFormat) === "json") {
350
521
  try {
@@ -379,16 +550,7 @@ async function enrichProduct(domain, handle, options) {
379
550
  if (typeof url !== "string") return false;
380
551
  const u = url.toLowerCase();
381
552
  if (productSet.has(u)) return false;
382
- const productPatterns = [
383
- "cdn.shopify.com",
384
- "/products/",
385
- "%2Fproducts%2F",
386
- "_large",
387
- "_grande",
388
- "_1024x1024",
389
- "_2048x"
390
- ];
391
- const looksLikeProductImage = productPatterns.some(
553
+ const looksLikeProductImage = SHOPIFY_PRODUCT_IMAGE_PATTERNS.some(
392
554
  (p) => u.includes(p)
393
555
  );
394
556
  return !looksLikeProductImage;
@@ -413,48 +575,26 @@ async function enrichProduct(domain, handle, options) {
413
575
  };
414
576
  }
415
577
  async function classifyProduct(productContent, options) {
416
- var _a;
417
- const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
418
- const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
419
- const model = (_a = options == null ? void 0 : options.model) != null ? _a : defaultModel;
420
- const prompt = `Classify the following product using a three-tiered hierarchy:
421
-
422
- Product Content:
423
- ${productContent}
424
-
425
- Classification Rules:
426
- 1. First determine the vertical (main product category)
427
- 2. Then determine the category (specific type within that vertical)
428
- 3. Finally determine the subCategory (sub-type within that category)
429
-
430
- Vertical must be one of: clothing, beauty, accessories, home-decor, food-and-beverages
431
- Audience must be one of: adult_male, adult_female, kid_male, kid_female, generic
432
-
433
- Hierarchy Examples:
434
- - Clothing \u2192 tops \u2192 t-shirts
435
- - Clothing \u2192 footwear \u2192 sneakers
436
- - Beauty \u2192 skincare \u2192 moisturizers
437
- - Accessories \u2192 bags \u2192 backpacks
438
- - Home-decor \u2192 furniture \u2192 chairs
439
- - Food-and-beverages \u2192 snacks \u2192 chips
440
-
441
- IMPORTANT CONSTRAINTS:
442
- - Category must be relevant to the chosen vertical
443
- - subCategory must be relevant to both vertical and category
444
- - subCategory must be a single word or hyphenated words (no spaces)
445
- - subCategory should NOT be material (e.g., "cotton", "leather") or color (e.g., "red", "blue")
446
- - Focus on product type/function, not attributes
447
-
448
- If you're not confident about category or sub-category, you can leave them optional.
449
-
450
- Return ONLY valid JSON (no markdown, no code fences) with keys:
451
- {
452
- "audience": "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic",
453
- "vertical": "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages",
454
- "category": null | string,
455
- "subCategory": null | string
456
- }`;
457
- const raw = await callOpenRouter(model, prompt, apiKey);
578
+ var _a, _b, _c, _d;
579
+ const openRouter = options == null ? void 0 : options.openRouter;
580
+ const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
581
+ const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
582
+ if (!offline && !apiKey) {
583
+ throw new Error(
584
+ "Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
585
+ );
586
+ }
587
+ const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
588
+ const prompts = buildClassifyPrompt(productContent);
589
+ const raw = await callOpenRouter({
590
+ model,
591
+ messages: [
592
+ { role: "system", content: prompts.system },
593
+ { role: "user", content: prompts.user }
594
+ ],
595
+ apiKey,
596
+ openRouter
597
+ });
458
598
  const cleaned = raw.replace(/```json|```/g, "").trim();
459
599
  const parsed = safeParseJson(cleaned);
460
600
  if (!parsed.ok) {
@@ -524,11 +664,12 @@ function validateClassification(obj) {
524
664
  };
525
665
  }
526
666
  async function generateSEOContent(product, options) {
527
- var _a;
528
- const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
529
- const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
530
- const model = (_a = options == null ? void 0 : options.model) != null ? _a : defaultModel;
531
- if (process.env.OPENROUTER_OFFLINE === "1") {
667
+ var _a, _b, _c, _d;
668
+ const openRouter = options == null ? void 0 : options.openRouter;
669
+ const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
670
+ const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
671
+ const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
672
+ if (offline) {
532
673
  const baseTags = Array.isArray(product.tags) ? product.tags.slice(0, 6) : [];
533
674
  const titlePart = product.title.trim().slice(0, 50);
534
675
  const vendorPart = (product.vendor || "").trim();
@@ -572,7 +713,23 @@ Return ONLY valid JSON (no markdown, no code fences) with keys: {
572
713
  "tags": string[],
573
714
  "marketingCopy": string
574
715
  }`;
575
- const raw = await callOpenRouter(model, prompt, apiKey);
716
+ if (!apiKey) {
717
+ throw new Error(
718
+ "Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
719
+ );
720
+ }
721
+ const raw = await callOpenRouter({
722
+ model,
723
+ messages: [
724
+ {
725
+ role: "system",
726
+ content: "You generate SEO content and return only valid JSON without markdown or code fences."
727
+ },
728
+ { role: "user", content: prompt }
729
+ ],
730
+ apiKey,
731
+ openRouter
732
+ });
576
733
  const cleaned = raw.replace(/```json|```/g, "").trim();
577
734
  const parsed = safeParseJson(cleaned);
578
735
  if (!parsed.ok) {
@@ -621,10 +778,11 @@ function validateSEOContent(obj) {
621
778
  };
622
779
  }
623
780
  async function determineStoreType(storeInfo, options) {
624
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
625
- const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
626
- const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
627
- const model = (_a = options == null ? void 0 : options.model) != null ? _a : defaultModel;
781
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
782
+ const openRouter = options == null ? void 0 : options.openRouter;
783
+ const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
784
+ const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
785
+ const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
628
786
  const productLines = Array.isArray(storeInfo.showcase.products) ? storeInfo.showcase.products.slice(0, 10).map((p) => {
629
787
  if (typeof p === "string") return `- ${p}`;
630
788
  const pt = typeof (p == null ? void 0 : p.productType) === "string" && p.productType.trim() ? p.productType : "N/A";
@@ -635,16 +793,16 @@ async function determineStoreType(storeInfo, options) {
635
793
  return `- ${String((c == null ? void 0 : c.title) || "N/A")}`;
636
794
  }) : [];
637
795
  const storeContent = `Store Title: ${storeInfo.title}
638
- Store Description: ${(_b = storeInfo.description) != null ? _b : "N/A"}
796
+ Store Description: ${(_e = storeInfo.description) != null ? _e : "N/A"}
639
797
 
640
798
  Sample Products:
641
799
  ${productLines.join("\n") || "- N/A"}
642
800
 
643
801
  Sample Collections:
644
802
  ${collectionLines.join("\n") || "- N/A"}`;
645
- const textNormalized = `${storeInfo.title} ${(_c = storeInfo.description) != null ? _c : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
646
- if (process.env.OPENROUTER_OFFLINE === "1") {
647
- const text = `${storeInfo.title} ${(_d = storeInfo.description) != null ? _d : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
803
+ const textNormalized = `${storeInfo.title} ${(_f = storeInfo.description) != null ? _f : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
804
+ if (offline) {
805
+ const text = `${storeInfo.title} ${(_g = storeInfo.description) != null ? _g : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
648
806
  const verticalKeywords = {
649
807
  clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
650
808
  beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
@@ -660,14 +818,14 @@ ${collectionLines.join("\n") || "- N/A"}`;
660
818
  adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
661
819
  };
662
820
  const audiences = [];
663
- if ((_e = audienceKeywords.kid) == null ? void 0 : _e.test(text)) {
664
- if ((_f = audienceKeywords.kid_male) == null ? void 0 : _f.test(text)) audiences.push("kid_male");
665
- if ((_g = audienceKeywords.kid_female) == null ? void 0 : _g.test(text)) audiences.push("kid_female");
666
- if (!((_h = audienceKeywords.kid_male) == null ? void 0 : _h.test(text)) && !((_i = audienceKeywords.kid_female) == null ? void 0 : _i.test(text)))
821
+ if ((_h = audienceKeywords.kid) == null ? void 0 : _h.test(text)) {
822
+ if ((_i = audienceKeywords.kid_male) == null ? void 0 : _i.test(text)) audiences.push("kid_male");
823
+ if ((_j = audienceKeywords.kid_female) == null ? void 0 : _j.test(text)) audiences.push("kid_female");
824
+ if (!((_k = audienceKeywords.kid_male) == null ? void 0 : _k.test(text)) && !((_l = audienceKeywords.kid_female) == null ? void 0 : _l.test(text)))
667
825
  audiences.push("generic");
668
826
  } else {
669
- if ((_j = audienceKeywords.adult_male) == null ? void 0 : _j.test(text)) audiences.push("adult_male");
670
- if ((_k = audienceKeywords.adult_female) == null ? void 0 : _k.test(text))
827
+ if ((_m = audienceKeywords.adult_male) == null ? void 0 : _m.test(text)) audiences.push("adult_male");
828
+ if ((_n = audienceKeywords.adult_female) == null ? void 0 : _n.test(text))
671
829
  audiences.push("adult_female");
672
830
  if (audiences.length === 0) audiences.push("generic");
673
831
  }
@@ -713,7 +871,23 @@ Rules:
713
871
  - Nested keys MUST be vertical: "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages".
714
872
  - Values MUST be non-empty arrays of category strings.
715
873
  `;
716
- const raw = await callOpenRouter(model, prompt, apiKey);
874
+ if (!apiKey) {
875
+ throw new Error(
876
+ "Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
877
+ );
878
+ }
879
+ const raw = await callOpenRouter({
880
+ model,
881
+ messages: [
882
+ {
883
+ role: "system",
884
+ content: "You analyze a Shopify store and return only valid JSON without markdown or code fences."
885
+ },
886
+ { role: "user", content: prompt }
887
+ ],
888
+ apiKey,
889
+ openRouter
890
+ });
717
891
  const cleaned = raw.replace(/```json|```/g, "").trim();
718
892
  const parsed = safeParseJson(cleaned);
719
893
  if (!parsed.ok) {
@@ -852,6 +1026,10 @@ function pruneBreakdownForSignals(breakdown, text) {
852
1026
  }
853
1027
 
854
1028
  export {
1029
+ buildEnrichPrompt,
1030
+ buildClassifyPrompt,
1031
+ buildEnrichPromptForProduct,
1032
+ buildClassifyPromptForProduct,
855
1033
  fetchAjaxProduct,
856
1034
  fetchProductPage,
857
1035
  extractMainSection,