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.
- package/README.md +23 -22
- package/dist/ai/enrich.d.ts +24 -3
- package/dist/ai/enrich.mjs +9 -1
- package/dist/{chunk-GNIBTUEK.mjs → chunk-2W623LCW.mjs} +384 -206
- package/dist/{chunk-ZF4M6GMB.mjs → chunk-ESTBP7AD.mjs} +51 -26
- package/dist/{chunk-RLVH7LEG.mjs → chunk-OA76XD32.mjs} +143 -7
- package/dist/{chunk-554O5ED6.mjs → chunk-QCB3U4AO.mjs} +12 -1
- package/dist/collections.d.ts +3 -3
- package/dist/collections.mjs +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.mjs +68 -10
- package/dist/products.d.ts +21 -4
- package/dist/products.mjs +1 -1
- package/dist/store.d.ts +19 -7
- package/dist/store.mjs +3 -3
- package/dist/{types-luPg5O08.d.ts → types-BRXamZMS.d.ts} +14 -1
- package/dist/utils/detect-country.d.ts +1 -1
- package/dist/utils/func.d.ts +1 -1
- package/package.json +1 -1
|
@@ -4,15 +4,246 @@ import {
|
|
|
4
4
|
|
|
5
5
|
// src/ai/enrich.ts
|
|
6
6
|
import TurndownService from "turndown";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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(
|
|
248
|
-
var _a, _b, _c;
|
|
249
|
-
|
|
250
|
-
|
|
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 =
|
|
257
|
-
const title =
|
|
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:
|
|
423
|
+
messages: args.messages,
|
|
263
424
|
temperature: 0.2
|
|
264
425
|
});
|
|
265
|
-
const base = (
|
|
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
|
|
268
|
-
|
|
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, ...
|
|
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 = (
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
418
|
-
const
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
529
|
-
const
|
|
530
|
-
const
|
|
531
|
-
|
|
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
|
-
|
|
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
|
|
626
|
-
const
|
|
627
|
-
const
|
|
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: ${(
|
|
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} ${(
|
|
646
|
-
if (
|
|
647
|
-
const text = `${storeInfo.title} ${(
|
|
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 ((
|
|
664
|
-
if ((
|
|
665
|
-
if ((
|
|
666
|
-
if (!((
|
|
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 ((
|
|
670
|
-
if ((
|
|
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
|
-
|
|
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,
|