shop-client 3.9.0 → 3.9.2
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 +65 -0
- package/dist/ai/enrich.d.ts +93 -0
- package/dist/ai/enrich.js +25 -0
- package/dist/checkout.js +6 -0
- package/dist/chunk-2MF53V33.js +196 -0
- package/dist/chunk-CN7L3BHG.js +147 -0
- package/dist/chunk-CXUCPK6X.js +460 -0
- package/dist/chunk-DJQEZNHG.js +233 -0
- package/dist/chunk-MOBWPEY4.js +420 -0
- package/dist/chunk-QUDGES3A.js +195 -0
- package/dist/chunk-RR6YTQWP.js +90 -0
- package/dist/chunk-VPPCOJC3.js +865 -0
- package/dist/collections.d.ts +2 -1
- package/dist/collections.js +8 -0
- package/dist/index.d.ts +7 -84
- package/dist/index.js +753 -0
- package/dist/products.d.ts +2 -1
- package/dist/products.js +8 -0
- package/dist/store.d.ts +53 -1
- package/dist/store.js +9 -0
- package/dist/{store-iQARl6J3.d.ts → types-luPg5O08.d.ts} +1 -208
- package/dist/utils/detect-country.d.ts +32 -0
- package/dist/utils/detect-country.js +6 -0
- package/dist/utils/func.d.ts +61 -0
- package/dist/utils/func.js +24 -0
- package/dist/utils/rate-limit.js +10 -0
- package/package.json +16 -3
- package/dist/checkout.mjs +0 -1
- package/dist/chunk-6GPWNCDO.mjs +0 -130
- package/dist/chunk-EJO5U4BT.mjs +0 -2
- package/dist/chunk-FFKWCNLU.mjs +0 -1
- package/dist/chunk-KYLPIEU3.mjs +0 -2
- package/dist/chunk-MB2INNNP.mjs +0 -1
- package/dist/chunk-MI7754VX.mjs +0 -2
- package/dist/chunk-SZQPMLZG.mjs +0 -1
- package/dist/collections.mjs +0 -1
- package/dist/enrich-OZHBXKK6.mjs +0 -1
- package/dist/index.mjs +0 -2
- package/dist/products.mjs +0 -1
- package/dist/store.mjs +0 -1
- package/dist/utils/rate-limit.mjs +0 -1
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import {
|
|
2
|
+
rateLimitedFetch
|
|
3
|
+
} from "./chunk-2MF53V33.js";
|
|
4
|
+
|
|
5
|
+
// src/ai/enrich.ts
|
|
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
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return key;
|
|
16
|
+
}
|
|
17
|
+
function normalizeDomainToBase(domain) {
|
|
18
|
+
if (domain.startsWith("http://") || domain.startsWith("https://")) {
|
|
19
|
+
try {
|
|
20
|
+
const u = new URL(domain);
|
|
21
|
+
return `${u.protocol}//${u.hostname}`;
|
|
22
|
+
} catch {
|
|
23
|
+
return domain;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return `https://${domain}`;
|
|
27
|
+
}
|
|
28
|
+
async function fetchAjaxProduct(domain, handle) {
|
|
29
|
+
const base = normalizeDomainToBase(domain);
|
|
30
|
+
const url = `${base}/products/${handle}.js`;
|
|
31
|
+
const res = await rateLimitedFetch(url, { rateLimitClass: "products:ajax" });
|
|
32
|
+
if (!res.ok) throw new Error(`Failed to fetch AJAX product: ${url}`);
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
async function fetchProductPage(domain, handle) {
|
|
37
|
+
const base = normalizeDomainToBase(domain);
|
|
38
|
+
const url = `${base}/products/${handle}`;
|
|
39
|
+
const res = await rateLimitedFetch(url, { rateLimitClass: "products:html" });
|
|
40
|
+
if (!res.ok) throw new Error(`Failed to fetch product page: ${url}`);
|
|
41
|
+
return res.text();
|
|
42
|
+
}
|
|
43
|
+
function extractMainSection(html) {
|
|
44
|
+
const startMatch = html.match(
|
|
45
|
+
/<section[^>]*id="shopify-section-template--.*?__main"[^>]*>/
|
|
46
|
+
);
|
|
47
|
+
if (!startMatch) return null;
|
|
48
|
+
const startIndex = html.indexOf(startMatch[0]);
|
|
49
|
+
if (startIndex === -1) return null;
|
|
50
|
+
const endIndex = html.indexOf("</section>", startIndex);
|
|
51
|
+
if (endIndex === -1) return null;
|
|
52
|
+
return html.substring(startIndex, endIndex + "</section>".length);
|
|
53
|
+
}
|
|
54
|
+
function htmlToMarkdown(html, options) {
|
|
55
|
+
var _a;
|
|
56
|
+
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
|
+
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);
|
|
96
|
+
return td.turndown(html);
|
|
97
|
+
}
|
|
98
|
+
async function mergeWithLLM(bodyInput, pageInput, options) {
|
|
99
|
+
var _a, _b;
|
|
100
|
+
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);
|
|
159
|
+
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
160
|
+
const cleaned = result.replace(/```json|```/g, "").trim();
|
|
161
|
+
const obj = safeParseJson(cleaned);
|
|
162
|
+
if (!obj.ok) {
|
|
163
|
+
throw new Error(`LLM returned invalid JSON: ${obj.error}`);
|
|
164
|
+
}
|
|
165
|
+
const schema = validateStructuredJson(obj.value);
|
|
166
|
+
if (!schema.ok) {
|
|
167
|
+
throw new Error(`LLM JSON schema invalid: ${schema.error}`);
|
|
168
|
+
}
|
|
169
|
+
const value = obj.value;
|
|
170
|
+
if (Array.isArray(value.images)) {
|
|
171
|
+
const filtered = value.images.filter((url) => {
|
|
172
|
+
if (typeof url !== "string") return false;
|
|
173
|
+
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(
|
|
184
|
+
(p) => u.includes(p)
|
|
185
|
+
);
|
|
186
|
+
return !looksLikeProductImage;
|
|
187
|
+
});
|
|
188
|
+
value.images = filtered.length > 0 ? filtered : null;
|
|
189
|
+
}
|
|
190
|
+
return JSON.stringify(value);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
function safeParseJson(input) {
|
|
195
|
+
try {
|
|
196
|
+
const v = JSON.parse(input);
|
|
197
|
+
return { ok: true, value: v };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return { ok: false, error: (err == null ? void 0 : err.message) || "Failed to parse JSON" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function validateStructuredJson(obj) {
|
|
203
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
204
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
205
|
+
}
|
|
206
|
+
const o = obj;
|
|
207
|
+
if ("title" in o && !(o.title === null || typeof o.title === "string")) {
|
|
208
|
+
return { ok: false, error: "title must be null or string" };
|
|
209
|
+
}
|
|
210
|
+
if ("description" in o && !(o.description === null || typeof o.description === "string")) {
|
|
211
|
+
return { ok: false, error: "description must be null or string" };
|
|
212
|
+
}
|
|
213
|
+
if ("fit" in o && !(o.fit === null || typeof o.fit === "string")) {
|
|
214
|
+
return { ok: false, error: "fit must be null or string" };
|
|
215
|
+
}
|
|
216
|
+
if ("returnPolicy" in o && !(o.returnPolicy === null || typeof o.returnPolicy === "string")) {
|
|
217
|
+
return { ok: false, error: "returnPolicy must be null or string" };
|
|
218
|
+
}
|
|
219
|
+
const validateStringArray = (arr, field) => {
|
|
220
|
+
if (!Array.isArray(arr))
|
|
221
|
+
return { ok: false, error: `${field} must be an array` };
|
|
222
|
+
for (const item of arr) {
|
|
223
|
+
if (typeof item !== "string")
|
|
224
|
+
return { ok: false, error: `${field} items must be strings` };
|
|
225
|
+
}
|
|
226
|
+
return { ok: true };
|
|
227
|
+
};
|
|
228
|
+
if ("materials" in o) {
|
|
229
|
+
const res = validateStringArray(o.materials, "materials");
|
|
230
|
+
if (!res.ok) return res;
|
|
231
|
+
}
|
|
232
|
+
if ("care" in o) {
|
|
233
|
+
const res = validateStringArray(o.care, "care");
|
|
234
|
+
if (!res.ok) return res;
|
|
235
|
+
}
|
|
236
|
+
if ("images" in o) {
|
|
237
|
+
if (!(o.images === null || Array.isArray(o.images))) {
|
|
238
|
+
return { ok: false, error: "images must be null or an array" };
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(o.images)) {
|
|
241
|
+
const res = validateStringArray(o.images, "images");
|
|
242
|
+
if (!res.ok) return res;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { ok: true };
|
|
246
|
+
}
|
|
247
|
+
async function callOpenRouter(model, prompt, apiKey) {
|
|
248
|
+
var _a, _b, _c;
|
|
249
|
+
if (process.env.OPENROUTER_OFFLINE === "1") {
|
|
250
|
+
return mockOpenRouterResponse(prompt);
|
|
251
|
+
}
|
|
252
|
+
const headers = {
|
|
253
|
+
"Content-Type": "application/json",
|
|
254
|
+
Authorization: `Bearer ${apiKey}`
|
|
255
|
+
};
|
|
256
|
+
const referer = process.env.OPENROUTER_SITE_URL || process.env.SITE_URL;
|
|
257
|
+
const title = process.env.OPENROUTER_APP_TITLE || "Shop Client";
|
|
258
|
+
if (referer) headers["HTTP-Referer"] = referer;
|
|
259
|
+
if (title) headers["X-Title"] = title;
|
|
260
|
+
const buildPayload = (m) => ({
|
|
261
|
+
model: m,
|
|
262
|
+
messages: [{ role: "user", content: prompt }],
|
|
263
|
+
temperature: 0.2
|
|
264
|
+
});
|
|
265
|
+
const base = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/$/, "");
|
|
266
|
+
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";
|
|
269
|
+
const modelsToTry = Array.from(
|
|
270
|
+
/* @__PURE__ */ new Set([model, ...fallbackEnv, defaultModel])
|
|
271
|
+
).filter(Boolean);
|
|
272
|
+
let lastErrorText = "";
|
|
273
|
+
for (const m of modelsToTry) {
|
|
274
|
+
for (const url of endpoints) {
|
|
275
|
+
try {
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
278
|
+
const response = await rateLimitedFetch(url, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers,
|
|
281
|
+
body: JSON.stringify(buildPayload(m)),
|
|
282
|
+
signal: controller.signal,
|
|
283
|
+
rateLimitClass: "ai:openrouter"
|
|
284
|
+
});
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
const text = await response.text();
|
|
288
|
+
lastErrorText = text || `${url}: HTTP ${response.status}`;
|
|
289
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
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;
|
|
294
|
+
if (typeof content === "string") return content;
|
|
295
|
+
lastErrorText = JSON.stringify(data);
|
|
296
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
297
|
+
} catch (err) {
|
|
298
|
+
lastErrorText = `${url}: ${(err == null ? void 0 : err.message) || String(err)}`;
|
|
299
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`OpenRouter request failed: ${lastErrorText}`);
|
|
304
|
+
}
|
|
305
|
+
function mockOpenRouterResponse(prompt) {
|
|
306
|
+
const p = prompt.toLowerCase();
|
|
307
|
+
if (p.includes("return only valid json") && p.includes('"audience":')) {
|
|
308
|
+
return JSON.stringify({
|
|
309
|
+
audience: "generic",
|
|
310
|
+
vertical: "clothing",
|
|
311
|
+
category: null,
|
|
312
|
+
subCategory: null
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (p.includes("return only valid json") && p.includes('"materials":')) {
|
|
316
|
+
return JSON.stringify({
|
|
317
|
+
title: null,
|
|
318
|
+
description: null,
|
|
319
|
+
materials: [],
|
|
320
|
+
care: [],
|
|
321
|
+
fit: null,
|
|
322
|
+
images: null,
|
|
323
|
+
returnPolicy: null
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return [
|
|
327
|
+
"## Description",
|
|
328
|
+
"Offline merge of product body and page.",
|
|
329
|
+
"",
|
|
330
|
+
"## Materials",
|
|
331
|
+
"- Not available"
|
|
332
|
+
].join("\n");
|
|
333
|
+
}
|
|
334
|
+
async function enrichProduct(domain, handle, options) {
|
|
335
|
+
var _a;
|
|
336
|
+
const ajaxProduct = await fetchAjaxProduct(domain, handle);
|
|
337
|
+
const bodyHtml = ajaxProduct.description || "";
|
|
338
|
+
const pageHtml = await fetchProductPage(domain, handle);
|
|
339
|
+
const extractedHtml = extractMainSection(pageHtml);
|
|
340
|
+
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 });
|
|
343
|
+
const mergedMarkdown = await mergeWithLLM(bodyInput, pageInput, {
|
|
344
|
+
apiKey: options == null ? void 0 : options.apiKey,
|
|
345
|
+
inputType,
|
|
346
|
+
model: options == null ? void 0 : options.model,
|
|
347
|
+
outputFormat: options == null ? void 0 : options.outputFormat
|
|
348
|
+
});
|
|
349
|
+
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
350
|
+
try {
|
|
351
|
+
const obj = JSON.parse(mergedMarkdown);
|
|
352
|
+
if (obj && Array.isArray(obj.images)) {
|
|
353
|
+
const productImageCandidates = [];
|
|
354
|
+
if (ajaxProduct.featured_image) {
|
|
355
|
+
productImageCandidates.push(String(ajaxProduct.featured_image));
|
|
356
|
+
}
|
|
357
|
+
if (Array.isArray(ajaxProduct.images)) {
|
|
358
|
+
for (const img of ajaxProduct.images) {
|
|
359
|
+
if (typeof img === "string" && img.length > 0) {
|
|
360
|
+
productImageCandidates.push(img);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (Array.isArray(ajaxProduct.media)) {
|
|
365
|
+
for (const m of ajaxProduct.media) {
|
|
366
|
+
if (m == null ? void 0 : m.src) productImageCandidates.push(String(m.src));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (Array.isArray(ajaxProduct.variants)) {
|
|
370
|
+
for (const v of ajaxProduct.variants) {
|
|
371
|
+
const fi = v == null ? void 0 : v.featured_image;
|
|
372
|
+
if (fi == null ? void 0 : fi.src) productImageCandidates.push(String(fi.src));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const productSet = new Set(
|
|
376
|
+
productImageCandidates.map((u) => String(u).toLowerCase())
|
|
377
|
+
);
|
|
378
|
+
const filtered = obj.images.filter((url) => {
|
|
379
|
+
if (typeof url !== "string") return false;
|
|
380
|
+
const u = url.toLowerCase();
|
|
381
|
+
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(
|
|
392
|
+
(p) => u.includes(p)
|
|
393
|
+
);
|
|
394
|
+
return !looksLikeProductImage;
|
|
395
|
+
});
|
|
396
|
+
obj.images = filtered.length > 0 ? filtered : null;
|
|
397
|
+
const sanitized = JSON.stringify(obj);
|
|
398
|
+
return {
|
|
399
|
+
bodyHtml,
|
|
400
|
+
pageHtml,
|
|
401
|
+
extractedMainHtml: extractedHtml || "",
|
|
402
|
+
mergedMarkdown: sanitized
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
bodyHtml,
|
|
410
|
+
pageHtml,
|
|
411
|
+
extractedMainHtml: extractedHtml || "",
|
|
412
|
+
mergedMarkdown
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
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);
|
|
458
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
459
|
+
const parsed = safeParseJson(cleaned);
|
|
460
|
+
if (!parsed.ok) {
|
|
461
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
462
|
+
}
|
|
463
|
+
const validated = validateClassification(parsed.value);
|
|
464
|
+
if (!validated.ok) {
|
|
465
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
466
|
+
}
|
|
467
|
+
return validated.value;
|
|
468
|
+
}
|
|
469
|
+
function validateClassification(obj) {
|
|
470
|
+
var _a, _b;
|
|
471
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
472
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
473
|
+
}
|
|
474
|
+
const o = obj;
|
|
475
|
+
const audienceValues = [
|
|
476
|
+
"adult_male",
|
|
477
|
+
"adult_female",
|
|
478
|
+
"kid_male",
|
|
479
|
+
"kid_female",
|
|
480
|
+
"generic"
|
|
481
|
+
];
|
|
482
|
+
if (typeof o.audience !== "string" || !audienceValues.includes(o.audience)) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
error: "audience must be one of: adult_male, adult_female, kid_male, kid_female, generic"
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const verticalValues = [
|
|
489
|
+
"clothing",
|
|
490
|
+
"beauty",
|
|
491
|
+
"accessories",
|
|
492
|
+
"home-decor",
|
|
493
|
+
"food-and-beverages"
|
|
494
|
+
];
|
|
495
|
+
if (typeof o.vertical !== "string" || !verticalValues.includes(o.vertical)) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
error: "vertical must be one of: clothing, beauty, accessories, home-decor, food-and-beverages"
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if ("category" in o && !(o.category === null || typeof o.category === "string")) {
|
|
502
|
+
return { ok: false, error: "category must be null or string" };
|
|
503
|
+
}
|
|
504
|
+
if ("subCategory" in o && !(o.subCategory === null || typeof o.subCategory === "string")) {
|
|
505
|
+
return { ok: false, error: "subCategory must be null or string" };
|
|
506
|
+
}
|
|
507
|
+
if (typeof o.subCategory === "string") {
|
|
508
|
+
const sc = o.subCategory.trim();
|
|
509
|
+
if (!/^[A-Za-z0-9-]+$/.test(sc)) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
error: "subCategory must be single word or hyphenated, no spaces"
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: true,
|
|
518
|
+
value: {
|
|
519
|
+
audience: o.audience,
|
|
520
|
+
vertical: o.vertical,
|
|
521
|
+
category: typeof o.category === "string" ? o.category : (_a = o.category) != null ? _a : null,
|
|
522
|
+
subCategory: typeof o.subCategory === "string" ? o.subCategory : (_b = o.subCategory) != null ? _b : null
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
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") {
|
|
532
|
+
const baseTags = Array.isArray(product.tags) ? product.tags.slice(0, 6) : [];
|
|
533
|
+
const titlePart = product.title.trim().slice(0, 50);
|
|
534
|
+
const vendorPart = (product.vendor || "").trim();
|
|
535
|
+
const pricePart = typeof product.price === "number" ? `$${product.price}` : "";
|
|
536
|
+
const metaTitle = vendorPart ? `${titlePart} | ${vendorPart}` : titlePart;
|
|
537
|
+
const metaDescription = `Discover ${product.title}. ${pricePart ? `Priced at ${pricePart}. ` : ""}Crafted to delight customers with quality and style.`.slice(
|
|
538
|
+
0,
|
|
539
|
+
160
|
|
540
|
+
);
|
|
541
|
+
const shortDescription = `${product.title} \u2014 ${vendorPart || "Premium"} quality, designed to impress.`;
|
|
542
|
+
const longDescription = product.description || `Introducing ${product.title}, combining performance and style for everyday use.`;
|
|
543
|
+
const marketingCopy = `Get ${product.title} today${pricePart ? ` for ${pricePart}` : ""}. Limited availability \u2014 don\u2019t miss out!`;
|
|
544
|
+
const res = {
|
|
545
|
+
metaTitle,
|
|
546
|
+
metaDescription,
|
|
547
|
+
shortDescription,
|
|
548
|
+
longDescription,
|
|
549
|
+
tags: baseTags.length ? baseTags : ["new", "featured", "popular"],
|
|
550
|
+
marketingCopy
|
|
551
|
+
};
|
|
552
|
+
const validated2 = validateSEOContent(res);
|
|
553
|
+
if (!validated2.ok)
|
|
554
|
+
throw new Error(`Offline SEO content invalid: ${validated2.error}`);
|
|
555
|
+
return validated2.value;
|
|
556
|
+
}
|
|
557
|
+
const prompt = `Generate SEO-optimized content for this product:
|
|
558
|
+
|
|
559
|
+
Title: ${product.title}
|
|
560
|
+
Description: ${product.description || "N/A"}
|
|
561
|
+
Vendor: ${product.vendor || "N/A"}
|
|
562
|
+
Price: ${typeof product.price === "number" ? `$${product.price}` : "N/A"}
|
|
563
|
+
Tags: ${Array.isArray(product.tags) && product.tags.length ? product.tags.join(", ") : "N/A"}
|
|
564
|
+
|
|
565
|
+
Create compelling, SEO-friendly content that will help this product rank well and convert customers.
|
|
566
|
+
|
|
567
|
+
Return ONLY valid JSON (no markdown, no code fences) with keys: {
|
|
568
|
+
"metaTitle": string,
|
|
569
|
+
"metaDescription": string,
|
|
570
|
+
"shortDescription": string,
|
|
571
|
+
"longDescription": string,
|
|
572
|
+
"tags": string[],
|
|
573
|
+
"marketingCopy": string
|
|
574
|
+
}`;
|
|
575
|
+
const raw = await callOpenRouter(model, prompt, apiKey);
|
|
576
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
577
|
+
const parsed = safeParseJson(cleaned);
|
|
578
|
+
if (!parsed.ok) {
|
|
579
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
580
|
+
}
|
|
581
|
+
const validated = validateSEOContent(parsed.value);
|
|
582
|
+
if (!validated.ok) {
|
|
583
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
584
|
+
}
|
|
585
|
+
return validated.value;
|
|
586
|
+
}
|
|
587
|
+
function validateSEOContent(obj) {
|
|
588
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
589
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
590
|
+
}
|
|
591
|
+
const o = obj;
|
|
592
|
+
const requiredStrings = [
|
|
593
|
+
"metaTitle",
|
|
594
|
+
"metaDescription",
|
|
595
|
+
"shortDescription",
|
|
596
|
+
"longDescription",
|
|
597
|
+
"marketingCopy"
|
|
598
|
+
];
|
|
599
|
+
for (const key of requiredStrings) {
|
|
600
|
+
if (typeof o[key] !== "string" || !o[key].trim()) {
|
|
601
|
+
return { ok: false, error: `${key} must be a non-empty string` };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (!Array.isArray(o.tags)) {
|
|
605
|
+
return { ok: false, error: "tags must be an array" };
|
|
606
|
+
}
|
|
607
|
+
for (const t of o.tags) {
|
|
608
|
+
if (typeof t !== "string")
|
|
609
|
+
return { ok: false, error: "tags items must be strings" };
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
value: {
|
|
614
|
+
metaTitle: String(o.metaTitle),
|
|
615
|
+
metaDescription: String(o.metaDescription),
|
|
616
|
+
shortDescription: String(o.shortDescription),
|
|
617
|
+
longDescription: String(o.longDescription),
|
|
618
|
+
tags: o.tags,
|
|
619
|
+
marketingCopy: String(o.marketingCopy)
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
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;
|
|
628
|
+
const productLines = Array.isArray(storeInfo.showcase.products) ? storeInfo.showcase.products.slice(0, 10).map((p) => {
|
|
629
|
+
if (typeof p === "string") return `- ${p}`;
|
|
630
|
+
const pt = typeof (p == null ? void 0 : p.productType) === "string" && p.productType.trim() ? p.productType : "N/A";
|
|
631
|
+
return `- ${String((p == null ? void 0 : p.title) || "N/A")}: ${pt}`;
|
|
632
|
+
}) : [];
|
|
633
|
+
const collectionLines = Array.isArray(storeInfo.showcase.collections) ? storeInfo.showcase.collections.slice(0, 5).map((c) => {
|
|
634
|
+
if (typeof c === "string") return `- ${c}`;
|
|
635
|
+
return `- ${String((c == null ? void 0 : c.title) || "N/A")}`;
|
|
636
|
+
}) : [];
|
|
637
|
+
const storeContent = `Store Title: ${storeInfo.title}
|
|
638
|
+
Store Description: ${(_b = storeInfo.description) != null ? _b : "N/A"}
|
|
639
|
+
|
|
640
|
+
Sample Products:
|
|
641
|
+
${productLines.join("\n") || "- N/A"}
|
|
642
|
+
|
|
643
|
+
Sample Collections:
|
|
644
|
+
${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();
|
|
648
|
+
const verticalKeywords = {
|
|
649
|
+
clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
|
|
650
|
+
beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
|
|
651
|
+
accessories: /(bag|belt|watch|wallet|accessor(y|ies)|sunglasses|jewell?ery)/,
|
|
652
|
+
"home-decor": /(sofa|chair|table|decor|home|candle|lamp|rug)/,
|
|
653
|
+
"food-and-beverages": /(snack|food|beverage|coffee|tea|chocolate|gourmet)/
|
|
654
|
+
};
|
|
655
|
+
const audienceKeywords = {
|
|
656
|
+
kid: /(\bkid\b|\bchild\b|\bchildren\b|\btoddler\b|\bboy\b|\bgirl\b)/,
|
|
657
|
+
kid_male: /\bboys\b|\bboy\b/,
|
|
658
|
+
kid_female: /\bgirls\b|\bgirl\b/,
|
|
659
|
+
adult_male: /\bmen\b|\bmale\b|\bman\b|\bmens\b/,
|
|
660
|
+
adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
|
|
661
|
+
};
|
|
662
|
+
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)))
|
|
667
|
+
audiences.push("generic");
|
|
668
|
+
} 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))
|
|
671
|
+
audiences.push("adult_female");
|
|
672
|
+
if (audiences.length === 0) audiences.push("generic");
|
|
673
|
+
}
|
|
674
|
+
const verticals = Object.entries(verticalKeywords).filter(([, rx]) => rx.test(text)).map(([k]) => k);
|
|
675
|
+
if (verticals.length === 0) verticals.push("accessories");
|
|
676
|
+
const allTitles = productLines.join(" ").toLowerCase();
|
|
677
|
+
const categoryMap = {
|
|
678
|
+
shirts: /(shirt|t[- ]?shirt|tee)/,
|
|
679
|
+
pants: /(pant|trouser|chino)/,
|
|
680
|
+
shorts: /shorts?/,
|
|
681
|
+
jeans: /jeans?/,
|
|
682
|
+
dresses: /dress/,
|
|
683
|
+
skincare: /(serum|moisturizer|skincare|cream)/,
|
|
684
|
+
accessories: /(belt|watch|wallet|bag)/,
|
|
685
|
+
footwear: /(sneaker|shoe|boot)/,
|
|
686
|
+
decor: /(candle|lamp|rug|sofa|chair|table)/,
|
|
687
|
+
beverages: /(coffee|tea|chocolate)/
|
|
688
|
+
};
|
|
689
|
+
const categories = Object.entries(categoryMap).filter(([, rx]) => rx.test(allTitles)).map(([name]) => name);
|
|
690
|
+
const defaultCategories = categories.length ? categories : ["general"];
|
|
691
|
+
const breakdown = {};
|
|
692
|
+
for (const aud of audiences) {
|
|
693
|
+
breakdown[aud] = breakdown[aud] || {};
|
|
694
|
+
for (const v of verticals) {
|
|
695
|
+
breakdown[aud][v] = Array.from(new Set(defaultCategories));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return pruneBreakdownForSignals(breakdown, textNormalized);
|
|
699
|
+
}
|
|
700
|
+
const prompt = `Analyze this store and build a multi-audience breakdown of verticals and categories.
|
|
701
|
+
Store Information:
|
|
702
|
+
${storeContent}
|
|
703
|
+
|
|
704
|
+
Return ONLY valid JSON (no markdown, no code fences) using this shape:
|
|
705
|
+
{
|
|
706
|
+
"adult_male": { "clothing": ["shirts", "pants"], "accessories": ["belts"] },
|
|
707
|
+
"adult_female": { "beauty": ["skincare"], "clothing": ["dresses"] },
|
|
708
|
+
"generic": { "clothing": ["t-shirts"] }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
Rules:
|
|
712
|
+
- Keys MUST be audience: "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic".
|
|
713
|
+
- Nested keys MUST be vertical: "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages".
|
|
714
|
+
- Values MUST be non-empty arrays of category strings.
|
|
715
|
+
`;
|
|
716
|
+
const raw = await callOpenRouter(model, prompt, apiKey);
|
|
717
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
718
|
+
const parsed = safeParseJson(cleaned);
|
|
719
|
+
if (!parsed.ok) {
|
|
720
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
721
|
+
}
|
|
722
|
+
const validated = validateStoreTypeBreakdown(parsed.value);
|
|
723
|
+
if (!validated.ok) {
|
|
724
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
725
|
+
}
|
|
726
|
+
return pruneBreakdownForSignals(validated.value, textNormalized);
|
|
727
|
+
}
|
|
728
|
+
function validateStoreTypeBreakdown(obj) {
|
|
729
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
error: "Top-level must be an object keyed by audience"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const audienceKeys = [
|
|
736
|
+
"adult_male",
|
|
737
|
+
"adult_female",
|
|
738
|
+
"kid_male",
|
|
739
|
+
"kid_female",
|
|
740
|
+
"generic"
|
|
741
|
+
];
|
|
742
|
+
const verticalKeys = [
|
|
743
|
+
"clothing",
|
|
744
|
+
"beauty",
|
|
745
|
+
"accessories",
|
|
746
|
+
"home-decor",
|
|
747
|
+
"food-and-beverages"
|
|
748
|
+
];
|
|
749
|
+
const o = obj;
|
|
750
|
+
const out = {};
|
|
751
|
+
const keys = Object.keys(o);
|
|
752
|
+
if (keys.length === 0) {
|
|
753
|
+
return { ok: false, error: "At least one audience key is required" };
|
|
754
|
+
}
|
|
755
|
+
for (const aKey of keys) {
|
|
756
|
+
if (!audienceKeys.includes(aKey)) {
|
|
757
|
+
return { ok: false, error: `Invalid audience key: ${aKey}` };
|
|
758
|
+
}
|
|
759
|
+
const vObj = o[aKey];
|
|
760
|
+
if (!vObj || typeof vObj !== "object" || Array.isArray(vObj)) {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
error: `Audience ${aKey} must map to an object of verticals`
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const vOut = {};
|
|
767
|
+
for (const vKey of Object.keys(vObj)) {
|
|
768
|
+
if (!verticalKeys.includes(vKey)) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
error: `Invalid vertical key ${vKey} for audience ${aKey}`
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const cats = vObj[vKey];
|
|
775
|
+
if (!Array.isArray(cats) || cats.length === 0 || !cats.every((c) => typeof c === "string" && c.trim())) {
|
|
776
|
+
return {
|
|
777
|
+
ok: false,
|
|
778
|
+
error: `Vertical ${vKey} for audience ${aKey} must be a non-empty array of strings`
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
vOut[vKey] = cats.map(
|
|
782
|
+
(c) => c.trim()
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
out[aKey] = vOut;
|
|
786
|
+
}
|
|
787
|
+
return { ok: true, value: out };
|
|
788
|
+
}
|
|
789
|
+
function pruneBreakdownForSignals(breakdown, text) {
|
|
790
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
791
|
+
const audienceKeywords = {
|
|
792
|
+
kid: /(\bkid\b|\bchild\b|\bchildren\b|\btoddler\b|\bboy\b|\bgirl\b)/,
|
|
793
|
+
kid_male: /\bboys\b|\bboy\b/,
|
|
794
|
+
kid_female: /\bgirls\b|\bgirl\b/,
|
|
795
|
+
adult_male: /\bmen\b|\bmale\b|\bman\b|\bmens\b/,
|
|
796
|
+
adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
|
|
797
|
+
};
|
|
798
|
+
const verticalKeywords = {
|
|
799
|
+
clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
|
|
800
|
+
beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
|
|
801
|
+
accessories: /(bag|belt|watch|wallet|accessor(y|ies)|sunglasses|jewell?ery)/,
|
|
802
|
+
// Tighten home-decor detection to avoid matching generic "Home" nav labels
|
|
803
|
+
// and other unrelated uses. Require specific furniture/decor terms or phrases.
|
|
804
|
+
"home-decor": /(sofa|chair|table|candle|lamp|rug|furniture|home[- ]?decor|homeware|housewares|living\s?room|dining\s?table|bed(?:room)?|wall\s?(art|mirror|clock))/,
|
|
805
|
+
"food-and-beverages": /(snack|food|beverage|coffee|tea|chocolate|gourmet)/
|
|
806
|
+
};
|
|
807
|
+
const signaledAudiences = /* @__PURE__ */ new Set();
|
|
808
|
+
if ((_a = audienceKeywords.kid) == null ? void 0 : _a.test(text)) {
|
|
809
|
+
if ((_b = audienceKeywords.kid_male) == null ? void 0 : _b.test(text))
|
|
810
|
+
signaledAudiences.add("kid_male");
|
|
811
|
+
if ((_c = audienceKeywords.kid_female) == null ? void 0 : _c.test(text))
|
|
812
|
+
signaledAudiences.add("kid_female");
|
|
813
|
+
if (!((_d = audienceKeywords.kid_male) == null ? void 0 : _d.test(text)) && !((_e = audienceKeywords.kid_female) == null ? void 0 : _e.test(text)))
|
|
814
|
+
signaledAudiences.add("generic");
|
|
815
|
+
} else {
|
|
816
|
+
if ((_f = audienceKeywords.adult_male) == null ? void 0 : _f.test(text))
|
|
817
|
+
signaledAudiences.add("adult_male");
|
|
818
|
+
if ((_g = audienceKeywords.adult_female) == null ? void 0 : _g.test(text))
|
|
819
|
+
signaledAudiences.add("adult_female");
|
|
820
|
+
if (signaledAudiences.size === 0) signaledAudiences.add("generic");
|
|
821
|
+
}
|
|
822
|
+
const signaledVerticals = new Set(
|
|
823
|
+
Object.entries(verticalKeywords).filter(([, rx]) => rx.test(text)).map(([k]) => k) || []
|
|
824
|
+
);
|
|
825
|
+
if (signaledVerticals.size === 0) signaledVerticals.add("accessories");
|
|
826
|
+
const pruned = {};
|
|
827
|
+
for (const [audience, verticals] of Object.entries(breakdown)) {
|
|
828
|
+
const a = audience;
|
|
829
|
+
if (!signaledAudiences.has(a)) continue;
|
|
830
|
+
const vOut = {};
|
|
831
|
+
for (const [vertical, categories] of Object.entries(verticals || {})) {
|
|
832
|
+
const v = vertical;
|
|
833
|
+
if (!signaledVerticals.has(v)) continue;
|
|
834
|
+
vOut[v] = categories;
|
|
835
|
+
}
|
|
836
|
+
if (Object.keys(vOut).length > 0) {
|
|
837
|
+
pruned[a] = vOut;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (Object.keys(pruned).length === 0) {
|
|
841
|
+
const vOut = {};
|
|
842
|
+
for (const v of Array.from(signaledVerticals)) {
|
|
843
|
+
vOut[v] = ["general"];
|
|
844
|
+
}
|
|
845
|
+
pruned.generic = vOut;
|
|
846
|
+
}
|
|
847
|
+
const adultHasData = pruned.adult_male && Object.keys(pruned.adult_male).length > 0 || pruned.adult_female && Object.keys(pruned.adult_female).length > 0;
|
|
848
|
+
if (adultHasData) {
|
|
849
|
+
delete pruned.generic;
|
|
850
|
+
}
|
|
851
|
+
return pruned;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export {
|
|
855
|
+
fetchAjaxProduct,
|
|
856
|
+
fetchProductPage,
|
|
857
|
+
extractMainSection,
|
|
858
|
+
htmlToMarkdown,
|
|
859
|
+
mergeWithLLM,
|
|
860
|
+
enrichProduct,
|
|
861
|
+
classifyProduct,
|
|
862
|
+
generateSEOContent,
|
|
863
|
+
determineStoreType,
|
|
864
|
+
pruneBreakdownForSignals
|
|
865
|
+
};
|