shop-client 3.14.1 → 3.16.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 +48 -22
- package/dist/ai/enrich.d.ts +27 -3
- package/dist/ai/enrich.mjs +9 -1
- package/dist/{chunk-7IMI76JZ.mjs → chunk-OA76XD32.mjs} +15 -2
- package/dist/{chunk-554O5ED6.mjs → chunk-QCB3U4AO.mjs} +12 -1
- package/dist/{chunk-ZF4M6GMB.mjs → chunk-THCO3JT4.mjs} +94 -29
- package/dist/{chunk-GNIBTUEK.mjs → chunk-ZX4IG4TY.mjs} +390 -206
- package/dist/collections.d.ts +1 -1
- package/dist/collections.mjs +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.mjs +10 -6
- package/dist/products.d.ts +30 -3
- package/dist/products.mjs +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.mjs +1 -1
- 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,250 @@ 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 ajaxProductPromise = fetchAjaxProduct(domain, handle);
|
|
208
|
+
const pageHtmlPromise = (options == null ? void 0 : options.htmlContent) ? Promise.resolve(options.htmlContent) : fetchProductPage(domain, handle);
|
|
209
|
+
const [ajaxProduct, pageHtml] = await Promise.all([
|
|
210
|
+
ajaxProductPromise,
|
|
211
|
+
pageHtmlPromise
|
|
212
|
+
]);
|
|
213
|
+
const bodyHtml = ajaxProduct.description || "";
|
|
214
|
+
const extractedHtml = extractMainSection(pageHtml);
|
|
215
|
+
const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
|
|
216
|
+
const outputFormat = (_b = options == null ? void 0 : options.outputFormat) != null ? _b : "markdown";
|
|
217
|
+
const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
|
|
218
|
+
const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
|
|
219
|
+
useGfm: options == null ? void 0 : options.useGfm
|
|
220
|
+
});
|
|
221
|
+
return buildEnrichPrompt({ bodyInput, pageInput, inputType, outputFormat });
|
|
222
|
+
}
|
|
223
|
+
async function buildClassifyPromptForProduct(domain, handle, options) {
|
|
224
|
+
var _a;
|
|
225
|
+
const ajaxProductPromise = fetchAjaxProduct(domain, handle);
|
|
226
|
+
const pageHtmlPromise = (options == null ? void 0 : options.htmlContent) ? Promise.resolve(options.htmlContent) : fetchProductPage(domain, handle);
|
|
227
|
+
const [ajaxProduct, pageHtml] = await Promise.all([
|
|
228
|
+
ajaxProductPromise,
|
|
229
|
+
pageHtmlPromise
|
|
230
|
+
]);
|
|
231
|
+
const bodyHtml = ajaxProduct.description || "";
|
|
232
|
+
const extractedHtml = extractMainSection(pageHtml);
|
|
233
|
+
const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
|
|
234
|
+
const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
|
|
235
|
+
const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
|
|
236
|
+
useGfm: options == null ? void 0 : options.useGfm
|
|
237
|
+
});
|
|
238
|
+
const header = [
|
|
239
|
+
`Title: ${String(ajaxProduct.title || "")}`.trim(),
|
|
240
|
+
ajaxProduct.vendor ? `Vendor: ${String(ajaxProduct.vendor)}` : null,
|
|
241
|
+
Array.isArray(ajaxProduct.tags) && ajaxProduct.tags.length ? `Tags: ${ajaxProduct.tags.join(", ")}` : null
|
|
242
|
+
].filter((s) => Boolean(s && s.trim())).join("\n");
|
|
243
|
+
const productContent = [
|
|
244
|
+
header,
|
|
245
|
+
`Body:
|
|
246
|
+
${bodyInput}`.trim(),
|
|
247
|
+
`Page:
|
|
248
|
+
${pageInput}`.trim()
|
|
249
|
+
].filter((s) => Boolean(s && s.trim())).join("\n\n");
|
|
250
|
+
return buildClassifyPrompt(productContent);
|
|
16
251
|
}
|
|
17
252
|
function normalizeDomainToBase(domain) {
|
|
18
253
|
if (domain.startsWith("http://") || domain.startsWith("https://")) {
|
|
@@ -51,111 +286,41 @@ function extractMainSection(html) {
|
|
|
51
286
|
if (endIndex === -1) return null;
|
|
52
287
|
return html.substring(startIndex, endIndex + "</section>".length);
|
|
53
288
|
}
|
|
54
|
-
function htmlToMarkdown(html, options) {
|
|
289
|
+
async function htmlToMarkdown(html, options) {
|
|
55
290
|
var _a;
|
|
56
291
|
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
292
|
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);
|
|
293
|
+
const td = await getTurndownService(useGfm);
|
|
96
294
|
return td.turndown(html);
|
|
97
295
|
}
|
|
98
296
|
async function mergeWithLLM(bodyInput, pageInput, options) {
|
|
99
|
-
var _a, _b;
|
|
297
|
+
var _a, _b, _c, _d, _e, _f;
|
|
100
298
|
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);
|
|
299
|
+
const outputFormat = (_b = options == null ? void 0 : options.outputFormat) != null ? _b : "markdown";
|
|
300
|
+
const prompts = buildEnrichPrompt({
|
|
301
|
+
bodyInput,
|
|
302
|
+
pageInput,
|
|
303
|
+
inputType,
|
|
304
|
+
outputFormat
|
|
305
|
+
});
|
|
306
|
+
const openRouter = options == null ? void 0 : options.openRouter;
|
|
307
|
+
const offline = (_c = openRouter == null ? void 0 : openRouter.offline) != null ? _c : false;
|
|
308
|
+
const apiKey = (_d = options == null ? void 0 : options.apiKey) != null ? _d : openRouter == null ? void 0 : openRouter.apiKey;
|
|
309
|
+
if (!offline && !apiKey) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const model = (_f = (_e = options == null ? void 0 : options.model) != null ? _e : openRouter == null ? void 0 : openRouter.model) != null ? _f : DEFAULT_OPENROUTER_MODEL;
|
|
315
|
+
const result = await callOpenRouter({
|
|
316
|
+
model,
|
|
317
|
+
messages: [
|
|
318
|
+
{ role: "system", content: prompts.system },
|
|
319
|
+
{ role: "user", content: prompts.user }
|
|
320
|
+
],
|
|
321
|
+
apiKey,
|
|
322
|
+
openRouter
|
|
323
|
+
});
|
|
159
324
|
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
160
325
|
const cleaned = result.replace(/```json|```/g, "").trim();
|
|
161
326
|
const obj = safeParseJson(cleaned);
|
|
@@ -171,16 +336,7 @@ ${pageInput}
|
|
|
171
336
|
const filtered = value.images.filter((url) => {
|
|
172
337
|
if (typeof url !== "string") return false;
|
|
173
338
|
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(
|
|
339
|
+
const looksLikeProductImage = SHOPIFY_PRODUCT_IMAGE_PATTERNS.some(
|
|
184
340
|
(p) => u.includes(p)
|
|
185
341
|
);
|
|
186
342
|
return !looksLikeProductImage;
|
|
@@ -244,30 +400,44 @@ function validateStructuredJson(obj) {
|
|
|
244
400
|
}
|
|
245
401
|
return { ok: true };
|
|
246
402
|
}
|
|
247
|
-
async function callOpenRouter(
|
|
248
|
-
var _a, _b, _c;
|
|
249
|
-
|
|
250
|
-
|
|
403
|
+
async function callOpenRouter(args) {
|
|
404
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
405
|
+
const openRouter = args.openRouter;
|
|
406
|
+
if (openRouter == null ? void 0 : openRouter.offline) {
|
|
407
|
+
return mockOpenRouterResponse(
|
|
408
|
+
args.messages.map((m) => m.content).join("\n")
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const apiKey = (_a = args.apiKey) != null ? _a : openRouter == null ? void 0 : openRouter.apiKey;
|
|
412
|
+
if (!apiKey) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
|
|
415
|
+
);
|
|
251
416
|
}
|
|
252
417
|
const headers = {
|
|
253
418
|
"Content-Type": "application/json",
|
|
254
419
|
Authorization: `Bearer ${apiKey}`
|
|
255
420
|
};
|
|
256
|
-
const referer =
|
|
257
|
-
const title =
|
|
421
|
+
const referer = openRouter == null ? void 0 : openRouter.siteUrl;
|
|
422
|
+
const title = (_b = openRouter == null ? void 0 : openRouter.appTitle) != null ? _b : DEFAULT_OPENROUTER_APP_TITLE;
|
|
258
423
|
if (referer) headers["HTTP-Referer"] = referer;
|
|
259
424
|
if (title) headers["X-Title"] = title;
|
|
260
425
|
const buildPayload = (m) => ({
|
|
261
426
|
model: m,
|
|
262
|
-
messages:
|
|
427
|
+
messages: args.messages,
|
|
263
428
|
temperature: 0.2
|
|
264
429
|
});
|
|
265
|
-
const base = (
|
|
430
|
+
const base = ((_c = openRouter == null ? void 0 : openRouter.baseUrl) != null ? _c : DEFAULT_OPENROUTER_BASE_URL).replace(
|
|
431
|
+
/\/$/,
|
|
432
|
+
""
|
|
433
|
+
);
|
|
266
434
|
const endpoints = [`${base}/chat/completions`];
|
|
267
|
-
const
|
|
268
|
-
|
|
435
|
+
const fallbackModels = ((_d = openRouter == null ? void 0 : openRouter.fallbackModels) != null ? _d : []).filter(
|
|
436
|
+
(s) => typeof s === "string" && Boolean(s.trim())
|
|
437
|
+
);
|
|
438
|
+
const defaultModel = (_e = openRouter == null ? void 0 : openRouter.model) != null ? _e : DEFAULT_OPENROUTER_MODEL;
|
|
269
439
|
const modelsToTry = Array.from(
|
|
270
|
-
/* @__PURE__ */ new Set([model, ...
|
|
440
|
+
/* @__PURE__ */ new Set([args.model, ...fallbackModels, defaultModel])
|
|
271
441
|
).filter(Boolean);
|
|
272
442
|
let lastErrorText = "";
|
|
273
443
|
for (const m of modelsToTry) {
|
|
@@ -290,7 +460,7 @@ async function callOpenRouter(model, prompt, apiKey) {
|
|
|
290
460
|
continue;
|
|
291
461
|
}
|
|
292
462
|
const data = await response.json();
|
|
293
|
-
const content = (
|
|
463
|
+
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
464
|
if (typeof content === "string") return content;
|
|
295
465
|
lastErrorText = JSON.stringify(data);
|
|
296
466
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -333,18 +503,25 @@ function mockOpenRouterResponse(prompt) {
|
|
|
333
503
|
}
|
|
334
504
|
async function enrichProduct(domain, handle, options) {
|
|
335
505
|
var _a;
|
|
336
|
-
const
|
|
506
|
+
const ajaxProductPromise = fetchAjaxProduct(domain, handle);
|
|
507
|
+
const pageHtmlPromise = (options == null ? void 0 : options.htmlContent) ? Promise.resolve(options.htmlContent) : fetchProductPage(domain, handle);
|
|
508
|
+
const [ajaxProduct, pageHtml] = await Promise.all([
|
|
509
|
+
ajaxProductPromise,
|
|
510
|
+
pageHtmlPromise
|
|
511
|
+
]);
|
|
337
512
|
const bodyHtml = ajaxProduct.description || "";
|
|
338
|
-
const pageHtml = await fetchProductPage(domain, handle);
|
|
339
513
|
const extractedHtml = extractMainSection(pageHtml);
|
|
340
514
|
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, {
|
|
515
|
+
const bodyInput = inputType === "html" ? bodyHtml : await htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
|
|
516
|
+
const pageInput = inputType === "html" ? extractedHtml || pageHtml : await htmlToMarkdown(extractedHtml || pageHtml, {
|
|
517
|
+
useGfm: options == null ? void 0 : options.useGfm
|
|
518
|
+
});
|
|
343
519
|
const mergedMarkdown = await mergeWithLLM(bodyInput, pageInput, {
|
|
344
520
|
apiKey: options == null ? void 0 : options.apiKey,
|
|
345
521
|
inputType,
|
|
346
522
|
model: options == null ? void 0 : options.model,
|
|
347
|
-
outputFormat: options == null ? void 0 : options.outputFormat
|
|
523
|
+
outputFormat: options == null ? void 0 : options.outputFormat,
|
|
524
|
+
openRouter: options == null ? void 0 : options.openRouter
|
|
348
525
|
});
|
|
349
526
|
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
350
527
|
try {
|
|
@@ -379,16 +556,7 @@ async function enrichProduct(domain, handle, options) {
|
|
|
379
556
|
if (typeof url !== "string") return false;
|
|
380
557
|
const u = url.toLowerCase();
|
|
381
558
|
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(
|
|
559
|
+
const looksLikeProductImage = SHOPIFY_PRODUCT_IMAGE_PATTERNS.some(
|
|
392
560
|
(p) => u.includes(p)
|
|
393
561
|
);
|
|
394
562
|
return !looksLikeProductImage;
|
|
@@ -413,48 +581,26 @@ async function enrichProduct(domain, handle, options) {
|
|
|
413
581
|
};
|
|
414
582
|
}
|
|
415
583
|
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);
|
|
584
|
+
var _a, _b, _c, _d;
|
|
585
|
+
const openRouter = options == null ? void 0 : options.openRouter;
|
|
586
|
+
const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
|
|
587
|
+
const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
|
|
588
|
+
if (!offline && !apiKey) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
"Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
|
|
594
|
+
const prompts = buildClassifyPrompt(productContent);
|
|
595
|
+
const raw = await callOpenRouter({
|
|
596
|
+
model,
|
|
597
|
+
messages: [
|
|
598
|
+
{ role: "system", content: prompts.system },
|
|
599
|
+
{ role: "user", content: prompts.user }
|
|
600
|
+
],
|
|
601
|
+
apiKey,
|
|
602
|
+
openRouter
|
|
603
|
+
});
|
|
458
604
|
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
459
605
|
const parsed = safeParseJson(cleaned);
|
|
460
606
|
if (!parsed.ok) {
|
|
@@ -524,11 +670,12 @@ function validateClassification(obj) {
|
|
|
524
670
|
};
|
|
525
671
|
}
|
|
526
672
|
async function generateSEOContent(product, options) {
|
|
527
|
-
var _a;
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
const
|
|
531
|
-
|
|
673
|
+
var _a, _b, _c, _d;
|
|
674
|
+
const openRouter = options == null ? void 0 : options.openRouter;
|
|
675
|
+
const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
|
|
676
|
+
const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
|
|
677
|
+
const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
|
|
678
|
+
if (offline) {
|
|
532
679
|
const baseTags = Array.isArray(product.tags) ? product.tags.slice(0, 6) : [];
|
|
533
680
|
const titlePart = product.title.trim().slice(0, 50);
|
|
534
681
|
const vendorPart = (product.vendor || "").trim();
|
|
@@ -572,7 +719,23 @@ Return ONLY valid JSON (no markdown, no code fences) with keys: {
|
|
|
572
719
|
"tags": string[],
|
|
573
720
|
"marketingCopy": string
|
|
574
721
|
}`;
|
|
575
|
-
|
|
722
|
+
if (!apiKey) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
"Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const raw = await callOpenRouter({
|
|
728
|
+
model,
|
|
729
|
+
messages: [
|
|
730
|
+
{
|
|
731
|
+
role: "system",
|
|
732
|
+
content: "You generate SEO content and return only valid JSON without markdown or code fences."
|
|
733
|
+
},
|
|
734
|
+
{ role: "user", content: prompt }
|
|
735
|
+
],
|
|
736
|
+
apiKey,
|
|
737
|
+
openRouter
|
|
738
|
+
});
|
|
576
739
|
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
577
740
|
const parsed = safeParseJson(cleaned);
|
|
578
741
|
if (!parsed.ok) {
|
|
@@ -621,10 +784,11 @@ function validateSEOContent(obj) {
|
|
|
621
784
|
};
|
|
622
785
|
}
|
|
623
786
|
async function determineStoreType(storeInfo, options) {
|
|
624
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
const
|
|
787
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
|
|
788
|
+
const openRouter = options == null ? void 0 : options.openRouter;
|
|
789
|
+
const offline = (_a = openRouter == null ? void 0 : openRouter.offline) != null ? _a : false;
|
|
790
|
+
const apiKey = (_b = options == null ? void 0 : options.apiKey) != null ? _b : openRouter == null ? void 0 : openRouter.apiKey;
|
|
791
|
+
const model = (_d = (_c = options == null ? void 0 : options.model) != null ? _c : openRouter == null ? void 0 : openRouter.model) != null ? _d : DEFAULT_OPENROUTER_MODEL;
|
|
628
792
|
const productLines = Array.isArray(storeInfo.showcase.products) ? storeInfo.showcase.products.slice(0, 10).map((p) => {
|
|
629
793
|
if (typeof p === "string") return `- ${p}`;
|
|
630
794
|
const pt = typeof (p == null ? void 0 : p.productType) === "string" && p.productType.trim() ? p.productType : "N/A";
|
|
@@ -635,16 +799,16 @@ async function determineStoreType(storeInfo, options) {
|
|
|
635
799
|
return `- ${String((c == null ? void 0 : c.title) || "N/A")}`;
|
|
636
800
|
}) : [];
|
|
637
801
|
const storeContent = `Store Title: ${storeInfo.title}
|
|
638
|
-
Store Description: ${(
|
|
802
|
+
Store Description: ${(_e = storeInfo.description) != null ? _e : "N/A"}
|
|
639
803
|
|
|
640
804
|
Sample Products:
|
|
641
805
|
${productLines.join("\n") || "- N/A"}
|
|
642
806
|
|
|
643
807
|
Sample Collections:
|
|
644
808
|
${collectionLines.join("\n") || "- N/A"}`;
|
|
645
|
-
const textNormalized = `${storeInfo.title} ${(
|
|
646
|
-
if (
|
|
647
|
-
const text = `${storeInfo.title} ${(
|
|
809
|
+
const textNormalized = `${storeInfo.title} ${(_f = storeInfo.description) != null ? _f : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
|
|
810
|
+
if (offline) {
|
|
811
|
+
const text = `${storeInfo.title} ${(_g = storeInfo.description) != null ? _g : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
|
|
648
812
|
const verticalKeywords = {
|
|
649
813
|
clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
|
|
650
814
|
beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
|
|
@@ -660,14 +824,14 @@ ${collectionLines.join("\n") || "- N/A"}`;
|
|
|
660
824
|
adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
|
|
661
825
|
};
|
|
662
826
|
const audiences = [];
|
|
663
|
-
if ((
|
|
664
|
-
if ((
|
|
665
|
-
if ((
|
|
666
|
-
if (!((
|
|
827
|
+
if ((_h = audienceKeywords.kid) == null ? void 0 : _h.test(text)) {
|
|
828
|
+
if ((_i = audienceKeywords.kid_male) == null ? void 0 : _i.test(text)) audiences.push("kid_male");
|
|
829
|
+
if ((_j = audienceKeywords.kid_female) == null ? void 0 : _j.test(text)) audiences.push("kid_female");
|
|
830
|
+
if (!((_k = audienceKeywords.kid_male) == null ? void 0 : _k.test(text)) && !((_l = audienceKeywords.kid_female) == null ? void 0 : _l.test(text)))
|
|
667
831
|
audiences.push("generic");
|
|
668
832
|
} else {
|
|
669
|
-
if ((
|
|
670
|
-
if ((
|
|
833
|
+
if ((_m = audienceKeywords.adult_male) == null ? void 0 : _m.test(text)) audiences.push("adult_male");
|
|
834
|
+
if ((_n = audienceKeywords.adult_female) == null ? void 0 : _n.test(text))
|
|
671
835
|
audiences.push("adult_female");
|
|
672
836
|
if (audiences.length === 0) audiences.push("generic");
|
|
673
837
|
}
|
|
@@ -713,7 +877,23 @@ Rules:
|
|
|
713
877
|
- Nested keys MUST be vertical: "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages".
|
|
714
878
|
- Values MUST be non-empty arrays of category strings.
|
|
715
879
|
`;
|
|
716
|
-
|
|
880
|
+
if (!apiKey) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
"Missing OpenRouter API key. Pass apiKey or set ShopClient options.openRouter.apiKey."
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const raw = await callOpenRouter({
|
|
886
|
+
model,
|
|
887
|
+
messages: [
|
|
888
|
+
{
|
|
889
|
+
role: "system",
|
|
890
|
+
content: "You analyze a Shopify store and return only valid JSON without markdown or code fences."
|
|
891
|
+
},
|
|
892
|
+
{ role: "user", content: prompt }
|
|
893
|
+
],
|
|
894
|
+
apiKey,
|
|
895
|
+
openRouter
|
|
896
|
+
});
|
|
717
897
|
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
718
898
|
const parsed = safeParseJson(cleaned);
|
|
719
899
|
if (!parsed.ok) {
|
|
@@ -852,6 +1032,10 @@ function pruneBreakdownForSignals(breakdown, text) {
|
|
|
852
1032
|
}
|
|
853
1033
|
|
|
854
1034
|
export {
|
|
1035
|
+
buildEnrichPrompt,
|
|
1036
|
+
buildClassifyPrompt,
|
|
1037
|
+
buildEnrichPromptForProduct,
|
|
1038
|
+
buildClassifyPromptForProduct,
|
|
855
1039
|
fetchAjaxProduct,
|
|
856
1040
|
fetchProductPage,
|
|
857
1041
|
extractMainSection,
|