shop-client 3.8.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/LICENSE +21 -0
- package/README.md +912 -0
- package/dist/checkout.d.mts +31 -0
- package/dist/checkout.d.ts +31 -0
- package/dist/checkout.js +115 -0
- package/dist/checkout.js.map +1 -0
- package/dist/checkout.mjs +7 -0
- package/dist/checkout.mjs.map +1 -0
- package/dist/chunk-2KBOKOAD.mjs +177 -0
- package/dist/chunk-2KBOKOAD.mjs.map +1 -0
- package/dist/chunk-BWKBRM2Z.mjs +136 -0
- package/dist/chunk-BWKBRM2Z.mjs.map +1 -0
- package/dist/chunk-O4BPIIQ6.mjs +503 -0
- package/dist/chunk-O4BPIIQ6.mjs.map +1 -0
- package/dist/chunk-QCTICSBE.mjs +398 -0
- package/dist/chunk-QCTICSBE.mjs.map +1 -0
- package/dist/chunk-QL5OUZGP.mjs +91 -0
- package/dist/chunk-QL5OUZGP.mjs.map +1 -0
- package/dist/chunk-WTK5HUFI.mjs +1287 -0
- package/dist/chunk-WTK5HUFI.mjs.map +1 -0
- package/dist/collections.d.mts +64 -0
- package/dist/collections.d.ts +64 -0
- package/dist/collections.js +540 -0
- package/dist/collections.js.map +1 -0
- package/dist/collections.mjs +9 -0
- package/dist/collections.mjs.map +1 -0
- package/dist/index.d.mts +233 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +3241 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +702 -0
- package/dist/index.mjs.map +1 -0
- package/dist/products.d.mts +63 -0
- package/dist/products.d.ts +63 -0
- package/dist/products.js +1206 -0
- package/dist/products.js.map +1 -0
- package/dist/products.mjs +9 -0
- package/dist/products.mjs.map +1 -0
- package/dist/store-CJVUz2Yb.d.mts +608 -0
- package/dist/store-CJVUz2Yb.d.ts +608 -0
- package/dist/store.d.mts +1 -0
- package/dist/store.d.ts +1 -0
- package/dist/store.js +698 -0
- package/dist/store.js.map +1 -0
- package/dist/store.mjs +9 -0
- package/dist/store.mjs.map +1 -0
- package/dist/utils/rate-limit.d.mts +25 -0
- package/dist/utils/rate-limit.d.ts +25 -0
- package/dist/utils/rate-limit.js +203 -0
- package/dist/utils/rate-limit.js.map +1 -0
- package/dist/utils/rate-limit.mjs +11 -0
- package/dist/utils/rate-limit.mjs.map +1 -0
- package/package.json +116 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
ShopClient: () => ShopClient,
|
|
34
|
+
calculateDiscount: () => calculateDiscount,
|
|
35
|
+
classifyProduct: () => classifyProduct,
|
|
36
|
+
configureRateLimit: () => configureRateLimit,
|
|
37
|
+
detectShopifyCountry: () => detectShopifyCountry,
|
|
38
|
+
extractDomainWithoutSuffix: () => extractDomainWithoutSuffix,
|
|
39
|
+
genProductSlug: () => genProductSlug,
|
|
40
|
+
generateSEOContent: () => generateSEOContent,
|
|
41
|
+
generateStoreSlug: () => generateStoreSlug,
|
|
42
|
+
safeParseDate: () => safeParseDate,
|
|
43
|
+
sanitizeDomain: () => sanitizeDomain
|
|
44
|
+
});
|
|
45
|
+
module.exports = __toCommonJS(index_exports);
|
|
46
|
+
|
|
47
|
+
// src/ai/enrich.ts
|
|
48
|
+
var import_turndown = __toESM(require("turndown"));
|
|
49
|
+
var import_turndown_plugin_gfm = require("turndown-plugin-gfm");
|
|
50
|
+
|
|
51
|
+
// src/utils/rate-limit.ts
|
|
52
|
+
var RateLimiter = class {
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.queue = [];
|
|
55
|
+
this.inFlight = 0;
|
|
56
|
+
this.refillTimer = null;
|
|
57
|
+
this.options = options;
|
|
58
|
+
this.tokens = options.maxRequestsPerInterval;
|
|
59
|
+
}
|
|
60
|
+
startRefill() {
|
|
61
|
+
if (this.refillTimer) return;
|
|
62
|
+
this.refillTimer = setInterval(() => {
|
|
63
|
+
this.tokens = this.options.maxRequestsPerInterval;
|
|
64
|
+
this.tryRun();
|
|
65
|
+
}, this.options.intervalMs);
|
|
66
|
+
if (this.refillTimer && typeof this.refillTimer.unref === "function") {
|
|
67
|
+
this.refillTimer.unref();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
stopRefill() {
|
|
71
|
+
if (this.refillTimer) {
|
|
72
|
+
clearInterval(this.refillTimer);
|
|
73
|
+
this.refillTimer = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
ensureRefillStarted() {
|
|
77
|
+
if (!this.refillTimer) {
|
|
78
|
+
this.startRefill();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
configure(next) {
|
|
82
|
+
this.options = { ...this.options, ...next };
|
|
83
|
+
this.options.maxRequestsPerInterval = Math.max(
|
|
84
|
+
1,
|
|
85
|
+
this.options.maxRequestsPerInterval
|
|
86
|
+
);
|
|
87
|
+
this.options.intervalMs = Math.max(10, this.options.intervalMs);
|
|
88
|
+
this.options.maxConcurrency = Math.max(1, this.options.maxConcurrency);
|
|
89
|
+
}
|
|
90
|
+
schedule(fn) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
this.ensureRefillStarted();
|
|
93
|
+
this.queue.push({ fn, resolve, reject });
|
|
94
|
+
this.tryRun();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
tryRun() {
|
|
98
|
+
while (this.queue.length > 0 && this.inFlight < this.options.maxConcurrency && this.tokens > 0) {
|
|
99
|
+
const task = this.queue.shift();
|
|
100
|
+
this.tokens -= 1;
|
|
101
|
+
this.inFlight += 1;
|
|
102
|
+
Promise.resolve().then(task.fn).then((result) => task.resolve(result)).catch((err) => task.reject(err)).finally(() => {
|
|
103
|
+
this.inFlight -= 1;
|
|
104
|
+
setTimeout(() => this.tryRun(), 0);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var enabled = false;
|
|
110
|
+
var defaultOptions = {
|
|
111
|
+
maxRequestsPerInterval: 5,
|
|
112
|
+
// 5 requests
|
|
113
|
+
intervalMs: 1e3,
|
|
114
|
+
// per second
|
|
115
|
+
maxConcurrency: 5
|
|
116
|
+
// up to 5 in parallel
|
|
117
|
+
};
|
|
118
|
+
var limiter = new RateLimiter(defaultOptions);
|
|
119
|
+
var hostLimiters = /* @__PURE__ */ new Map();
|
|
120
|
+
var classLimiters = /* @__PURE__ */ new Map();
|
|
121
|
+
function getHost(input) {
|
|
122
|
+
try {
|
|
123
|
+
if (typeof input === "string") {
|
|
124
|
+
return new URL(input).host;
|
|
125
|
+
}
|
|
126
|
+
if (input instanceof URL) {
|
|
127
|
+
return input.host;
|
|
128
|
+
}
|
|
129
|
+
const url = input.url;
|
|
130
|
+
if (url) {
|
|
131
|
+
return new URL(url).host;
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
return void 0;
|
|
136
|
+
}
|
|
137
|
+
function getHostLimiter(host) {
|
|
138
|
+
if (!host) return void 0;
|
|
139
|
+
const exact = hostLimiters.get(host);
|
|
140
|
+
if (exact) return exact;
|
|
141
|
+
for (const [key, lim] of hostLimiters.entries()) {
|
|
142
|
+
if (key.startsWith("*.") && host.endsWith(key.slice(2))) {
|
|
143
|
+
return lim;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
function configureRateLimit(options) {
|
|
149
|
+
var _a, _b, _c, _d, _e, _f;
|
|
150
|
+
if (typeof options.enabled === "boolean") {
|
|
151
|
+
enabled = options.enabled;
|
|
152
|
+
}
|
|
153
|
+
const { perHost, perClass } = options;
|
|
154
|
+
const globalOpts = {};
|
|
155
|
+
if (typeof options.maxRequestsPerInterval === "number") {
|
|
156
|
+
globalOpts.maxRequestsPerInterval = options.maxRequestsPerInterval;
|
|
157
|
+
}
|
|
158
|
+
if (typeof options.intervalMs === "number") {
|
|
159
|
+
globalOpts.intervalMs = options.intervalMs;
|
|
160
|
+
}
|
|
161
|
+
if (typeof options.maxConcurrency === "number") {
|
|
162
|
+
globalOpts.maxConcurrency = options.maxConcurrency;
|
|
163
|
+
}
|
|
164
|
+
if (Object.keys(globalOpts).length) {
|
|
165
|
+
limiter.configure(globalOpts);
|
|
166
|
+
}
|
|
167
|
+
if (perHost) {
|
|
168
|
+
for (const host of Object.keys(perHost)) {
|
|
169
|
+
const opts = perHost[host];
|
|
170
|
+
const existing = hostLimiters.get(host);
|
|
171
|
+
if (existing) {
|
|
172
|
+
existing.configure(opts);
|
|
173
|
+
} else {
|
|
174
|
+
hostLimiters.set(
|
|
175
|
+
host,
|
|
176
|
+
new RateLimiter({
|
|
177
|
+
maxRequestsPerInterval: (_a = opts.maxRequestsPerInterval) != null ? _a : defaultOptions.maxRequestsPerInterval,
|
|
178
|
+
intervalMs: (_b = opts.intervalMs) != null ? _b : defaultOptions.intervalMs,
|
|
179
|
+
maxConcurrency: (_c = opts.maxConcurrency) != null ? _c : defaultOptions.maxConcurrency
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (perClass) {
|
|
186
|
+
for (const klass of Object.keys(perClass)) {
|
|
187
|
+
const opts = perClass[klass];
|
|
188
|
+
const existing = classLimiters.get(klass);
|
|
189
|
+
if (existing) {
|
|
190
|
+
existing.configure(opts);
|
|
191
|
+
} else {
|
|
192
|
+
classLimiters.set(
|
|
193
|
+
klass,
|
|
194
|
+
new RateLimiter({
|
|
195
|
+
maxRequestsPerInterval: (_d = opts.maxRequestsPerInterval) != null ? _d : defaultOptions.maxRequestsPerInterval,
|
|
196
|
+
intervalMs: (_e = opts.intervalMs) != null ? _e : defaultOptions.intervalMs,
|
|
197
|
+
maxConcurrency: (_f = opts.maxConcurrency) != null ? _f : defaultOptions.maxConcurrency
|
|
198
|
+
})
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function rateLimitedFetch(input, init) {
|
|
205
|
+
var _a;
|
|
206
|
+
if (!enabled) {
|
|
207
|
+
return fetch(input, init);
|
|
208
|
+
}
|
|
209
|
+
const klass = init == null ? void 0 : init.rateLimitClass;
|
|
210
|
+
const byClass = klass ? classLimiters.get(klass) : void 0;
|
|
211
|
+
const byHost = getHostLimiter(getHost(input));
|
|
212
|
+
const eff = (_a = byClass != null ? byClass : byHost) != null ? _a : limiter;
|
|
213
|
+
return eff.schedule(() => fetch(input, init));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/ai/enrich.ts
|
|
217
|
+
function ensureOpenRouter(apiKey) {
|
|
218
|
+
const key = apiKey || process.env.OPENROUTER_API_KEY;
|
|
219
|
+
if (!key) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return key;
|
|
225
|
+
}
|
|
226
|
+
function normalizeDomainToBase(domain) {
|
|
227
|
+
if (domain.startsWith("http://") || domain.startsWith("https://")) {
|
|
228
|
+
try {
|
|
229
|
+
const u = new URL(domain);
|
|
230
|
+
return `${u.protocol}//${u.hostname}`;
|
|
231
|
+
} catch {
|
|
232
|
+
return domain;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return `https://${domain}`;
|
|
236
|
+
}
|
|
237
|
+
async function fetchAjaxProduct(domain, handle) {
|
|
238
|
+
const base = normalizeDomainToBase(domain);
|
|
239
|
+
const url = `${base}/products/${handle}.js`;
|
|
240
|
+
const res = await rateLimitedFetch(url);
|
|
241
|
+
if (!res.ok) throw new Error(`Failed to fetch AJAX product: ${url}`);
|
|
242
|
+
const data = await res.json();
|
|
243
|
+
return data;
|
|
244
|
+
}
|
|
245
|
+
async function fetchProductPage(domain, handle) {
|
|
246
|
+
const base = normalizeDomainToBase(domain);
|
|
247
|
+
const url = `${base}/products/${handle}`;
|
|
248
|
+
const res = await rateLimitedFetch(url);
|
|
249
|
+
if (!res.ok) throw new Error(`Failed to fetch product page: ${url}`);
|
|
250
|
+
return res.text();
|
|
251
|
+
}
|
|
252
|
+
function extractMainSection(html) {
|
|
253
|
+
const startMatch = html.match(
|
|
254
|
+
/<section[^>]*id="shopify-section-template--.*?__main"[^>]*>/
|
|
255
|
+
);
|
|
256
|
+
if (!startMatch) return null;
|
|
257
|
+
const startIndex = html.indexOf(startMatch[0]);
|
|
258
|
+
if (startIndex === -1) return null;
|
|
259
|
+
const endIndex = html.indexOf("</section>", startIndex);
|
|
260
|
+
if (endIndex === -1) return null;
|
|
261
|
+
return html.substring(startIndex, endIndex + "</section>".length);
|
|
262
|
+
}
|
|
263
|
+
function htmlToMarkdown(html, options) {
|
|
264
|
+
var _a;
|
|
265
|
+
if (!html) return "";
|
|
266
|
+
const td = new import_turndown.default({
|
|
267
|
+
headingStyle: "atx",
|
|
268
|
+
codeBlockStyle: "fenced",
|
|
269
|
+
bulletListMarker: "-",
|
|
270
|
+
emDelimiter: "*",
|
|
271
|
+
strongDelimiter: "**",
|
|
272
|
+
linkStyle: "inlined"
|
|
273
|
+
});
|
|
274
|
+
const useGfm = (_a = options == null ? void 0 : options.useGfm) != null ? _a : true;
|
|
275
|
+
if (useGfm) {
|
|
276
|
+
td.use(import_turndown_plugin_gfm.gfm);
|
|
277
|
+
}
|
|
278
|
+
["script", "style", "nav", "footer"].forEach((tag) => {
|
|
279
|
+
td.remove((node) => {
|
|
280
|
+
var _a2;
|
|
281
|
+
return ((_a2 = node.nodeName) == null ? void 0 : _a2.toLowerCase()) === tag;
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
const removeByClass = (className) => td.remove((node) => {
|
|
285
|
+
const cls = typeof node.getAttribute === "function" ? node.getAttribute("class") || "" : "";
|
|
286
|
+
return cls.split(/\s+/).includes(className);
|
|
287
|
+
});
|
|
288
|
+
[
|
|
289
|
+
"product-form",
|
|
290
|
+
"shopify-payment-button",
|
|
291
|
+
"shopify-payment-buttons",
|
|
292
|
+
"product__actions",
|
|
293
|
+
"product__media-wrapper",
|
|
294
|
+
"loox-rating",
|
|
295
|
+
"jdgm-widget",
|
|
296
|
+
"stamped-reviews"
|
|
297
|
+
].forEach(removeByClass);
|
|
298
|
+
["button", "input", "select", "label"].forEach((tag) => {
|
|
299
|
+
td.remove((node) => {
|
|
300
|
+
var _a2;
|
|
301
|
+
return ((_a2 = node.nodeName) == null ? void 0 : _a2.toLowerCase()) === tag;
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
["quantity-selector", "product-atc-wrapper"].forEach(removeByClass);
|
|
305
|
+
return td.turndown(html);
|
|
306
|
+
}
|
|
307
|
+
async function mergeWithLLM(bodyInput, pageInput, options) {
|
|
308
|
+
var _a, _b;
|
|
309
|
+
const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
|
|
310
|
+
const bodyLabel = inputType === "html" ? "BODY HTML" : "BODY MARKDOWN";
|
|
311
|
+
const pageLabel = inputType === "html" ? "PAGE HTML" : "PAGE MARKDOWN";
|
|
312
|
+
const prompt = (options == null ? void 0 : options.outputFormat) === "json" ? `You are extracting structured buyer-useful information from Shopify product content.
|
|
313
|
+
|
|
314
|
+
Inputs:
|
|
315
|
+
1) ${bodyLabel}: ${inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
|
|
316
|
+
2) ${pageLabel}: ${inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
|
|
317
|
+
|
|
318
|
+
Return ONLY valid JSON (no markdown, no code fences) with this shape:
|
|
319
|
+
{
|
|
320
|
+
"title": null | string,
|
|
321
|
+
"description": null | string,
|
|
322
|
+
"materials": string[] | [],
|
|
323
|
+
"care": string[] | [],
|
|
324
|
+
"fit": null | string,
|
|
325
|
+
"images": null | string[],
|
|
326
|
+
"returnPolicy": null | string
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
Rules:
|
|
330
|
+
- Do not invent facts; if a field is unavailable, use null or []
|
|
331
|
+
- Prefer concise, factual statements
|
|
332
|
+
- Do NOT include product gallery/hero images in "images"; include only documentation images like size charts or measurement guides. If none, set "images": null.
|
|
333
|
+
|
|
334
|
+
${bodyLabel}:
|
|
335
|
+
${bodyInput}
|
|
336
|
+
|
|
337
|
+
${pageLabel}:
|
|
338
|
+
${pageInput}
|
|
339
|
+
` : `
|
|
340
|
+
You are enriching a Shopify product for a modern shopping-discovery app.
|
|
341
|
+
|
|
342
|
+
Inputs:
|
|
343
|
+
1) ${bodyLabel}: ${inputType === "html" ? "Raw Shopify product body_html" : "Cleaned version of Shopify product body_html"}
|
|
344
|
+
2) ${pageLabel}: ${inputType === "html" ? "Raw product page HTML (main section)" : "Extracted product page HTML converted to markdown"}
|
|
345
|
+
|
|
346
|
+
Your tasks:
|
|
347
|
+
- Merge them into a single clean markdown document
|
|
348
|
+
- Remove duplicate content
|
|
349
|
+
- Remove product images
|
|
350
|
+
- Remove UI text, buttons, menus, review widgets, theme junk
|
|
351
|
+
- Remove product options
|
|
352
|
+
- Keep only available buyer-useful info: features, materials, care, fit, size chart, return policy, size chart, care instructions
|
|
353
|
+
- Include image of size-chart if present
|
|
354
|
+
- Don't include statements like information not available.
|
|
355
|
+
- Maintain structured headings (## Description, ## Materials, etc.)
|
|
356
|
+
- Output ONLY markdown (no commentary)
|
|
357
|
+
|
|
358
|
+
${bodyLabel}:
|
|
359
|
+
${bodyInput}
|
|
360
|
+
|
|
361
|
+
${pageLabel}:
|
|
362
|
+
${pageInput}
|
|
363
|
+
`;
|
|
364
|
+
const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
|
|
365
|
+
const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
|
|
366
|
+
const model = (_b = options == null ? void 0 : options.model) != null ? _b : defaultModel;
|
|
367
|
+
const result = await callOpenRouter(model, prompt, apiKey);
|
|
368
|
+
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
369
|
+
const cleaned = result.replace(/```json|```/g, "").trim();
|
|
370
|
+
const obj = safeParseJson(cleaned);
|
|
371
|
+
if (!obj.ok) {
|
|
372
|
+
throw new Error(`LLM returned invalid JSON: ${obj.error}`);
|
|
373
|
+
}
|
|
374
|
+
const schema = validateStructuredJson(obj.value);
|
|
375
|
+
if (!schema.ok) {
|
|
376
|
+
throw new Error(`LLM JSON schema invalid: ${schema.error}`);
|
|
377
|
+
}
|
|
378
|
+
const value = obj.value;
|
|
379
|
+
if (Array.isArray(value.images)) {
|
|
380
|
+
const filtered = value.images.filter((url) => {
|
|
381
|
+
if (typeof url !== "string") return false;
|
|
382
|
+
const u = url.toLowerCase();
|
|
383
|
+
const productPatterns = [
|
|
384
|
+
"cdn.shopify.com",
|
|
385
|
+
"/products/",
|
|
386
|
+
"%2Fproducts%2F",
|
|
387
|
+
"_large",
|
|
388
|
+
"_grande",
|
|
389
|
+
"_1024x1024",
|
|
390
|
+
"_2048x"
|
|
391
|
+
];
|
|
392
|
+
const looksLikeProductImage = productPatterns.some(
|
|
393
|
+
(p) => u.includes(p)
|
|
394
|
+
);
|
|
395
|
+
return !looksLikeProductImage;
|
|
396
|
+
});
|
|
397
|
+
value.images = filtered.length > 0 ? filtered : null;
|
|
398
|
+
}
|
|
399
|
+
return JSON.stringify(value);
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
function safeParseJson(input) {
|
|
404
|
+
try {
|
|
405
|
+
const v = JSON.parse(input);
|
|
406
|
+
return { ok: true, value: v };
|
|
407
|
+
} catch (err) {
|
|
408
|
+
return { ok: false, error: (err == null ? void 0 : err.message) || "Failed to parse JSON" };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function validateStructuredJson(obj) {
|
|
412
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
413
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
414
|
+
}
|
|
415
|
+
const o = obj;
|
|
416
|
+
if ("title" in o && !(o.title === null || typeof o.title === "string")) {
|
|
417
|
+
return { ok: false, error: "title must be null or string" };
|
|
418
|
+
}
|
|
419
|
+
if ("description" in o && !(o.description === null || typeof o.description === "string")) {
|
|
420
|
+
return { ok: false, error: "description must be null or string" };
|
|
421
|
+
}
|
|
422
|
+
if ("fit" in o && !(o.fit === null || typeof o.fit === "string")) {
|
|
423
|
+
return { ok: false, error: "fit must be null or string" };
|
|
424
|
+
}
|
|
425
|
+
if ("returnPolicy" in o && !(o.returnPolicy === null || typeof o.returnPolicy === "string")) {
|
|
426
|
+
return { ok: false, error: "returnPolicy must be null or string" };
|
|
427
|
+
}
|
|
428
|
+
const validateStringArray = (arr, field) => {
|
|
429
|
+
if (!Array.isArray(arr))
|
|
430
|
+
return { ok: false, error: `${field} must be an array` };
|
|
431
|
+
for (const item of arr) {
|
|
432
|
+
if (typeof item !== "string")
|
|
433
|
+
return { ok: false, error: `${field} items must be strings` };
|
|
434
|
+
}
|
|
435
|
+
return { ok: true };
|
|
436
|
+
};
|
|
437
|
+
if ("materials" in o) {
|
|
438
|
+
const res = validateStringArray(o.materials, "materials");
|
|
439
|
+
if (!res.ok) return res;
|
|
440
|
+
}
|
|
441
|
+
if ("care" in o) {
|
|
442
|
+
const res = validateStringArray(o.care, "care");
|
|
443
|
+
if (!res.ok) return res;
|
|
444
|
+
}
|
|
445
|
+
if ("images" in o) {
|
|
446
|
+
if (!(o.images === null || Array.isArray(o.images))) {
|
|
447
|
+
return { ok: false, error: "images must be null or an array" };
|
|
448
|
+
}
|
|
449
|
+
if (Array.isArray(o.images)) {
|
|
450
|
+
const res = validateStringArray(o.images, "images");
|
|
451
|
+
if (!res.ok) return res;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { ok: true };
|
|
455
|
+
}
|
|
456
|
+
async function callOpenRouter(model, prompt, apiKey) {
|
|
457
|
+
var _a, _b, _c;
|
|
458
|
+
if (process.env.OPENROUTER_OFFLINE === "1") {
|
|
459
|
+
return mockOpenRouterResponse(prompt);
|
|
460
|
+
}
|
|
461
|
+
const headers = {
|
|
462
|
+
"Content-Type": "application/json",
|
|
463
|
+
Authorization: `Bearer ${apiKey}`
|
|
464
|
+
};
|
|
465
|
+
const referer = process.env.OPENROUTER_SITE_URL || process.env.SITE_URL;
|
|
466
|
+
const title = process.env.OPENROUTER_APP_TITLE || "Shop Search";
|
|
467
|
+
if (referer) headers["HTTP-Referer"] = referer;
|
|
468
|
+
if (title) headers["X-Title"] = title;
|
|
469
|
+
const buildPayload = (m) => ({
|
|
470
|
+
model: m,
|
|
471
|
+
messages: [{ role: "user", content: prompt }],
|
|
472
|
+
temperature: 0.2
|
|
473
|
+
});
|
|
474
|
+
const base = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/$/, "");
|
|
475
|
+
const endpoints = [`${base}/chat/completions`];
|
|
476
|
+
const fallbackEnv = (process.env.OPENROUTER_FALLBACK_MODELS || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
477
|
+
const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
|
|
478
|
+
const modelsToTry = Array.from(
|
|
479
|
+
/* @__PURE__ */ new Set([model, ...fallbackEnv, defaultModel])
|
|
480
|
+
).filter(Boolean);
|
|
481
|
+
let lastErrorText = "";
|
|
482
|
+
for (const m of modelsToTry) {
|
|
483
|
+
for (const url of endpoints) {
|
|
484
|
+
try {
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
487
|
+
const response = await rateLimitedFetch(url, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers,
|
|
490
|
+
body: JSON.stringify(buildPayload(m)),
|
|
491
|
+
signal: controller.signal
|
|
492
|
+
});
|
|
493
|
+
clearTimeout(timeout);
|
|
494
|
+
if (!response.ok) {
|
|
495
|
+
const text = await response.text();
|
|
496
|
+
lastErrorText = text || `${url}: HTTP ${response.status}`;
|
|
497
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const data = await response.json();
|
|
501
|
+
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;
|
|
502
|
+
if (typeof content === "string") return content;
|
|
503
|
+
lastErrorText = JSON.stringify(data);
|
|
504
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
505
|
+
} catch (err) {
|
|
506
|
+
lastErrorText = `${url}: ${(err == null ? void 0 : err.message) || String(err)}`;
|
|
507
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`OpenRouter request failed: ${lastErrorText}`);
|
|
512
|
+
}
|
|
513
|
+
function mockOpenRouterResponse(prompt) {
|
|
514
|
+
const p = prompt.toLowerCase();
|
|
515
|
+
if (p.includes("return only valid json") && p.includes('"audience":')) {
|
|
516
|
+
return JSON.stringify({
|
|
517
|
+
audience: "generic",
|
|
518
|
+
vertical: "clothing",
|
|
519
|
+
category: null,
|
|
520
|
+
subCategory: null
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (p.includes("return only valid json") && p.includes('"materials":')) {
|
|
524
|
+
return JSON.stringify({
|
|
525
|
+
title: null,
|
|
526
|
+
description: null,
|
|
527
|
+
materials: [],
|
|
528
|
+
care: [],
|
|
529
|
+
fit: null,
|
|
530
|
+
images: null,
|
|
531
|
+
returnPolicy: null
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return [
|
|
535
|
+
"## Description",
|
|
536
|
+
"Offline merge of product body and page.",
|
|
537
|
+
"",
|
|
538
|
+
"## Materials",
|
|
539
|
+
"- Not available"
|
|
540
|
+
].join("\n");
|
|
541
|
+
}
|
|
542
|
+
async function enrichProduct(domain, handle, options) {
|
|
543
|
+
var _a;
|
|
544
|
+
const ajaxProduct = await fetchAjaxProduct(domain, handle);
|
|
545
|
+
const bodyHtml = ajaxProduct.description || "";
|
|
546
|
+
const pageHtml = await fetchProductPage(domain, handle);
|
|
547
|
+
const extractedHtml = extractMainSection(pageHtml);
|
|
548
|
+
const inputType = (_a = options == null ? void 0 : options.inputType) != null ? _a : "markdown";
|
|
549
|
+
const bodyInput = inputType === "html" ? bodyHtml : htmlToMarkdown(bodyHtml, { useGfm: options == null ? void 0 : options.useGfm });
|
|
550
|
+
const pageInput = inputType === "html" ? extractedHtml || pageHtml : htmlToMarkdown(extractedHtml, { useGfm: options == null ? void 0 : options.useGfm });
|
|
551
|
+
const mergedMarkdown = await mergeWithLLM(bodyInput, pageInput, {
|
|
552
|
+
apiKey: options == null ? void 0 : options.apiKey,
|
|
553
|
+
inputType,
|
|
554
|
+
model: options == null ? void 0 : options.model,
|
|
555
|
+
outputFormat: options == null ? void 0 : options.outputFormat
|
|
556
|
+
});
|
|
557
|
+
if ((options == null ? void 0 : options.outputFormat) === "json") {
|
|
558
|
+
try {
|
|
559
|
+
const obj = JSON.parse(mergedMarkdown);
|
|
560
|
+
if (obj && Array.isArray(obj.images)) {
|
|
561
|
+
const productImageCandidates = [];
|
|
562
|
+
if (ajaxProduct.featured_image) {
|
|
563
|
+
productImageCandidates.push(String(ajaxProduct.featured_image));
|
|
564
|
+
}
|
|
565
|
+
if (Array.isArray(ajaxProduct.images)) {
|
|
566
|
+
for (const img of ajaxProduct.images) {
|
|
567
|
+
if (typeof img === "string" && img.length > 0) {
|
|
568
|
+
productImageCandidates.push(img);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (Array.isArray(ajaxProduct.media)) {
|
|
573
|
+
for (const m of ajaxProduct.media) {
|
|
574
|
+
if (m == null ? void 0 : m.src) productImageCandidates.push(String(m.src));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (Array.isArray(ajaxProduct.variants)) {
|
|
578
|
+
for (const v of ajaxProduct.variants) {
|
|
579
|
+
const fi = v == null ? void 0 : v.featured_image;
|
|
580
|
+
if (fi == null ? void 0 : fi.src) productImageCandidates.push(String(fi.src));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const productSet = new Set(
|
|
584
|
+
productImageCandidates.map((u) => String(u).toLowerCase())
|
|
585
|
+
);
|
|
586
|
+
const filtered = obj.images.filter((url) => {
|
|
587
|
+
if (typeof url !== "string") return false;
|
|
588
|
+
const u = url.toLowerCase();
|
|
589
|
+
if (productSet.has(u)) return false;
|
|
590
|
+
const productPatterns = [
|
|
591
|
+
"cdn.shopify.com",
|
|
592
|
+
"/products/",
|
|
593
|
+
"%2Fproducts%2F",
|
|
594
|
+
"_large",
|
|
595
|
+
"_grande",
|
|
596
|
+
"_1024x1024",
|
|
597
|
+
"_2048x"
|
|
598
|
+
];
|
|
599
|
+
const looksLikeProductImage = productPatterns.some(
|
|
600
|
+
(p) => u.includes(p)
|
|
601
|
+
);
|
|
602
|
+
return !looksLikeProductImage;
|
|
603
|
+
});
|
|
604
|
+
obj.images = filtered.length > 0 ? filtered : null;
|
|
605
|
+
const sanitized = JSON.stringify(obj);
|
|
606
|
+
return {
|
|
607
|
+
bodyHtml,
|
|
608
|
+
pageHtml,
|
|
609
|
+
extractedMainHtml: extractedHtml || "",
|
|
610
|
+
mergedMarkdown: sanitized
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
bodyHtml,
|
|
618
|
+
pageHtml,
|
|
619
|
+
extractedMainHtml: extractedHtml || "",
|
|
620
|
+
mergedMarkdown
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
async function classifyProduct(productContent, options) {
|
|
624
|
+
var _a;
|
|
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 prompt = `Classify the following product using a three-tiered hierarchy:
|
|
629
|
+
|
|
630
|
+
Product Content:
|
|
631
|
+
${productContent}
|
|
632
|
+
|
|
633
|
+
Classification Rules:
|
|
634
|
+
1. First determine the vertical (main product category)
|
|
635
|
+
2. Then determine the category (specific type within that vertical)
|
|
636
|
+
3. Finally determine the subCategory (sub-type within that category)
|
|
637
|
+
|
|
638
|
+
Vertical must be one of: clothing, beauty, accessories, home-decor, food-and-beverages
|
|
639
|
+
Audience must be one of: adult_male, adult_female, kid_male, kid_female, generic
|
|
640
|
+
|
|
641
|
+
Hierarchy Examples:
|
|
642
|
+
- Clothing \u2192 tops \u2192 t-shirts
|
|
643
|
+
- Clothing \u2192 footwear \u2192 sneakers
|
|
644
|
+
- Beauty \u2192 skincare \u2192 moisturizers
|
|
645
|
+
- Accessories \u2192 bags \u2192 backpacks
|
|
646
|
+
- Home-decor \u2192 furniture \u2192 chairs
|
|
647
|
+
- Food-and-beverages \u2192 snacks \u2192 chips
|
|
648
|
+
|
|
649
|
+
IMPORTANT CONSTRAINTS:
|
|
650
|
+
- Category must be relevant to the chosen vertical
|
|
651
|
+
- subCategory must be relevant to both vertical and category
|
|
652
|
+
- subCategory must be a single word or hyphenated words (no spaces)
|
|
653
|
+
- subCategory should NOT be material (e.g., "cotton", "leather") or color (e.g., "red", "blue")
|
|
654
|
+
- Focus on product type/function, not attributes
|
|
655
|
+
|
|
656
|
+
If you're not confident about category or sub-category, you can leave them optional.
|
|
657
|
+
|
|
658
|
+
Return ONLY valid JSON (no markdown, no code fences) with keys:
|
|
659
|
+
{
|
|
660
|
+
"audience": "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic",
|
|
661
|
+
"vertical": "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages",
|
|
662
|
+
"category": null | string,
|
|
663
|
+
"subCategory": null | string
|
|
664
|
+
}`;
|
|
665
|
+
const raw = await callOpenRouter(model, prompt, apiKey);
|
|
666
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
667
|
+
const parsed = safeParseJson(cleaned);
|
|
668
|
+
if (!parsed.ok) {
|
|
669
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
670
|
+
}
|
|
671
|
+
const validated = validateClassification(parsed.value);
|
|
672
|
+
if (!validated.ok) {
|
|
673
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
674
|
+
}
|
|
675
|
+
return validated.value;
|
|
676
|
+
}
|
|
677
|
+
function validateClassification(obj) {
|
|
678
|
+
var _a, _b;
|
|
679
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
680
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
681
|
+
}
|
|
682
|
+
const o = obj;
|
|
683
|
+
const audienceValues = [
|
|
684
|
+
"adult_male",
|
|
685
|
+
"adult_female",
|
|
686
|
+
"kid_male",
|
|
687
|
+
"kid_female",
|
|
688
|
+
"generic"
|
|
689
|
+
];
|
|
690
|
+
if (typeof o.audience !== "string" || !audienceValues.includes(o.audience)) {
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
error: "audience must be one of: adult_male, adult_female, kid_male, kid_female, generic"
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const verticalValues = [
|
|
697
|
+
"clothing",
|
|
698
|
+
"beauty",
|
|
699
|
+
"accessories",
|
|
700
|
+
"home-decor",
|
|
701
|
+
"food-and-beverages"
|
|
702
|
+
];
|
|
703
|
+
if (typeof o.vertical !== "string" || !verticalValues.includes(o.vertical)) {
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
error: "vertical must be one of: clothing, beauty, accessories, home-decor, food-and-beverages"
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
if ("category" in o && !(o.category === null || typeof o.category === "string")) {
|
|
710
|
+
return { ok: false, error: "category must be null or string" };
|
|
711
|
+
}
|
|
712
|
+
if ("subCategory" in o && !(o.subCategory === null || typeof o.subCategory === "string")) {
|
|
713
|
+
return { ok: false, error: "subCategory must be null or string" };
|
|
714
|
+
}
|
|
715
|
+
if (typeof o.subCategory === "string") {
|
|
716
|
+
const sc = o.subCategory.trim();
|
|
717
|
+
if (!/^[A-Za-z0-9-]+$/.test(sc)) {
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
error: "subCategory must be single word or hyphenated, no spaces"
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
ok: true,
|
|
726
|
+
value: {
|
|
727
|
+
audience: o.audience,
|
|
728
|
+
vertical: o.vertical,
|
|
729
|
+
category: typeof o.category === "string" ? o.category : (_a = o.category) != null ? _a : null,
|
|
730
|
+
subCategory: typeof o.subCategory === "string" ? o.subCategory : (_b = o.subCategory) != null ? _b : null
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
async function generateSEOContent(product, options) {
|
|
735
|
+
var _a;
|
|
736
|
+
const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
|
|
737
|
+
const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
|
|
738
|
+
const model = (_a = options == null ? void 0 : options.model) != null ? _a : defaultModel;
|
|
739
|
+
if (process.env.OPENROUTER_OFFLINE === "1") {
|
|
740
|
+
const baseTags = Array.isArray(product.tags) ? product.tags.slice(0, 6) : [];
|
|
741
|
+
const titlePart = product.title.trim().slice(0, 50);
|
|
742
|
+
const vendorPart = (product.vendor || "").trim();
|
|
743
|
+
const pricePart = typeof product.price === "number" ? `$${product.price}` : "";
|
|
744
|
+
const metaTitle = vendorPart ? `${titlePart} | ${vendorPart}` : titlePart;
|
|
745
|
+
const metaDescription = `Discover ${product.title}. ${pricePart ? `Priced at ${pricePart}. ` : ""}Crafted to delight customers with quality and style.`.slice(
|
|
746
|
+
0,
|
|
747
|
+
160
|
|
748
|
+
);
|
|
749
|
+
const shortDescription = `${product.title} \u2014 ${vendorPart || "Premium"} quality, designed to impress.`;
|
|
750
|
+
const longDescription = product.description || `Introducing ${product.title}, combining performance and style for everyday use.`;
|
|
751
|
+
const marketingCopy = `Get ${product.title} today${pricePart ? ` for ${pricePart}` : ""}. Limited availability \u2014 don\u2019t miss out!`;
|
|
752
|
+
const res = {
|
|
753
|
+
metaTitle,
|
|
754
|
+
metaDescription,
|
|
755
|
+
shortDescription,
|
|
756
|
+
longDescription,
|
|
757
|
+
tags: baseTags.length ? baseTags : ["new", "featured", "popular"],
|
|
758
|
+
marketingCopy
|
|
759
|
+
};
|
|
760
|
+
const validated2 = validateSEOContent(res);
|
|
761
|
+
if (!validated2.ok)
|
|
762
|
+
throw new Error(`Offline SEO content invalid: ${validated2.error}`);
|
|
763
|
+
return validated2.value;
|
|
764
|
+
}
|
|
765
|
+
const prompt = `Generate SEO-optimized content for this product:
|
|
766
|
+
|
|
767
|
+
Title: ${product.title}
|
|
768
|
+
Description: ${product.description || "N/A"}
|
|
769
|
+
Vendor: ${product.vendor || "N/A"}
|
|
770
|
+
Price: ${typeof product.price === "number" ? `$${product.price}` : "N/A"}
|
|
771
|
+
Tags: ${Array.isArray(product.tags) && product.tags.length ? product.tags.join(", ") : "N/A"}
|
|
772
|
+
|
|
773
|
+
Create compelling, SEO-friendly content that will help this product rank well and convert customers.
|
|
774
|
+
|
|
775
|
+
Return ONLY valid JSON (no markdown, no code fences) with keys: {
|
|
776
|
+
"metaTitle": string,
|
|
777
|
+
"metaDescription": string,
|
|
778
|
+
"shortDescription": string,
|
|
779
|
+
"longDescription": string,
|
|
780
|
+
"tags": string[],
|
|
781
|
+
"marketingCopy": string
|
|
782
|
+
}`;
|
|
783
|
+
const raw = await callOpenRouter(model, prompt, apiKey);
|
|
784
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
785
|
+
const parsed = safeParseJson(cleaned);
|
|
786
|
+
if (!parsed.ok) {
|
|
787
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
788
|
+
}
|
|
789
|
+
const validated = validateSEOContent(parsed.value);
|
|
790
|
+
if (!validated.ok) {
|
|
791
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
792
|
+
}
|
|
793
|
+
return validated.value;
|
|
794
|
+
}
|
|
795
|
+
function validateSEOContent(obj) {
|
|
796
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
797
|
+
return { ok: false, error: "Top-level must be a JSON object" };
|
|
798
|
+
}
|
|
799
|
+
const o = obj;
|
|
800
|
+
const requiredStrings = [
|
|
801
|
+
"metaTitle",
|
|
802
|
+
"metaDescription",
|
|
803
|
+
"shortDescription",
|
|
804
|
+
"longDescription",
|
|
805
|
+
"marketingCopy"
|
|
806
|
+
];
|
|
807
|
+
for (const key of requiredStrings) {
|
|
808
|
+
if (typeof o[key] !== "string" || !o[key].trim()) {
|
|
809
|
+
return { ok: false, error: `${key} must be a non-empty string` };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (!Array.isArray(o.tags)) {
|
|
813
|
+
return { ok: false, error: "tags must be an array" };
|
|
814
|
+
}
|
|
815
|
+
for (const t of o.tags) {
|
|
816
|
+
if (typeof t !== "string")
|
|
817
|
+
return { ok: false, error: "tags items must be strings" };
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
ok: true,
|
|
821
|
+
value: {
|
|
822
|
+
metaTitle: String(o.metaTitle),
|
|
823
|
+
metaDescription: String(o.metaDescription),
|
|
824
|
+
shortDescription: String(o.shortDescription),
|
|
825
|
+
longDescription: String(o.longDescription),
|
|
826
|
+
tags: o.tags,
|
|
827
|
+
marketingCopy: String(o.marketingCopy)
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async function determineStoreType(storeInfo, options) {
|
|
832
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
|
|
833
|
+
const apiKey = ensureOpenRouter(options == null ? void 0 : options.apiKey);
|
|
834
|
+
const defaultModel = process.env.OPENROUTER_MODEL || "openai/gpt-4o-mini";
|
|
835
|
+
const model = (_a = options == null ? void 0 : options.model) != null ? _a : defaultModel;
|
|
836
|
+
const productLines = Array.isArray(storeInfo.showcase.products) ? storeInfo.showcase.products.slice(0, 10).map((p) => {
|
|
837
|
+
if (typeof p === "string") return `- ${p}`;
|
|
838
|
+
const pt = typeof (p == null ? void 0 : p.productType) === "string" && p.productType.trim() ? p.productType : "N/A";
|
|
839
|
+
return `- ${String((p == null ? void 0 : p.title) || "N/A")}: ${pt}`;
|
|
840
|
+
}) : [];
|
|
841
|
+
const collectionLines = Array.isArray(storeInfo.showcase.collections) ? storeInfo.showcase.collections.slice(0, 5).map((c) => {
|
|
842
|
+
if (typeof c === "string") return `- ${c}`;
|
|
843
|
+
return `- ${String((c == null ? void 0 : c.title) || "N/A")}`;
|
|
844
|
+
}) : [];
|
|
845
|
+
const storeContent = `Store Title: ${storeInfo.title}
|
|
846
|
+
Store Description: ${(_b = storeInfo.description) != null ? _b : "N/A"}
|
|
847
|
+
|
|
848
|
+
Sample Products:
|
|
849
|
+
${productLines.join("\n") || "- N/A"}
|
|
850
|
+
|
|
851
|
+
Sample Collections:
|
|
852
|
+
${collectionLines.join("\n") || "- N/A"}`;
|
|
853
|
+
const textNormalized = `${storeInfo.title} ${(_c = storeInfo.description) != null ? _c : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
|
|
854
|
+
if (process.env.OPENROUTER_OFFLINE === "1") {
|
|
855
|
+
const text = `${storeInfo.title} ${(_d = storeInfo.description) != null ? _d : ""} ${productLines.join(" ")} ${collectionLines.join(" ")}`.toLowerCase();
|
|
856
|
+
const verticalKeywords = {
|
|
857
|
+
clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
|
|
858
|
+
beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
|
|
859
|
+
accessories: /(bag|belt|watch|wallet|accessor(y|ies)|sunglasses|jewell?ery)/,
|
|
860
|
+
"home-decor": /(sofa|chair|table|decor|home|candle|lamp|rug)/,
|
|
861
|
+
"food-and-beverages": /(snack|food|beverage|coffee|tea|chocolate|gourmet)/
|
|
862
|
+
};
|
|
863
|
+
const audienceKeywords = {
|
|
864
|
+
kid: /(\bkid\b|\bchild\b|\bchildren\b|\btoddler\b|\bboy\b|\bgirl\b)/,
|
|
865
|
+
kid_male: /\bboys\b|\bboy\b/,
|
|
866
|
+
kid_female: /\bgirls\b|\bgirl\b/,
|
|
867
|
+
adult_male: /\bmen\b|\bmale\b|\bman\b|\bmens\b/,
|
|
868
|
+
adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
|
|
869
|
+
};
|
|
870
|
+
const audiences = [];
|
|
871
|
+
if ((_e = audienceKeywords.kid) == null ? void 0 : _e.test(text)) {
|
|
872
|
+
if ((_f = audienceKeywords.kid_male) == null ? void 0 : _f.test(text)) audiences.push("kid_male");
|
|
873
|
+
if ((_g = audienceKeywords.kid_female) == null ? void 0 : _g.test(text)) audiences.push("kid_female");
|
|
874
|
+
if (!((_h = audienceKeywords.kid_male) == null ? void 0 : _h.test(text)) && !((_i = audienceKeywords.kid_female) == null ? void 0 : _i.test(text)))
|
|
875
|
+
audiences.push("generic");
|
|
876
|
+
} else {
|
|
877
|
+
if ((_j = audienceKeywords.adult_male) == null ? void 0 : _j.test(text)) audiences.push("adult_male");
|
|
878
|
+
if ((_k = audienceKeywords.adult_female) == null ? void 0 : _k.test(text))
|
|
879
|
+
audiences.push("adult_female");
|
|
880
|
+
if (audiences.length === 0) audiences.push("generic");
|
|
881
|
+
}
|
|
882
|
+
const verticals = Object.entries(verticalKeywords).filter(([, rx]) => rx.test(text)).map(([k]) => k);
|
|
883
|
+
if (verticals.length === 0) verticals.push("accessories");
|
|
884
|
+
const allTitles = productLines.join(" ").toLowerCase();
|
|
885
|
+
const categoryMap = {
|
|
886
|
+
shirts: /(shirt|t[- ]?shirt|tee)/,
|
|
887
|
+
pants: /(pant|trouser|chino)/,
|
|
888
|
+
shorts: /shorts?/,
|
|
889
|
+
jeans: /jeans?/,
|
|
890
|
+
dresses: /dress/,
|
|
891
|
+
skincare: /(serum|moisturizer|skincare|cream)/,
|
|
892
|
+
accessories: /(belt|watch|wallet|bag)/,
|
|
893
|
+
footwear: /(sneaker|shoe|boot)/,
|
|
894
|
+
decor: /(candle|lamp|rug|sofa|chair|table)/,
|
|
895
|
+
beverages: /(coffee|tea|chocolate)/
|
|
896
|
+
};
|
|
897
|
+
const categories = Object.entries(categoryMap).filter(([, rx]) => rx.test(allTitles)).map(([name]) => name);
|
|
898
|
+
const defaultCategories = categories.length ? categories : ["general"];
|
|
899
|
+
const breakdown = {};
|
|
900
|
+
for (const aud of audiences) {
|
|
901
|
+
breakdown[aud] = breakdown[aud] || {};
|
|
902
|
+
for (const v of verticals) {
|
|
903
|
+
breakdown[aud][v] = Array.from(new Set(defaultCategories));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return pruneBreakdownForSignals(breakdown, textNormalized);
|
|
907
|
+
}
|
|
908
|
+
const prompt = `Analyze this store and build a multi-audience breakdown of verticals and categories.
|
|
909
|
+
Store Information:
|
|
910
|
+
${storeContent}
|
|
911
|
+
|
|
912
|
+
Return ONLY valid JSON (no markdown, no code fences) using this shape:
|
|
913
|
+
{
|
|
914
|
+
"adult_male": { "clothing": ["shirts", "pants"], "accessories": ["belts"] },
|
|
915
|
+
"adult_female": { "beauty": ["skincare"], "clothing": ["dresses"] },
|
|
916
|
+
"generic": { "clothing": ["t-shirts"] }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
Rules:
|
|
920
|
+
- Keys MUST be audience: "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic".
|
|
921
|
+
- Nested keys MUST be vertical: "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages".
|
|
922
|
+
- Values MUST be non-empty arrays of category strings.
|
|
923
|
+
`;
|
|
924
|
+
const raw = await callOpenRouter(model, prompt, apiKey);
|
|
925
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
926
|
+
const parsed = safeParseJson(cleaned);
|
|
927
|
+
if (!parsed.ok) {
|
|
928
|
+
throw new Error(`LLM returned invalid JSON: ${parsed.error}`);
|
|
929
|
+
}
|
|
930
|
+
const validated = validateStoreTypeBreakdown(parsed.value);
|
|
931
|
+
if (!validated.ok) {
|
|
932
|
+
throw new Error(`LLM JSON schema invalid: ${validated.error}`);
|
|
933
|
+
}
|
|
934
|
+
return pruneBreakdownForSignals(validated.value, textNormalized);
|
|
935
|
+
}
|
|
936
|
+
function validateStoreTypeBreakdown(obj) {
|
|
937
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
error: "Top-level must be an object keyed by audience"
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const audienceKeys = [
|
|
944
|
+
"adult_male",
|
|
945
|
+
"adult_female",
|
|
946
|
+
"kid_male",
|
|
947
|
+
"kid_female",
|
|
948
|
+
"generic"
|
|
949
|
+
];
|
|
950
|
+
const verticalKeys = [
|
|
951
|
+
"clothing",
|
|
952
|
+
"beauty",
|
|
953
|
+
"accessories",
|
|
954
|
+
"home-decor",
|
|
955
|
+
"food-and-beverages"
|
|
956
|
+
];
|
|
957
|
+
const o = obj;
|
|
958
|
+
const out = {};
|
|
959
|
+
const keys = Object.keys(o);
|
|
960
|
+
if (keys.length === 0) {
|
|
961
|
+
return { ok: false, error: "At least one audience key is required" };
|
|
962
|
+
}
|
|
963
|
+
for (const aKey of keys) {
|
|
964
|
+
if (!audienceKeys.includes(aKey)) {
|
|
965
|
+
return { ok: false, error: `Invalid audience key: ${aKey}` };
|
|
966
|
+
}
|
|
967
|
+
const vObj = o[aKey];
|
|
968
|
+
if (!vObj || typeof vObj !== "object" || Array.isArray(vObj)) {
|
|
969
|
+
return {
|
|
970
|
+
ok: false,
|
|
971
|
+
error: `Audience ${aKey} must map to an object of verticals`
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
const vOut = {};
|
|
975
|
+
for (const vKey of Object.keys(vObj)) {
|
|
976
|
+
if (!verticalKeys.includes(vKey)) {
|
|
977
|
+
return {
|
|
978
|
+
ok: false,
|
|
979
|
+
error: `Invalid vertical key ${vKey} for audience ${aKey}`
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const cats = vObj[vKey];
|
|
983
|
+
if (!Array.isArray(cats) || cats.length === 0 || !cats.every((c) => typeof c === "string" && c.trim())) {
|
|
984
|
+
return {
|
|
985
|
+
ok: false,
|
|
986
|
+
error: `Vertical ${vKey} for audience ${aKey} must be a non-empty array of strings`
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
vOut[vKey] = cats.map(
|
|
990
|
+
(c) => c.trim()
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
out[aKey] = vOut;
|
|
994
|
+
}
|
|
995
|
+
return { ok: true, value: out };
|
|
996
|
+
}
|
|
997
|
+
function pruneBreakdownForSignals(breakdown, text) {
|
|
998
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
999
|
+
const audienceKeywords = {
|
|
1000
|
+
kid: /(\bkid\b|\bchild\b|\bchildren\b|\btoddler\b|\bboy\b|\bgirl\b)/,
|
|
1001
|
+
kid_male: /\bboys\b|\bboy\b/,
|
|
1002
|
+
kid_female: /\bgirls\b|\bgirl\b/,
|
|
1003
|
+
adult_male: /\bmen\b|\bmale\b|\bman\b|\bmens\b/,
|
|
1004
|
+
adult_female: /\bwomen\b|\bfemale\b|\bwoman\b|\bwomens\b/
|
|
1005
|
+
};
|
|
1006
|
+
const verticalKeywords = {
|
|
1007
|
+
clothing: /(dress|shirt|pant|jean|hoodie|tee|t[- ]?shirt|sneaker|apparel|clothing)/,
|
|
1008
|
+
beauty: /(skincare|moisturizer|serum|beauty|cosmetic|makeup)/,
|
|
1009
|
+
accessories: /(bag|belt|watch|wallet|accessor(y|ies)|sunglasses|jewell?ery)/,
|
|
1010
|
+
// Tighten home-decor detection to avoid matching generic "Home" nav labels
|
|
1011
|
+
// and other unrelated uses. Require specific furniture/decor terms or phrases.
|
|
1012
|
+
"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))/,
|
|
1013
|
+
"food-and-beverages": /(snack|food|beverage|coffee|tea|chocolate|gourmet)/
|
|
1014
|
+
};
|
|
1015
|
+
const signaledAudiences = /* @__PURE__ */ new Set();
|
|
1016
|
+
if ((_a = audienceKeywords.kid) == null ? void 0 : _a.test(text)) {
|
|
1017
|
+
if ((_b = audienceKeywords.kid_male) == null ? void 0 : _b.test(text))
|
|
1018
|
+
signaledAudiences.add("kid_male");
|
|
1019
|
+
if ((_c = audienceKeywords.kid_female) == null ? void 0 : _c.test(text))
|
|
1020
|
+
signaledAudiences.add("kid_female");
|
|
1021
|
+
if (!((_d = audienceKeywords.kid_male) == null ? void 0 : _d.test(text)) && !((_e = audienceKeywords.kid_female) == null ? void 0 : _e.test(text)))
|
|
1022
|
+
signaledAudiences.add("generic");
|
|
1023
|
+
} else {
|
|
1024
|
+
if ((_f = audienceKeywords.adult_male) == null ? void 0 : _f.test(text))
|
|
1025
|
+
signaledAudiences.add("adult_male");
|
|
1026
|
+
if ((_g = audienceKeywords.adult_female) == null ? void 0 : _g.test(text))
|
|
1027
|
+
signaledAudiences.add("adult_female");
|
|
1028
|
+
if (signaledAudiences.size === 0) signaledAudiences.add("generic");
|
|
1029
|
+
}
|
|
1030
|
+
const signaledVerticals = new Set(
|
|
1031
|
+
Object.entries(verticalKeywords).filter(([, rx]) => rx.test(text)).map(([k]) => k) || []
|
|
1032
|
+
);
|
|
1033
|
+
if (signaledVerticals.size === 0) signaledVerticals.add("accessories");
|
|
1034
|
+
const pruned = {};
|
|
1035
|
+
for (const [audience, verticals] of Object.entries(breakdown)) {
|
|
1036
|
+
const a = audience;
|
|
1037
|
+
if (!signaledAudiences.has(a)) continue;
|
|
1038
|
+
const vOut = {};
|
|
1039
|
+
for (const [vertical, categories] of Object.entries(verticals || {})) {
|
|
1040
|
+
const v = vertical;
|
|
1041
|
+
if (!signaledVerticals.has(v)) continue;
|
|
1042
|
+
vOut[v] = categories;
|
|
1043
|
+
}
|
|
1044
|
+
if (Object.keys(vOut).length > 0) {
|
|
1045
|
+
pruned[a] = vOut;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (Object.keys(pruned).length === 0) {
|
|
1049
|
+
const vOut = {};
|
|
1050
|
+
for (const v of Array.from(signaledVerticals)) {
|
|
1051
|
+
vOut[v] = ["general"];
|
|
1052
|
+
}
|
|
1053
|
+
pruned.generic = vOut;
|
|
1054
|
+
}
|
|
1055
|
+
const adultHasData = pruned.adult_male && Object.keys(pruned.adult_male).length > 0 || pruned.adult_female && Object.keys(pruned.adult_female).length > 0;
|
|
1056
|
+
if (adultHasData) {
|
|
1057
|
+
delete pruned.generic;
|
|
1058
|
+
}
|
|
1059
|
+
return pruned;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/ai/determine-store-type.ts
|
|
1063
|
+
async function determineStoreTypeForStore(args) {
|
|
1064
|
+
var _a, _b, _c;
|
|
1065
|
+
const info = await args.getInfo();
|
|
1066
|
+
const maxProducts = Math.max(0, Math.min(50, (_a = args.maxShowcaseProducts) != null ? _a : 10));
|
|
1067
|
+
const maxCollections = Math.max(
|
|
1068
|
+
0,
|
|
1069
|
+
Math.min(50, (_b = args.maxShowcaseCollections) != null ? _b : 10)
|
|
1070
|
+
);
|
|
1071
|
+
const take = (arr, n) => arr.slice(0, Math.max(0, n));
|
|
1072
|
+
const productsSample = Array.isArray(info.showcase.products) ? take(info.showcase.products, maxProducts) : [];
|
|
1073
|
+
const collectionsSample = Array.isArray(info.showcase.collections) ? take(info.showcase.collections, maxCollections) : [];
|
|
1074
|
+
const breakdown = await determineStoreType(
|
|
1075
|
+
{
|
|
1076
|
+
title: info.title || info.name,
|
|
1077
|
+
description: (_c = info.description) != null ? _c : null,
|
|
1078
|
+
showcase: {
|
|
1079
|
+
products: productsSample,
|
|
1080
|
+
collections: collectionsSample
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
{ apiKey: args.apiKey, model: args.model }
|
|
1084
|
+
);
|
|
1085
|
+
return breakdown;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/checkout.ts
|
|
1089
|
+
function createCheckoutOperations(baseUrl) {
|
|
1090
|
+
return {
|
|
1091
|
+
/**
|
|
1092
|
+
* Creates a Shopify checkout URL with pre-filled customer information and cart items.
|
|
1093
|
+
*
|
|
1094
|
+
* @param params - Checkout parameters
|
|
1095
|
+
* @param params.email - Customer's email address (must be valid email format)
|
|
1096
|
+
* @param params.items - Array of products to add to cart
|
|
1097
|
+
* @param params.items[].productVariantId - Shopify product variant ID
|
|
1098
|
+
* @param params.items[].quantity - Quantity as string (must be positive number)
|
|
1099
|
+
* @param params.address - Customer's shipping address
|
|
1100
|
+
* @param params.address.firstName - Customer's first name
|
|
1101
|
+
* @param params.address.lastName - Customer's last name
|
|
1102
|
+
* @param params.address.address1 - Street address
|
|
1103
|
+
* @param params.address.city - City name
|
|
1104
|
+
* @param params.address.zip - Postal/ZIP code
|
|
1105
|
+
* @param params.address.country - Country name
|
|
1106
|
+
* @param params.address.province - State/Province name
|
|
1107
|
+
* @param params.address.phone - Phone number
|
|
1108
|
+
*
|
|
1109
|
+
* @returns {string} Complete Shopify checkout URL with pre-filled information
|
|
1110
|
+
*
|
|
1111
|
+
* @throws {Error} When email is invalid, items array is empty, or required address fields are missing
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* ```typescript
|
|
1115
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
1116
|
+
* const checkoutUrl = await shop.checkout.create([
|
|
1117
|
+
* { variantId: '123', quantity: 2 },
|
|
1118
|
+
* { variantId: '456', quantity: 1 }
|
|
1119
|
+
* ]);
|
|
1120
|
+
* console.log(checkoutUrl);
|
|
1121
|
+
* ```
|
|
1122
|
+
*/
|
|
1123
|
+
createUrl: ({
|
|
1124
|
+
email,
|
|
1125
|
+
items,
|
|
1126
|
+
address
|
|
1127
|
+
}) => {
|
|
1128
|
+
if (!email || !email.includes("@")) {
|
|
1129
|
+
throw new Error("Invalid email address");
|
|
1130
|
+
}
|
|
1131
|
+
if (!items || items.length === 0) {
|
|
1132
|
+
throw new Error("Items array cannot be empty");
|
|
1133
|
+
}
|
|
1134
|
+
for (const item of items) {
|
|
1135
|
+
if (!item.productVariantId || !item.quantity) {
|
|
1136
|
+
throw new Error("Each item must have productVariantId and quantity");
|
|
1137
|
+
}
|
|
1138
|
+
const qty = Number.parseInt(item.quantity, 10);
|
|
1139
|
+
if (Number.isNaN(qty) || qty <= 0) {
|
|
1140
|
+
throw new Error("Quantity must be a positive number");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const requiredFields = [
|
|
1144
|
+
"firstName",
|
|
1145
|
+
"lastName",
|
|
1146
|
+
"address1",
|
|
1147
|
+
"city",
|
|
1148
|
+
"zip",
|
|
1149
|
+
"country"
|
|
1150
|
+
];
|
|
1151
|
+
for (const field of requiredFields) {
|
|
1152
|
+
if (!address[field]) {
|
|
1153
|
+
throw new Error(`Address field '${field}' is required`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const cartPath = items.map(
|
|
1157
|
+
(item) => `${encodeURIComponent(item.productVariantId)}:${encodeURIComponent(item.quantity)}`
|
|
1158
|
+
).join(",");
|
|
1159
|
+
const params = new URLSearchParams({
|
|
1160
|
+
"checkout[email]": email,
|
|
1161
|
+
"checkout[shipping_address][first_name]": address.firstName,
|
|
1162
|
+
"checkout[shipping_address][last_name]": address.lastName,
|
|
1163
|
+
"checkout[shipping_address][address1]": address.address1,
|
|
1164
|
+
"checkout[shipping_address][city]": address.city,
|
|
1165
|
+
"checkout[shipping_address][zip]": address.zip,
|
|
1166
|
+
"checkout[shipping_address][country]": address.country,
|
|
1167
|
+
"checkout[shipping_address][province]": address.province,
|
|
1168
|
+
"checkout[shipping_address][phone]": address.phone
|
|
1169
|
+
});
|
|
1170
|
+
return `${baseUrl}cart/${cartPath}?${params.toString()}`;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/client/get-info.ts
|
|
1176
|
+
var import_remeda = require("remeda");
|
|
1177
|
+
|
|
1178
|
+
// src/utils/detect-country.ts
|
|
1179
|
+
var COUNTRY_CODES = {
|
|
1180
|
+
"+1": "US",
|
|
1181
|
+
// United States (primary) / Canada also uses +1
|
|
1182
|
+
"+44": "GB",
|
|
1183
|
+
// United Kingdom
|
|
1184
|
+
"+61": "AU",
|
|
1185
|
+
// Australia
|
|
1186
|
+
"+65": "SG",
|
|
1187
|
+
// Singapore
|
|
1188
|
+
"+91": "IN",
|
|
1189
|
+
// India
|
|
1190
|
+
"+81": "JP",
|
|
1191
|
+
// Japan
|
|
1192
|
+
"+49": "DE",
|
|
1193
|
+
// Germany
|
|
1194
|
+
"+33": "FR",
|
|
1195
|
+
// France
|
|
1196
|
+
"+971": "AE",
|
|
1197
|
+
// United Arab Emirates
|
|
1198
|
+
"+39": "IT",
|
|
1199
|
+
// Italy
|
|
1200
|
+
"+34": "ES",
|
|
1201
|
+
// Spain
|
|
1202
|
+
"+82": "KR",
|
|
1203
|
+
// South Korea
|
|
1204
|
+
"+55": "BR",
|
|
1205
|
+
// Brazil
|
|
1206
|
+
"+62": "ID",
|
|
1207
|
+
// Indonesia
|
|
1208
|
+
"+92": "PK",
|
|
1209
|
+
// Pakistan
|
|
1210
|
+
"+7": "RU"
|
|
1211
|
+
// Russia
|
|
1212
|
+
};
|
|
1213
|
+
var CURRENCY_SYMBOLS = {
|
|
1214
|
+
Rs: "IN",
|
|
1215
|
+
// India
|
|
1216
|
+
"\u20B9": "IN",
|
|
1217
|
+
// India
|
|
1218
|
+
$: "US",
|
|
1219
|
+
// United States (primary, though many countries use $)
|
|
1220
|
+
CA$: "CA",
|
|
1221
|
+
// Canada
|
|
1222
|
+
A$: "AU",
|
|
1223
|
+
// Australia
|
|
1224
|
+
"\xA3": "GB",
|
|
1225
|
+
// United Kingdom
|
|
1226
|
+
"\u20AC": "EU",
|
|
1227
|
+
// European Union (not a country code, but commonly used)
|
|
1228
|
+
AED: "AE",
|
|
1229
|
+
// United Arab Emirates
|
|
1230
|
+
"\u20A9": "KR",
|
|
1231
|
+
// South Korea
|
|
1232
|
+
"\xA5": "JP"
|
|
1233
|
+
// Japan (primary, though China also uses ¥)
|
|
1234
|
+
};
|
|
1235
|
+
var CURRENCY_SYMBOL_TO_CODE = {
|
|
1236
|
+
Rs: "INR",
|
|
1237
|
+
"\u20B9": "INR",
|
|
1238
|
+
$: "USD",
|
|
1239
|
+
CA$: "CAD",
|
|
1240
|
+
A$: "AUD",
|
|
1241
|
+
"\xA3": "GBP",
|
|
1242
|
+
"\u20AC": "EUR",
|
|
1243
|
+
AED: "AED",
|
|
1244
|
+
"\u20A9": "KRW",
|
|
1245
|
+
"\xA5": "JPY"
|
|
1246
|
+
};
|
|
1247
|
+
var CURRENCY_CODE_TO_COUNTRY = {
|
|
1248
|
+
INR: "IN",
|
|
1249
|
+
USD: "US",
|
|
1250
|
+
CAD: "CA",
|
|
1251
|
+
AUD: "AU",
|
|
1252
|
+
GBP: "GB",
|
|
1253
|
+
EUR: "EU",
|
|
1254
|
+
AED: "AE",
|
|
1255
|
+
KRW: "KR",
|
|
1256
|
+
JPY: "JP"
|
|
1257
|
+
};
|
|
1258
|
+
function scoreCountry(countryScores, country, weight, reason) {
|
|
1259
|
+
if (!country) return;
|
|
1260
|
+
if (!countryScores[country])
|
|
1261
|
+
countryScores[country] = { score: 0, reasons: [] };
|
|
1262
|
+
countryScores[country].score += weight;
|
|
1263
|
+
countryScores[country].reasons.push(reason);
|
|
1264
|
+
}
|
|
1265
|
+
async function detectShopifyCountry(html) {
|
|
1266
|
+
var _a, _b;
|
|
1267
|
+
const countryScores = {};
|
|
1268
|
+
let detectedCurrencyCode;
|
|
1269
|
+
const shopifyFeaturesMatch = html.match(
|
|
1270
|
+
/<script[^>]+id=["']shopify-features["'][^>]*>([\s\S]*?)<\/script>/
|
|
1271
|
+
);
|
|
1272
|
+
if (shopifyFeaturesMatch) {
|
|
1273
|
+
try {
|
|
1274
|
+
const json = shopifyFeaturesMatch[1];
|
|
1275
|
+
if (!json) {
|
|
1276
|
+
} else {
|
|
1277
|
+
const data = JSON.parse(json);
|
|
1278
|
+
if (data.country)
|
|
1279
|
+
scoreCountry(
|
|
1280
|
+
countryScores,
|
|
1281
|
+
data.country,
|
|
1282
|
+
1,
|
|
1283
|
+
"shopify-features.country"
|
|
1284
|
+
);
|
|
1285
|
+
if ((_a = data.locale) == null ? void 0 : _a.includes("-")) {
|
|
1286
|
+
const [, localeCountry] = data.locale.split("-");
|
|
1287
|
+
if (localeCountry) {
|
|
1288
|
+
scoreCountry(
|
|
1289
|
+
countryScores,
|
|
1290
|
+
localeCountry.toUpperCase(),
|
|
1291
|
+
0.7,
|
|
1292
|
+
"shopify-features.locale"
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (data.moneyFormat) {
|
|
1297
|
+
for (const symbol in CURRENCY_SYMBOLS) {
|
|
1298
|
+
if (data.moneyFormat.includes(symbol)) {
|
|
1299
|
+
const iso = CURRENCY_SYMBOLS[symbol];
|
|
1300
|
+
if (typeof iso === "string") {
|
|
1301
|
+
scoreCountry(countryScores, iso, 0.6, "moneyFormat symbol");
|
|
1302
|
+
}
|
|
1303
|
+
const code = CURRENCY_SYMBOL_TO_CODE[symbol];
|
|
1304
|
+
if (!detectedCurrencyCode && typeof code === "string") {
|
|
1305
|
+
detectedCurrencyCode = code;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
} catch (_error) {
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
const currencyJsonMatch = html.match(/Shopify\.currency\s*=\s*(\{[^}]*\})/);
|
|
1315
|
+
if (currencyJsonMatch) {
|
|
1316
|
+
try {
|
|
1317
|
+
const raw = currencyJsonMatch[1];
|
|
1318
|
+
const obj = JSON.parse(raw || "{}");
|
|
1319
|
+
const activeCode = typeof (obj == null ? void 0 : obj.active) === "string" ? obj.active.toUpperCase() : void 0;
|
|
1320
|
+
const iso = activeCode ? CURRENCY_CODE_TO_COUNTRY[activeCode] : void 0;
|
|
1321
|
+
if (activeCode) {
|
|
1322
|
+
detectedCurrencyCode = activeCode;
|
|
1323
|
+
}
|
|
1324
|
+
if (typeof iso === "string") {
|
|
1325
|
+
scoreCountry(countryScores, iso, 0.8, "Shopify.currency.active");
|
|
1326
|
+
}
|
|
1327
|
+
} catch (_error) {
|
|
1328
|
+
}
|
|
1329
|
+
} else {
|
|
1330
|
+
const currencyActiveAssignMatch = html.match(
|
|
1331
|
+
/Shopify\.currency\.active\s*=\s*['"]([A-Za-z]{3})['"]/i
|
|
1332
|
+
);
|
|
1333
|
+
if (currencyActiveAssignMatch) {
|
|
1334
|
+
const captured = currencyActiveAssignMatch[1];
|
|
1335
|
+
const code = typeof captured === "string" ? captured.toUpperCase() : void 0;
|
|
1336
|
+
const iso = code ? CURRENCY_CODE_TO_COUNTRY[code] : void 0;
|
|
1337
|
+
if (code) {
|
|
1338
|
+
detectedCurrencyCode = code;
|
|
1339
|
+
}
|
|
1340
|
+
if (typeof iso === "string") {
|
|
1341
|
+
scoreCountry(countryScores, iso, 0.8, "Shopify.currency.active");
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const shopifyCountryMatch = html.match(
|
|
1346
|
+
/Shopify\.country\s*=\s*['"]([A-Za-z]{2})['"]/i
|
|
1347
|
+
);
|
|
1348
|
+
if (shopifyCountryMatch) {
|
|
1349
|
+
const captured = shopifyCountryMatch[1];
|
|
1350
|
+
const iso = typeof captured === "string" ? captured.toUpperCase() : void 0;
|
|
1351
|
+
if (typeof iso === "string") {
|
|
1352
|
+
scoreCountry(countryScores, iso, 1, "Shopify.country");
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
const phones = html.match(/\+\d{1,3}[\s\-()0-9]{5,}/g);
|
|
1356
|
+
if (phones) {
|
|
1357
|
+
for (const phone of phones) {
|
|
1358
|
+
const prefix = (_b = phone.match(/^\+\d{1,3}/)) == null ? void 0 : _b[0];
|
|
1359
|
+
if (prefix && COUNTRY_CODES[prefix])
|
|
1360
|
+
scoreCountry(
|
|
1361
|
+
countryScores,
|
|
1362
|
+
COUNTRY_CODES[prefix],
|
|
1363
|
+
0.8,
|
|
1364
|
+
`phone prefix ${prefix}`
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const jsonLdRegex = /<script[^>]+application\/ld\+json[^>]*>(.*?)<\/script>/g;
|
|
1369
|
+
let jsonLdMatch = jsonLdRegex.exec(html);
|
|
1370
|
+
while (jsonLdMatch !== null) {
|
|
1371
|
+
try {
|
|
1372
|
+
const json = jsonLdMatch[1];
|
|
1373
|
+
if (!json) {
|
|
1374
|
+
} else {
|
|
1375
|
+
const raw = JSON.parse(json);
|
|
1376
|
+
const collectAddressCountries = (node, results = []) => {
|
|
1377
|
+
if (Array.isArray(node)) {
|
|
1378
|
+
for (const item of node) collectAddressCountries(item, results);
|
|
1379
|
+
return results;
|
|
1380
|
+
}
|
|
1381
|
+
if (node && typeof node === "object") {
|
|
1382
|
+
const obj = node;
|
|
1383
|
+
const address = obj.address;
|
|
1384
|
+
if (address && typeof address === "object") {
|
|
1385
|
+
const country = address.addressCountry;
|
|
1386
|
+
if (typeof country === "string") results.push(country);
|
|
1387
|
+
}
|
|
1388
|
+
const graph = obj["@graph"];
|
|
1389
|
+
if (graph) collectAddressCountries(graph, results);
|
|
1390
|
+
}
|
|
1391
|
+
return results;
|
|
1392
|
+
};
|
|
1393
|
+
const countries = collectAddressCountries(raw);
|
|
1394
|
+
for (const country of countries) {
|
|
1395
|
+
scoreCountry(countryScores, country, 1, "JSON-LD addressCountry");
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
} catch (_error) {
|
|
1399
|
+
}
|
|
1400
|
+
jsonLdMatch = jsonLdRegex.exec(html);
|
|
1401
|
+
}
|
|
1402
|
+
const footerMatch = html.match(/<footer[^>]*>(.*?)<\/footer>/i);
|
|
1403
|
+
if (footerMatch) {
|
|
1404
|
+
const footerTextGroup = footerMatch[1];
|
|
1405
|
+
const footerText = footerTextGroup ? footerTextGroup.toLowerCase() : "";
|
|
1406
|
+
const countryNameToISO = {
|
|
1407
|
+
india: "IN",
|
|
1408
|
+
"united states": "US",
|
|
1409
|
+
canada: "CA",
|
|
1410
|
+
australia: "AU",
|
|
1411
|
+
"united kingdom": "GB",
|
|
1412
|
+
britain: "GB",
|
|
1413
|
+
uk: "GB",
|
|
1414
|
+
japan: "JP",
|
|
1415
|
+
"south korea": "KR",
|
|
1416
|
+
korea: "KR",
|
|
1417
|
+
germany: "DE",
|
|
1418
|
+
france: "FR",
|
|
1419
|
+
italy: "IT",
|
|
1420
|
+
spain: "ES",
|
|
1421
|
+
brazil: "BR",
|
|
1422
|
+
russia: "RU",
|
|
1423
|
+
singapore: "SG",
|
|
1424
|
+
indonesia: "ID",
|
|
1425
|
+
pakistan: "PK"
|
|
1426
|
+
};
|
|
1427
|
+
for (const [countryName, isoCode] of Object.entries(countryNameToISO)) {
|
|
1428
|
+
if (footerText.includes(countryName))
|
|
1429
|
+
scoreCountry(countryScores, isoCode, 0.4, "footer mention");
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const sorted = Object.entries(countryScores).sort(
|
|
1433
|
+
(a, b) => b[1].score - a[1].score
|
|
1434
|
+
);
|
|
1435
|
+
const best = sorted[0];
|
|
1436
|
+
return best ? {
|
|
1437
|
+
country: best[0],
|
|
1438
|
+
confidence: Math.min(1, best[1].score / 2),
|
|
1439
|
+
signals: best[1].reasons,
|
|
1440
|
+
currencyCode: detectedCurrencyCode
|
|
1441
|
+
} : {
|
|
1442
|
+
country: "Unknown",
|
|
1443
|
+
confidence: 0,
|
|
1444
|
+
signals: [],
|
|
1445
|
+
currencyCode: detectedCurrencyCode
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/utils/func.ts
|
|
1450
|
+
var import_tldts = require("tldts");
|
|
1451
|
+
function extractDomainWithoutSuffix(domain) {
|
|
1452
|
+
const parsedDomain = (0, import_tldts.parse)(domain);
|
|
1453
|
+
return parsedDomain.domainWithoutSuffix;
|
|
1454
|
+
}
|
|
1455
|
+
function generateStoreSlug(domain) {
|
|
1456
|
+
var _a;
|
|
1457
|
+
const input = new URL(domain);
|
|
1458
|
+
const parsedDomain = (0, import_tldts.parse)(input.href);
|
|
1459
|
+
const domainName = (_a = parsedDomain.domainWithoutSuffix) != null ? _a : input.hostname.split(".")[0];
|
|
1460
|
+
return (domainName || "").toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1461
|
+
}
|
|
1462
|
+
var genProductSlug = ({
|
|
1463
|
+
handle,
|
|
1464
|
+
storeDomain
|
|
1465
|
+
}) => {
|
|
1466
|
+
const storeSlug = generateStoreSlug(storeDomain);
|
|
1467
|
+
return `${handle}-by-${storeSlug}`;
|
|
1468
|
+
};
|
|
1469
|
+
var calculateDiscount = (price, compareAtPrice) => !compareAtPrice || compareAtPrice === 0 ? 0 : Math.max(
|
|
1470
|
+
0,
|
|
1471
|
+
Math.round(100 - price / compareAtPrice * 100)
|
|
1472
|
+
// Removed the decimal precision
|
|
1473
|
+
);
|
|
1474
|
+
function sanitizeDomain(input, opts) {
|
|
1475
|
+
var _a;
|
|
1476
|
+
if (typeof input !== "string") {
|
|
1477
|
+
throw new Error("sanitizeDomain: input must be a string");
|
|
1478
|
+
}
|
|
1479
|
+
let raw = input.trim();
|
|
1480
|
+
if (!raw) {
|
|
1481
|
+
throw new Error("sanitizeDomain: input cannot be empty");
|
|
1482
|
+
}
|
|
1483
|
+
const hasProtocol = /^[a-z]+:\/\//i.test(raw);
|
|
1484
|
+
if (!hasProtocol && !raw.startsWith("//")) {
|
|
1485
|
+
raw = `https://${raw}`;
|
|
1486
|
+
}
|
|
1487
|
+
const stripWWW = (_a = opts == null ? void 0 : opts.stripWWW) != null ? _a : true;
|
|
1488
|
+
try {
|
|
1489
|
+
let url;
|
|
1490
|
+
if (raw.startsWith("//")) {
|
|
1491
|
+
url = new URL(`https:${raw}`);
|
|
1492
|
+
} else if (raw.includes("://")) {
|
|
1493
|
+
url = new URL(raw);
|
|
1494
|
+
} else {
|
|
1495
|
+
url = new URL(`https://${raw}`);
|
|
1496
|
+
}
|
|
1497
|
+
let hostname = url.hostname.toLowerCase();
|
|
1498
|
+
const hadWWW = /^www\./i.test(url.hostname);
|
|
1499
|
+
if (stripWWW) hostname = hostname.replace(/^www\./, "");
|
|
1500
|
+
if (!hostname.includes(".")) {
|
|
1501
|
+
throw new Error("sanitizeDomain: invalid domain (missing suffix)");
|
|
1502
|
+
}
|
|
1503
|
+
const parsed = (0, import_tldts.parse)(hostname);
|
|
1504
|
+
if (!parsed.publicSuffix || parsed.isIcann === false) {
|
|
1505
|
+
throw new Error("sanitizeDomain: invalid domain (missing suffix)");
|
|
1506
|
+
}
|
|
1507
|
+
if (!stripWWW && hadWWW) {
|
|
1508
|
+
return `www.${parsed.domain || hostname}`;
|
|
1509
|
+
}
|
|
1510
|
+
return parsed.domain || hostname;
|
|
1511
|
+
} catch {
|
|
1512
|
+
let hostname = raw.toLowerCase();
|
|
1513
|
+
hostname = hostname.replace(/^[a-z]+:\/\//, "");
|
|
1514
|
+
hostname = hostname.replace(/^\/\//, "");
|
|
1515
|
+
hostname = hostname.replace(/[/:#?].*$/, "");
|
|
1516
|
+
const hadWWW = /^www\./i.test(hostname);
|
|
1517
|
+
if (stripWWW) hostname = hostname.replace(/^www\./, "");
|
|
1518
|
+
if (!hostname.includes(".")) {
|
|
1519
|
+
throw new Error("sanitizeDomain: invalid domain (missing suffix)");
|
|
1520
|
+
}
|
|
1521
|
+
const parsed = (0, import_tldts.parse)(hostname);
|
|
1522
|
+
if (!parsed.publicSuffix || parsed.isIcann === false) {
|
|
1523
|
+
throw new Error("sanitizeDomain: invalid domain (missing suffix)");
|
|
1524
|
+
}
|
|
1525
|
+
if (!stripWWW && hadWWW) {
|
|
1526
|
+
return `www.${parsed.domain || hostname}`;
|
|
1527
|
+
}
|
|
1528
|
+
return parsed.domain || hostname;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
function safeParseDate(input) {
|
|
1532
|
+
if (!input || typeof input !== "string") return void 0;
|
|
1533
|
+
const d = new Date(input);
|
|
1534
|
+
return Number.isNaN(d.getTime()) ? void 0 : d;
|
|
1535
|
+
}
|
|
1536
|
+
function normalizeKey(input) {
|
|
1537
|
+
return input.toLowerCase().replace(/\s+/g, "_");
|
|
1538
|
+
}
|
|
1539
|
+
function buildVariantOptionsMap(optionNames, variants) {
|
|
1540
|
+
const keys = optionNames.map(normalizeKey);
|
|
1541
|
+
const map = {};
|
|
1542
|
+
for (const v of variants) {
|
|
1543
|
+
const parts = [];
|
|
1544
|
+
if (keys[0] && v.option1)
|
|
1545
|
+
parts.push(`${keys[0]}#${normalizeKey(v.option1)}`);
|
|
1546
|
+
if (keys[1] && v.option2)
|
|
1547
|
+
parts.push(`${keys[1]}#${normalizeKey(v.option2)}`);
|
|
1548
|
+
if (keys[2] && v.option3)
|
|
1549
|
+
parts.push(`${keys[2]}#${normalizeKey(v.option3)}`);
|
|
1550
|
+
if (parts.length > 0) {
|
|
1551
|
+
if (parts.length > 1) parts.sort();
|
|
1552
|
+
const key = parts.join("##");
|
|
1553
|
+
const id = v.id.toString();
|
|
1554
|
+
if (map[key] === void 0) {
|
|
1555
|
+
map[key] = id;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return map;
|
|
1560
|
+
}
|
|
1561
|
+
function formatPrice(amountInCents, currency) {
|
|
1562
|
+
try {
|
|
1563
|
+
return new Intl.NumberFormat(void 0, {
|
|
1564
|
+
style: "currency",
|
|
1565
|
+
currency
|
|
1566
|
+
}).format((amountInCents || 0) / 100);
|
|
1567
|
+
} catch {
|
|
1568
|
+
const val = (amountInCents || 0) / 100;
|
|
1569
|
+
return `${val} ${currency}`;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/client/get-info.ts
|
|
1574
|
+
async function getInfoForStore(args) {
|
|
1575
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
|
|
1576
|
+
const {
|
|
1577
|
+
baseUrl,
|
|
1578
|
+
storeDomain,
|
|
1579
|
+
validateProductExists,
|
|
1580
|
+
validateCollectionExists,
|
|
1581
|
+
validateLinksInBatches
|
|
1582
|
+
} = args;
|
|
1583
|
+
const response = await rateLimitedFetch(baseUrl);
|
|
1584
|
+
if (!response.ok) {
|
|
1585
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1586
|
+
}
|
|
1587
|
+
const html = await response.text();
|
|
1588
|
+
const getMetaTag = (name2) => {
|
|
1589
|
+
const regex = new RegExp(
|
|
1590
|
+
`<meta[^>]*name=["']${name2}["'][^>]*content=["'](.*?)["']`
|
|
1591
|
+
);
|
|
1592
|
+
const match = html.match(regex);
|
|
1593
|
+
return match ? match[1] : null;
|
|
1594
|
+
};
|
|
1595
|
+
const getPropertyMetaTag = (property) => {
|
|
1596
|
+
const regex = new RegExp(
|
|
1597
|
+
`<meta[^>]*property=["']${property}["'][^>]*content=["'](.*?)["']`
|
|
1598
|
+
);
|
|
1599
|
+
const match = html.match(regex);
|
|
1600
|
+
return match ? match[1] : null;
|
|
1601
|
+
};
|
|
1602
|
+
const name = (_a = getMetaTag("og:site_name")) != null ? _a : extractDomainWithoutSuffix(baseUrl);
|
|
1603
|
+
const title = (_b = getMetaTag("og:title")) != null ? _b : getMetaTag("twitter:title");
|
|
1604
|
+
const description = getMetaTag("description") || getPropertyMetaTag("og:description");
|
|
1605
|
+
const shopifyWalletId = (_c = getMetaTag("shopify-digital-wallet")) == null ? void 0 : _c.split("/")[1];
|
|
1606
|
+
const myShopifySubdomainMatch = html.match(/['"](.*?\.myshopify\.com)['"]/);
|
|
1607
|
+
const myShopifySubdomain = myShopifySubdomainMatch ? myShopifySubdomainMatch[1] : null;
|
|
1608
|
+
let logoUrl = getPropertyMetaTag("og:image") || getPropertyMetaTag("og:image:secure_url");
|
|
1609
|
+
if (!logoUrl) {
|
|
1610
|
+
const logoMatch = html.match(
|
|
1611
|
+
/<img[^>]+src=["']([^"']+\/cdn\/shop\/[^"']+)["']/
|
|
1612
|
+
);
|
|
1613
|
+
const matchedUrl = logoMatch == null ? void 0 : logoMatch[1];
|
|
1614
|
+
logoUrl = matchedUrl ? matchedUrl.replace("http://", "https://") : null;
|
|
1615
|
+
} else {
|
|
1616
|
+
logoUrl = logoUrl.replace("http://", "https://");
|
|
1617
|
+
}
|
|
1618
|
+
const socialLinks = {};
|
|
1619
|
+
const socialRegex = /<a[^>]+href=["']([^"']*(?:facebook|twitter|instagram|pinterest|youtube|linkedin|tiktok|vimeo)\.com[^"']*)["']/g;
|
|
1620
|
+
for (const match of html.matchAll(socialRegex)) {
|
|
1621
|
+
const str = match[1];
|
|
1622
|
+
if (!str) continue;
|
|
1623
|
+
let href = str;
|
|
1624
|
+
try {
|
|
1625
|
+
if (href.startsWith("//")) {
|
|
1626
|
+
href = `https:${href}`;
|
|
1627
|
+
} else if (href.startsWith("/")) {
|
|
1628
|
+
href = new URL(href, baseUrl).toString();
|
|
1629
|
+
}
|
|
1630
|
+
const parsed = new URL(href);
|
|
1631
|
+
const domain = parsed.hostname.replace("www.", "").split(".")[0];
|
|
1632
|
+
if (domain) {
|
|
1633
|
+
socialLinks[domain] = parsed.toString();
|
|
1634
|
+
}
|
|
1635
|
+
} catch {
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const contactLinks = {
|
|
1639
|
+
tel: null,
|
|
1640
|
+
email: null,
|
|
1641
|
+
contactPage: null
|
|
1642
|
+
};
|
|
1643
|
+
for (const match of html.matchAll(/href=["']tel:([^"']+)["']/g)) {
|
|
1644
|
+
contactLinks.tel = ((_d = match == null ? void 0 : match[1]) == null ? void 0 : _d.trim()) || null;
|
|
1645
|
+
}
|
|
1646
|
+
for (const match of html.matchAll(/href=["']mailto:([^"']+)["']/g)) {
|
|
1647
|
+
contactLinks.email = ((_e = match == null ? void 0 : match[1]) == null ? void 0 : _e.trim()) || null;
|
|
1648
|
+
}
|
|
1649
|
+
for (const match of html.matchAll(
|
|
1650
|
+
/href=["']([^"']*(?:\/contact|\/pages\/contact)[^"']*)["']/g
|
|
1651
|
+
)) {
|
|
1652
|
+
contactLinks.contactPage = (match == null ? void 0 : match[1]) || null;
|
|
1653
|
+
}
|
|
1654
|
+
const extractedProductLinks = ((_g = (_f = html.match(/href=["']([^"']*\/products\/[^"']+)["']/g)) == null ? void 0 : _f.map(
|
|
1655
|
+
(match) => {
|
|
1656
|
+
var _a2, _b2;
|
|
1657
|
+
return (_b2 = (_a2 = match == null ? void 0 : match.split("href=")[1]) == null ? void 0 : _a2.replace(/["']/g, "")) == null ? void 0 : _b2.split("/").at(-1);
|
|
1658
|
+
}
|
|
1659
|
+
)) == null ? void 0 : _g.filter(Boolean)) || [];
|
|
1660
|
+
const extractedCollectionLinks = ((_i = (_h = html.match(/href=["']([^"']*\/collections\/[^"']+)["']/g)) == null ? void 0 : _h.map(
|
|
1661
|
+
(match) => {
|
|
1662
|
+
var _a2, _b2;
|
|
1663
|
+
return (_b2 = (_a2 = match == null ? void 0 : match.split("href=")[1]) == null ? void 0 : _a2.replace(/["']/g, "")) == null ? void 0 : _b2.split("/").at(-1);
|
|
1664
|
+
}
|
|
1665
|
+
)) == null ? void 0 : _i.filter(Boolean)) || [];
|
|
1666
|
+
const headerLinks = (_k = (_j = html.match(
|
|
1667
|
+
/<(header|nav|div|section)\b[^>]*\b(?:id|class)=["'][^"']*(?=.*shopify-section)(?=.*\b(header|navigation|nav|menu)\b)[^"']*["'][^>]*>[\s\S]*?<\/\1>/gi
|
|
1668
|
+
)) == null ? void 0 : _j.flatMap((header) => {
|
|
1669
|
+
var _a2, _b2;
|
|
1670
|
+
const links = (_a2 = header.match(/href=["']([^"']+)["']/g)) == null ? void 0 : _a2.filter(
|
|
1671
|
+
(link) => link.includes("/products/") || link.includes("/collections/") || link.includes("/pages/")
|
|
1672
|
+
);
|
|
1673
|
+
return (_b2 = links == null ? void 0 : links.map((link) => {
|
|
1674
|
+
var _a3;
|
|
1675
|
+
const href = (_a3 = link.match(/href=["']([^"']+)["']/)) == null ? void 0 : _a3[1];
|
|
1676
|
+
if (href && !href.startsWith("#") && !href.startsWith("javascript:")) {
|
|
1677
|
+
try {
|
|
1678
|
+
const url = new URL(href, storeDomain);
|
|
1679
|
+
return url.pathname.replace(/^\/|\/$/g, "");
|
|
1680
|
+
} catch {
|
|
1681
|
+
return href.replace(/^\/|\/$/g, "");
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}).filter((item) => Boolean(item))) != null ? _b2 : [];
|
|
1686
|
+
})) != null ? _k : [];
|
|
1687
|
+
const slug = generateStoreSlug(baseUrl);
|
|
1688
|
+
const countryDetection = await detectShopifyCountry(html);
|
|
1689
|
+
const [homePageProductLinks, homePageCollectionLinks] = await Promise.all([
|
|
1690
|
+
validateLinksInBatches(
|
|
1691
|
+
extractedProductLinks.filter(
|
|
1692
|
+
(handle) => Boolean(handle)
|
|
1693
|
+
),
|
|
1694
|
+
(handle) => validateProductExists(handle)
|
|
1695
|
+
),
|
|
1696
|
+
validateLinksInBatches(
|
|
1697
|
+
extractedCollectionLinks.filter(
|
|
1698
|
+
(handle) => Boolean(handle)
|
|
1699
|
+
),
|
|
1700
|
+
(handle) => validateCollectionExists(handle)
|
|
1701
|
+
)
|
|
1702
|
+
]);
|
|
1703
|
+
const info = {
|
|
1704
|
+
name: name || slug,
|
|
1705
|
+
domain: sanitizeDomain(baseUrl),
|
|
1706
|
+
slug,
|
|
1707
|
+
title: title || null,
|
|
1708
|
+
description: description || null,
|
|
1709
|
+
logoUrl,
|
|
1710
|
+
socialLinks,
|
|
1711
|
+
contactLinks,
|
|
1712
|
+
headerLinks,
|
|
1713
|
+
showcase: {
|
|
1714
|
+
products: (0, import_remeda.unique)(homePageProductLinks != null ? homePageProductLinks : []),
|
|
1715
|
+
collections: (0, import_remeda.unique)(homePageCollectionLinks != null ? homePageCollectionLinks : [])
|
|
1716
|
+
},
|
|
1717
|
+
jsonLdData: ((_m = (_l = html.match(
|
|
1718
|
+
/<script[^>]*type="application\/ld\+json"[^>]*>([^<]+)<\/script>/g
|
|
1719
|
+
)) == null ? void 0 : _l.map(
|
|
1720
|
+
(match) => {
|
|
1721
|
+
var _a2;
|
|
1722
|
+
return ((_a2 = match == null ? void 0 : match.split(">")[1]) == null ? void 0 : _a2.replace(/<\/script/g, "")) || null;
|
|
1723
|
+
}
|
|
1724
|
+
)) == null ? void 0 : _m.map((json) => json ? JSON.parse(json) : null)) || [],
|
|
1725
|
+
techProvider: {
|
|
1726
|
+
name: "shopify",
|
|
1727
|
+
walletId: shopifyWalletId,
|
|
1728
|
+
subDomain: myShopifySubdomain != null ? myShopifySubdomain : null
|
|
1729
|
+
},
|
|
1730
|
+
country: countryDetection.country
|
|
1731
|
+
};
|
|
1732
|
+
const currencyCode = countryDetection == null ? void 0 : countryDetection.currencyCode;
|
|
1733
|
+
return { info, currencyCode };
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// src/collections.ts
|
|
1737
|
+
var import_remeda2 = require("remeda");
|
|
1738
|
+
function createCollectionOperations(baseUrl, storeDomain, fetchCollections, collectionsDto2, fetchPaginatedProductsFromCollection, getStoreInfo, findCollection) {
|
|
1739
|
+
function applyCurrencyOverride(product, currency) {
|
|
1740
|
+
var _a, _b, _c, _d, _e, _f;
|
|
1741
|
+
const priceMin = (_b = (_a = product.priceMin) != null ? _a : product.price) != null ? _b : 0;
|
|
1742
|
+
const priceMax = (_d = (_c = product.priceMax) != null ? _c : product.price) != null ? _d : 0;
|
|
1743
|
+
const compareAtMin = (_f = (_e = product.compareAtPriceMin) != null ? _e : product.compareAtPrice) != null ? _f : 0;
|
|
1744
|
+
return {
|
|
1745
|
+
...product,
|
|
1746
|
+
currency,
|
|
1747
|
+
localizedPricing: {
|
|
1748
|
+
currency,
|
|
1749
|
+
priceFormatted: formatPrice(priceMin, currency),
|
|
1750
|
+
priceMinFormatted: formatPrice(priceMin, currency),
|
|
1751
|
+
priceMaxFormatted: formatPrice(priceMax, currency),
|
|
1752
|
+
compareAtPriceFormatted: formatPrice(compareAtMin, currency)
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function maybeOverrideProductsCurrency(products, currency) {
|
|
1757
|
+
if (!products || !currency) return products;
|
|
1758
|
+
return products.map((p) => applyCurrencyOverride(p, currency));
|
|
1759
|
+
}
|
|
1760
|
+
return {
|
|
1761
|
+
/**
|
|
1762
|
+
* Fetches collections with pagination support.
|
|
1763
|
+
*
|
|
1764
|
+
* @param options - Pagination options
|
|
1765
|
+
* @param options.page - Page number (default: 1)
|
|
1766
|
+
* @param options.limit - Number of collections per page (default: 10, max: 250)
|
|
1767
|
+
*
|
|
1768
|
+
* @returns {Promise<Collection[] | null>} Collections for the requested page, or null on error
|
|
1769
|
+
*/
|
|
1770
|
+
paginated: async (options) => {
|
|
1771
|
+
var _a, _b;
|
|
1772
|
+
const page = (_a = options == null ? void 0 : options.page) != null ? _a : 1;
|
|
1773
|
+
const limit = (_b = options == null ? void 0 : options.limit) != null ? _b : 10;
|
|
1774
|
+
if (page < 1 || limit < 1 || limit > 250) {
|
|
1775
|
+
throw new Error(
|
|
1776
|
+
"Invalid pagination parameters: page must be >= 1, limit must be between 1 and 250"
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
try {
|
|
1780
|
+
const collections = await fetchCollections(page, limit);
|
|
1781
|
+
return collections != null ? collections : null;
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
console.error(
|
|
1784
|
+
"Failed to fetch paginated collections:",
|
|
1785
|
+
storeDomain,
|
|
1786
|
+
error
|
|
1787
|
+
);
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
/**
|
|
1792
|
+
* Fetches all collections from the store across all pages.
|
|
1793
|
+
*
|
|
1794
|
+
* @returns {Promise<Collection[]>} Array of all collections
|
|
1795
|
+
*
|
|
1796
|
+
* @throws {Error} When there's a network error or API failure
|
|
1797
|
+
*
|
|
1798
|
+
* @example
|
|
1799
|
+
* ```typescript
|
|
1800
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
1801
|
+
* const allCollections = await shop.collections.all();
|
|
1802
|
+
*
|
|
1803
|
+
* console.log(`Found ${allCollections.length} collections`);
|
|
1804
|
+
* allCollections.forEach(collection => {
|
|
1805
|
+
* console.log(collection.title, collection.handle);
|
|
1806
|
+
* });
|
|
1807
|
+
* ```
|
|
1808
|
+
*/
|
|
1809
|
+
all: async () => {
|
|
1810
|
+
const limit = 250;
|
|
1811
|
+
const allCollections = [];
|
|
1812
|
+
async function fetchAll() {
|
|
1813
|
+
let currentPage = 1;
|
|
1814
|
+
while (true) {
|
|
1815
|
+
const collections = await fetchCollections(currentPage, limit);
|
|
1816
|
+
if (!collections || collections.length === 0 || collections.length < limit) {
|
|
1817
|
+
if (!collections) {
|
|
1818
|
+
console.warn(
|
|
1819
|
+
"fetchCollections returned null, treating as empty array."
|
|
1820
|
+
);
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
if (collections && collections.length > 0) {
|
|
1824
|
+
allCollections.push(...collections);
|
|
1825
|
+
}
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
allCollections.push(...collections);
|
|
1829
|
+
currentPage++;
|
|
1830
|
+
}
|
|
1831
|
+
return allCollections;
|
|
1832
|
+
}
|
|
1833
|
+
try {
|
|
1834
|
+
const collections = await fetchAll();
|
|
1835
|
+
return collections || [];
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
console.error("Failed to fetch all collections:", storeDomain, error);
|
|
1838
|
+
throw error;
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
/**
|
|
1842
|
+
* Finds a specific collection by its handle.
|
|
1843
|
+
*
|
|
1844
|
+
* @param collectionHandle - The collection handle (URL slug) to search for
|
|
1845
|
+
*
|
|
1846
|
+
* @returns {Promise<Collection | null>} The collection if found, null if not found
|
|
1847
|
+
*
|
|
1848
|
+
* @throws {Error} When the handle is invalid or there's a network error
|
|
1849
|
+
*
|
|
1850
|
+
* @example
|
|
1851
|
+
* ```typescript
|
|
1852
|
+
* const shop = new ShopClient('https://example.myshopify.com');
|
|
1853
|
+
* const collection = await shop.collections.find('summer-collection');
|
|
1854
|
+
* if (collection) {
|
|
1855
|
+
* console.log(collection.title); // "Summer Collection"
|
|
1856
|
+
* }
|
|
1857
|
+
* ```
|
|
1858
|
+
*/
|
|
1859
|
+
find: async (collectionHandle) => {
|
|
1860
|
+
var _a, _b;
|
|
1861
|
+
if (!collectionHandle || typeof collectionHandle !== "string") {
|
|
1862
|
+
throw new Error("Collection handle is required and must be a string");
|
|
1863
|
+
}
|
|
1864
|
+
const sanitizedHandle = collectionHandle.trim().replace(/[^a-zA-Z0-9\-_]/g, "");
|
|
1865
|
+
if (!sanitizedHandle) {
|
|
1866
|
+
throw new Error("Invalid collection handle format");
|
|
1867
|
+
}
|
|
1868
|
+
if (sanitizedHandle.length > 255) {
|
|
1869
|
+
throw new Error("Collection handle is too long");
|
|
1870
|
+
}
|
|
1871
|
+
try {
|
|
1872
|
+
const url = `${baseUrl}collections/${encodeURIComponent(sanitizedHandle)}.json`;
|
|
1873
|
+
const response = await rateLimitedFetch(url);
|
|
1874
|
+
if (!response.ok) {
|
|
1875
|
+
if (response.status === 404) {
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1879
|
+
}
|
|
1880
|
+
const result = await response.json();
|
|
1881
|
+
let collectionImage = result.collection.image;
|
|
1882
|
+
if (!collectionImage) {
|
|
1883
|
+
const collectionProduct = (_a = await fetchPaginatedProductsFromCollection(
|
|
1884
|
+
result.collection.handle,
|
|
1885
|
+
{
|
|
1886
|
+
limit: 1,
|
|
1887
|
+
page: 1
|
|
1888
|
+
}
|
|
1889
|
+
)) == null ? void 0 : _a.at(0);
|
|
1890
|
+
const collectionProductImage = (_b = collectionProduct == null ? void 0 : collectionProduct.images) == null ? void 0 : _b[0];
|
|
1891
|
+
if (collectionProduct && collectionProductImage) {
|
|
1892
|
+
collectionImage = {
|
|
1893
|
+
id: collectionProductImage.id,
|
|
1894
|
+
src: collectionProductImage.src,
|
|
1895
|
+
alt: collectionProductImage.alt || collectionProduct.title,
|
|
1896
|
+
created_at: collectionProductImage.createdAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const collectionData = collectionsDto2([
|
|
1901
|
+
{
|
|
1902
|
+
...result.collection,
|
|
1903
|
+
image: collectionImage
|
|
1904
|
+
}
|
|
1905
|
+
]);
|
|
1906
|
+
return collectionData[0] || null;
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
if (error instanceof Error) {
|
|
1909
|
+
console.error(
|
|
1910
|
+
`Error fetching collection ${sanitizedHandle}:`,
|
|
1911
|
+
baseUrl,
|
|
1912
|
+
error.message
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
throw error;
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
/**
|
|
1919
|
+
* Fetches collections that are showcased/featured on the store's homepage.
|
|
1920
|
+
*
|
|
1921
|
+
* @returns {Promise<Collection[]>} Array of showcased collections found on the homepage
|
|
1922
|
+
*
|
|
1923
|
+
* @throws {Error} When there's a network error or API failure
|
|
1924
|
+
*
|
|
1925
|
+
* @example
|
|
1926
|
+
* ```typescript
|
|
1927
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
1928
|
+
* const showcasedCollections = await shop.collections.showcased();
|
|
1929
|
+
*
|
|
1930
|
+
* console.log(`Found ${showcasedCollections.length} showcased collections`);
|
|
1931
|
+
* showcasedCollections.forEach(collection => {
|
|
1932
|
+
* console.log(`Featured: ${collection.title} - ${collection.productsCount} products`);
|
|
1933
|
+
* });
|
|
1934
|
+
* ```
|
|
1935
|
+
*/
|
|
1936
|
+
showcased: async () => {
|
|
1937
|
+
const storeInfo = await getStoreInfo();
|
|
1938
|
+
const collections = await Promise.all(
|
|
1939
|
+
storeInfo.showcase.collections.map(
|
|
1940
|
+
(collectionHandle) => findCollection(collectionHandle)
|
|
1941
|
+
)
|
|
1942
|
+
);
|
|
1943
|
+
return (0, import_remeda2.filter)(collections, import_remeda2.isNonNullish);
|
|
1944
|
+
},
|
|
1945
|
+
products: {
|
|
1946
|
+
/**
|
|
1947
|
+
* Fetches products from a specific collection with pagination support.
|
|
1948
|
+
*
|
|
1949
|
+
* @param collectionHandle - The collection handle to fetch products from
|
|
1950
|
+
* @param options - Pagination options
|
|
1951
|
+
* @param options.page - Page number (default: 1)
|
|
1952
|
+
* @param options.limit - Number of products per page (default: 250, max: 250)
|
|
1953
|
+
*
|
|
1954
|
+
* @returns {Promise<Product[] | null>} Array of products from the collection or null if error occurs
|
|
1955
|
+
*
|
|
1956
|
+
* @throws {Error} When the collection handle is invalid or there's a network error
|
|
1957
|
+
*
|
|
1958
|
+
* @example
|
|
1959
|
+
* ```typescript
|
|
1960
|
+
* const shop = new ShopClient('https://example.myshopify.com');
|
|
1961
|
+
*
|
|
1962
|
+
* // Get first page of products from a collection
|
|
1963
|
+
* const products = await shop.collections.products.paginated('summer-collection');
|
|
1964
|
+
*
|
|
1965
|
+
* // Get second page with custom limit
|
|
1966
|
+
* const moreProducts = await shop.collections.products.paginated(
|
|
1967
|
+
* 'summer-collection',
|
|
1968
|
+
* { page: 2, limit: 50 }
|
|
1969
|
+
* );
|
|
1970
|
+
* ```
|
|
1971
|
+
*/
|
|
1972
|
+
paginated: async (collectionHandle, options) => {
|
|
1973
|
+
var _a, _b;
|
|
1974
|
+
if (!collectionHandle || typeof collectionHandle !== "string") {
|
|
1975
|
+
throw new Error("Collection handle is required and must be a string");
|
|
1976
|
+
}
|
|
1977
|
+
const sanitizedHandle = collectionHandle.trim().replace(/[^a-zA-Z0-9\-_]/g, "");
|
|
1978
|
+
if (!sanitizedHandle) {
|
|
1979
|
+
throw new Error("Invalid collection handle format");
|
|
1980
|
+
}
|
|
1981
|
+
if (sanitizedHandle.length > 255) {
|
|
1982
|
+
throw new Error("Collection handle is too long");
|
|
1983
|
+
}
|
|
1984
|
+
const page = (_a = options == null ? void 0 : options.page) != null ? _a : 1;
|
|
1985
|
+
const limit = (_b = options == null ? void 0 : options.limit) != null ? _b : 250;
|
|
1986
|
+
if (page < 1 || limit < 1 || limit > 250) {
|
|
1987
|
+
throw new Error(
|
|
1988
|
+
"Invalid pagination parameters: page must be >= 1, limit must be between 1 and 250"
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
const products = await fetchPaginatedProductsFromCollection(
|
|
1992
|
+
sanitizedHandle,
|
|
1993
|
+
{ page, limit }
|
|
1994
|
+
);
|
|
1995
|
+
return maybeOverrideProductsCurrency(products, options == null ? void 0 : options.currency);
|
|
1996
|
+
},
|
|
1997
|
+
/**
|
|
1998
|
+
* Fetches all products from a specific collection.
|
|
1999
|
+
*
|
|
2000
|
+
* @param collectionHandle - The collection handle to fetch products from
|
|
2001
|
+
*
|
|
2002
|
+
* @returns {Promise<Product[] | null>} Array of all products from the collection or null if error occurs
|
|
2003
|
+
*
|
|
2004
|
+
* @throws {Error} When the collection handle is invalid or there's a network error
|
|
2005
|
+
*
|
|
2006
|
+
* @example
|
|
2007
|
+
* ```typescript
|
|
2008
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2009
|
+
* const allProducts = await shop.collections.products.all('summer-collection');
|
|
2010
|
+
*
|
|
2011
|
+
* if (allProducts) {
|
|
2012
|
+
* console.log(`Found ${allProducts.length} products in the collection`);
|
|
2013
|
+
* allProducts.forEach(product => {
|
|
2014
|
+
* console.log(`${product.title} - $${product.price}`);
|
|
2015
|
+
* });
|
|
2016
|
+
* }
|
|
2017
|
+
* ```
|
|
2018
|
+
*/
|
|
2019
|
+
all: async (collectionHandle, options) => {
|
|
2020
|
+
if (!collectionHandle || typeof collectionHandle !== "string") {
|
|
2021
|
+
throw new Error("Collection handle is required and must be a string");
|
|
2022
|
+
}
|
|
2023
|
+
const sanitizedHandle = collectionHandle.trim().replace(/[^a-zA-Z0-9\-_]/g, "");
|
|
2024
|
+
if (!sanitizedHandle) {
|
|
2025
|
+
throw new Error("Invalid collection handle format");
|
|
2026
|
+
}
|
|
2027
|
+
if (sanitizedHandle.length > 255) {
|
|
2028
|
+
throw new Error("Collection handle is too long");
|
|
2029
|
+
}
|
|
2030
|
+
try {
|
|
2031
|
+
const limit = 250;
|
|
2032
|
+
const allProducts = [];
|
|
2033
|
+
let currentPage = 1;
|
|
2034
|
+
while (true) {
|
|
2035
|
+
const products = await fetchPaginatedProductsFromCollection(
|
|
2036
|
+
sanitizedHandle,
|
|
2037
|
+
{
|
|
2038
|
+
page: currentPage,
|
|
2039
|
+
limit
|
|
2040
|
+
}
|
|
2041
|
+
);
|
|
2042
|
+
if (!products || products.length === 0 || products.length < limit) {
|
|
2043
|
+
if (products && products.length > 0) {
|
|
2044
|
+
allProducts.push(...products);
|
|
2045
|
+
}
|
|
2046
|
+
break;
|
|
2047
|
+
}
|
|
2048
|
+
allProducts.push(...products);
|
|
2049
|
+
currentPage++;
|
|
2050
|
+
}
|
|
2051
|
+
return maybeOverrideProductsCurrency(allProducts, options == null ? void 0 : options.currency);
|
|
2052
|
+
} catch (error) {
|
|
2053
|
+
console.error(
|
|
2054
|
+
`Error fetching all products for collection ${sanitizedHandle}:`,
|
|
2055
|
+
baseUrl,
|
|
2056
|
+
error
|
|
2057
|
+
);
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
2060
|
+
},
|
|
2061
|
+
/**
|
|
2062
|
+
* Fetches all product slugs from a specific collection.
|
|
2063
|
+
*
|
|
2064
|
+
* @param collectionHandle - The collection handle to fetch product slugs from
|
|
2065
|
+
*
|
|
2066
|
+
* @returns {Promise<string[] | null>} Array of product slugs from the collection or null if error occurs
|
|
2067
|
+
*
|
|
2068
|
+
* @throws {Error} When the collection handle is invalid or there's a network error
|
|
2069
|
+
*
|
|
2070
|
+
* @example
|
|
2071
|
+
* ```typescript
|
|
2072
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2073
|
+
* const productSlugs = await shop.collections.products.slugs('summer-collection');
|
|
2074
|
+
* console.log(productSlugs);
|
|
2075
|
+
* ```
|
|
2076
|
+
*/
|
|
2077
|
+
slugs: async (collectionHandle) => {
|
|
2078
|
+
if (!collectionHandle || typeof collectionHandle !== "string") {
|
|
2079
|
+
throw new Error("Collection handle is required and must be a string");
|
|
2080
|
+
}
|
|
2081
|
+
const sanitizedHandle = collectionHandle.trim().replace(/[^a-zA-Z0-9\-_]/g, "");
|
|
2082
|
+
if (!sanitizedHandle) {
|
|
2083
|
+
throw new Error("Invalid collection handle format");
|
|
2084
|
+
}
|
|
2085
|
+
if (sanitizedHandle.length > 255) {
|
|
2086
|
+
throw new Error("Collection handle is too long");
|
|
2087
|
+
}
|
|
2088
|
+
try {
|
|
2089
|
+
const limit = 250;
|
|
2090
|
+
const slugs = [];
|
|
2091
|
+
let currentPage = 1;
|
|
2092
|
+
while (true) {
|
|
2093
|
+
const products = await fetchPaginatedProductsFromCollection(
|
|
2094
|
+
sanitizedHandle,
|
|
2095
|
+
{
|
|
2096
|
+
page: currentPage,
|
|
2097
|
+
limit
|
|
2098
|
+
}
|
|
2099
|
+
);
|
|
2100
|
+
if (!products || products.length === 0 || products.length < limit) {
|
|
2101
|
+
if (products && products.length > 0) {
|
|
2102
|
+
slugs.push(...products.map((p) => p.slug));
|
|
2103
|
+
}
|
|
2104
|
+
break;
|
|
2105
|
+
}
|
|
2106
|
+
slugs.push(...products.map((p) => p.slug));
|
|
2107
|
+
currentPage++;
|
|
2108
|
+
}
|
|
2109
|
+
return slugs;
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
console.error(
|
|
2112
|
+
`Error fetching product slugs for collection ${sanitizedHandle}:`,
|
|
2113
|
+
baseUrl,
|
|
2114
|
+
error
|
|
2115
|
+
);
|
|
2116
|
+
return null;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/dto/collections.dto.ts
|
|
2124
|
+
function collectionsDto(collections) {
|
|
2125
|
+
if (!collections || collections.length === 0) return null;
|
|
2126
|
+
return collections.map((collection) => ({
|
|
2127
|
+
id: collection.id.toString(),
|
|
2128
|
+
title: collection.title,
|
|
2129
|
+
handle: collection.handle,
|
|
2130
|
+
description: collection.description,
|
|
2131
|
+
image: collection.image ? {
|
|
2132
|
+
id: collection.image.id,
|
|
2133
|
+
createdAt: collection.image.created_at,
|
|
2134
|
+
src: collection.image.src,
|
|
2135
|
+
alt: collection.image.alt
|
|
2136
|
+
} : void 0,
|
|
2137
|
+
productsCount: collection.products_count,
|
|
2138
|
+
publishedAt: collection.published_at,
|
|
2139
|
+
updatedAt: collection.updated_at
|
|
2140
|
+
}));
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/dto/products.mapped.ts
|
|
2144
|
+
function mapVariants(product) {
|
|
2145
|
+
var _a;
|
|
2146
|
+
const variants = (_a = product.variants) != null ? _a : [];
|
|
2147
|
+
return variants.map((variant) => {
|
|
2148
|
+
var _a2;
|
|
2149
|
+
return {
|
|
2150
|
+
id: variant.id.toString(),
|
|
2151
|
+
platformId: variant.id.toString(),
|
|
2152
|
+
name: variant.name,
|
|
2153
|
+
title: variant.title,
|
|
2154
|
+
option1: variant.option1 || null,
|
|
2155
|
+
option2: variant.option2 || null,
|
|
2156
|
+
option3: variant.option3 || null,
|
|
2157
|
+
options: [variant.option1, variant.option2, variant.option3].filter(
|
|
2158
|
+
Boolean
|
|
2159
|
+
),
|
|
2160
|
+
sku: variant.sku || null,
|
|
2161
|
+
requiresShipping: variant.requires_shipping,
|
|
2162
|
+
taxable: variant.taxable,
|
|
2163
|
+
featuredImage: variant.featured_image ? {
|
|
2164
|
+
id: variant.featured_image.id,
|
|
2165
|
+
src: variant.featured_image.src,
|
|
2166
|
+
width: variant.featured_image.width,
|
|
2167
|
+
height: variant.featured_image.height,
|
|
2168
|
+
position: variant.featured_image.position,
|
|
2169
|
+
productId: variant.featured_image.product_id,
|
|
2170
|
+
aspectRatio: variant.featured_image.aspect_ratio || 0,
|
|
2171
|
+
variantIds: variant.featured_image.variant_ids || [],
|
|
2172
|
+
createdAt: variant.featured_image.created_at,
|
|
2173
|
+
updatedAt: variant.featured_image.updated_at,
|
|
2174
|
+
alt: variant.featured_image.alt
|
|
2175
|
+
} : null,
|
|
2176
|
+
available: Boolean(variant.available),
|
|
2177
|
+
price: typeof variant.price === "string" ? Number.parseFloat(variant.price) * 100 : variant.price,
|
|
2178
|
+
weightInGrams: (_a2 = variant.weightInGrams) != null ? _a2 : variant.grams,
|
|
2179
|
+
compareAtPrice: variant.compare_at_price ? typeof variant.compare_at_price === "string" ? Number.parseFloat(variant.compare_at_price) * 100 : variant.compare_at_price : 0,
|
|
2180
|
+
position: variant.position,
|
|
2181
|
+
productId: variant.product_id,
|
|
2182
|
+
createdAt: variant.created_at,
|
|
2183
|
+
updatedAt: variant.updated_at
|
|
2184
|
+
};
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
function mapProductsDto(products, ctx) {
|
|
2188
|
+
if (!products || products.length === 0) return null;
|
|
2189
|
+
return products.map((product) => {
|
|
2190
|
+
var _a, _b, _c;
|
|
2191
|
+
const optionNames = product.options.map((o) => o.name);
|
|
2192
|
+
const variantOptionsMap = buildVariantOptionsMap(
|
|
2193
|
+
optionNames,
|
|
2194
|
+
product.variants
|
|
2195
|
+
);
|
|
2196
|
+
const mappedVariants = mapVariants(product);
|
|
2197
|
+
const priceValues = mappedVariants.map((v) => v.price).filter((p) => typeof p === "number" && !Number.isNaN(p));
|
|
2198
|
+
const compareAtValues = mappedVariants.map((v) => v.compareAtPrice || 0).filter((p) => typeof p === "number" && !Number.isNaN(p));
|
|
2199
|
+
const priceMin = priceValues.length ? Math.min(...priceValues) : 0;
|
|
2200
|
+
const priceMax = priceValues.length ? Math.max(...priceValues) : 0;
|
|
2201
|
+
const priceVaries = mappedVariants.length > 1 && priceMin !== priceMax;
|
|
2202
|
+
const compareAtMin = compareAtValues.length ? Math.min(...compareAtValues) : 0;
|
|
2203
|
+
const compareAtMax = compareAtValues.length ? Math.max(...compareAtValues) : 0;
|
|
2204
|
+
const compareAtVaries = mappedVariants.length > 1 && compareAtMin !== compareAtMax;
|
|
2205
|
+
return {
|
|
2206
|
+
slug: genProductSlug({
|
|
2207
|
+
handle: product.handle,
|
|
2208
|
+
storeDomain: ctx.storeDomain
|
|
2209
|
+
}),
|
|
2210
|
+
handle: product.handle,
|
|
2211
|
+
platformId: product.id.toString(),
|
|
2212
|
+
title: product.title,
|
|
2213
|
+
available: mappedVariants.some((v) => v.available),
|
|
2214
|
+
price: priceMin,
|
|
2215
|
+
priceMin,
|
|
2216
|
+
priceMax,
|
|
2217
|
+
priceVaries,
|
|
2218
|
+
compareAtPrice: compareAtMin,
|
|
2219
|
+
compareAtPriceMin: compareAtMin,
|
|
2220
|
+
compareAtPriceMax: compareAtMax,
|
|
2221
|
+
compareAtPriceVaries: compareAtVaries,
|
|
2222
|
+
discount: 0,
|
|
2223
|
+
currency: ctx.currency,
|
|
2224
|
+
localizedPricing: {
|
|
2225
|
+
currency: ctx.currency,
|
|
2226
|
+
priceFormatted: ctx.formatPrice(priceMin),
|
|
2227
|
+
priceMinFormatted: ctx.formatPrice(priceMin),
|
|
2228
|
+
priceMaxFormatted: ctx.formatPrice(priceMax),
|
|
2229
|
+
compareAtPriceFormatted: ctx.formatPrice(compareAtMin)
|
|
2230
|
+
},
|
|
2231
|
+
options: product.options.map((option) => ({
|
|
2232
|
+
key: normalizeKey(option.name),
|
|
2233
|
+
data: option.values,
|
|
2234
|
+
name: option.name,
|
|
2235
|
+
position: option.position,
|
|
2236
|
+
values: option.values
|
|
2237
|
+
})),
|
|
2238
|
+
variantOptionsMap,
|
|
2239
|
+
bodyHtml: product.body_html || null,
|
|
2240
|
+
active: true,
|
|
2241
|
+
productType: product.product_type || null,
|
|
2242
|
+
tags: Array.isArray(product.tags) ? product.tags : [],
|
|
2243
|
+
vendor: product.vendor,
|
|
2244
|
+
featuredImage: ((_b = (_a = product.images) == null ? void 0 : _a[0]) == null ? void 0 : _b.src) ? ctx.normalizeImageUrl(product.images[0].src) : null,
|
|
2245
|
+
isProxyFeaturedImage: false,
|
|
2246
|
+
createdAt: safeParseDate(product.created_at),
|
|
2247
|
+
updatedAt: safeParseDate(product.updated_at),
|
|
2248
|
+
variants: mappedVariants,
|
|
2249
|
+
images: product.images.map((image) => ({
|
|
2250
|
+
id: image.id,
|
|
2251
|
+
productId: image.product_id,
|
|
2252
|
+
alt: null,
|
|
2253
|
+
position: image.position,
|
|
2254
|
+
src: ctx.normalizeImageUrl(image.src),
|
|
2255
|
+
width: image.width,
|
|
2256
|
+
height: image.height,
|
|
2257
|
+
mediaType: "image",
|
|
2258
|
+
variantIds: image.variant_ids || [],
|
|
2259
|
+
createdAt: image.created_at,
|
|
2260
|
+
updatedAt: image.updated_at
|
|
2261
|
+
})),
|
|
2262
|
+
publishedAt: (_c = safeParseDate(product.published_at)) != null ? _c : null,
|
|
2263
|
+
seo: null,
|
|
2264
|
+
metaTags: null,
|
|
2265
|
+
displayScore: void 0,
|
|
2266
|
+
deletedAt: null,
|
|
2267
|
+
storeSlug: ctx.storeSlug,
|
|
2268
|
+
storeDomain: ctx.storeDomain,
|
|
2269
|
+
url: `${ctx.storeDomain}/products/${product.handle}`
|
|
2270
|
+
};
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
function mapProductDto(product, ctx) {
|
|
2274
|
+
var _a;
|
|
2275
|
+
const optionNames = product.options.map((o) => o.name);
|
|
2276
|
+
const variantOptionsMap = buildVariantOptionsMap(
|
|
2277
|
+
optionNames,
|
|
2278
|
+
product.variants
|
|
2279
|
+
);
|
|
2280
|
+
const mapped = {
|
|
2281
|
+
slug: genProductSlug({
|
|
2282
|
+
handle: product.handle,
|
|
2283
|
+
storeDomain: ctx.storeDomain
|
|
2284
|
+
}),
|
|
2285
|
+
handle: product.handle,
|
|
2286
|
+
platformId: product.id.toString(),
|
|
2287
|
+
title: product.title,
|
|
2288
|
+
available: product.available,
|
|
2289
|
+
price: product.price,
|
|
2290
|
+
priceMin: product.price_min,
|
|
2291
|
+
priceMax: product.price_max,
|
|
2292
|
+
priceVaries: product.price_varies,
|
|
2293
|
+
compareAtPrice: product.compare_at_price || 0,
|
|
2294
|
+
compareAtPriceMin: product.compare_at_price_min,
|
|
2295
|
+
compareAtPriceMax: product.compare_at_price_max,
|
|
2296
|
+
compareAtPriceVaries: product.compare_at_price_varies,
|
|
2297
|
+
discount: 0,
|
|
2298
|
+
currency: ctx.currency,
|
|
2299
|
+
localizedPricing: {
|
|
2300
|
+
currency: ctx.currency,
|
|
2301
|
+
priceFormatted: ctx.formatPrice(product.price),
|
|
2302
|
+
priceMinFormatted: ctx.formatPrice(product.price_min),
|
|
2303
|
+
priceMaxFormatted: ctx.formatPrice(product.price_max),
|
|
2304
|
+
compareAtPriceFormatted: ctx.formatPrice(product.compare_at_price || 0)
|
|
2305
|
+
},
|
|
2306
|
+
options: product.options.map((option) => ({
|
|
2307
|
+
key: normalizeKey(option.name),
|
|
2308
|
+
data: option.values,
|
|
2309
|
+
name: option.name,
|
|
2310
|
+
position: option.position,
|
|
2311
|
+
values: option.values
|
|
2312
|
+
})),
|
|
2313
|
+
variantOptionsMap,
|
|
2314
|
+
bodyHtml: product.description || null,
|
|
2315
|
+
active: true,
|
|
2316
|
+
productType: product.type || null,
|
|
2317
|
+
tags: Array.isArray(product.tags) ? product.tags : typeof product.tags === "string" ? [product.tags] : [],
|
|
2318
|
+
vendor: product.vendor,
|
|
2319
|
+
featuredImage: ctx.normalizeImageUrl(product.featured_image),
|
|
2320
|
+
isProxyFeaturedImage: false,
|
|
2321
|
+
createdAt: safeParseDate(product.created_at),
|
|
2322
|
+
updatedAt: safeParseDate(product.updated_at),
|
|
2323
|
+
variants: mapVariants(product),
|
|
2324
|
+
images: Array.isArray(product.images) ? product.images.map((imageSrc, index) => ({
|
|
2325
|
+
id: index + 1,
|
|
2326
|
+
productId: product.id,
|
|
2327
|
+
alt: null,
|
|
2328
|
+
position: index + 1,
|
|
2329
|
+
src: ctx.normalizeImageUrl(imageSrc),
|
|
2330
|
+
width: 0,
|
|
2331
|
+
height: 0,
|
|
2332
|
+
mediaType: "image",
|
|
2333
|
+
variantIds: [],
|
|
2334
|
+
createdAt: product.created_at,
|
|
2335
|
+
updatedAt: product.updated_at
|
|
2336
|
+
})) : [],
|
|
2337
|
+
publishedAt: (_a = safeParseDate(product.published_at)) != null ? _a : null,
|
|
2338
|
+
seo: null,
|
|
2339
|
+
metaTags: null,
|
|
2340
|
+
displayScore: void 0,
|
|
2341
|
+
deletedAt: null,
|
|
2342
|
+
storeSlug: ctx.storeSlug,
|
|
2343
|
+
storeDomain: ctx.storeDomain,
|
|
2344
|
+
url: product.url || `${ctx.storeDomain}/products/${product.handle}`
|
|
2345
|
+
};
|
|
2346
|
+
return mapped;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// src/products.ts
|
|
2350
|
+
var import_remeda3 = require("remeda");
|
|
2351
|
+
function createProductOperations(baseUrl, storeDomain, fetchProducts, productsDto, productDto, getStoreInfo, findProduct) {
|
|
2352
|
+
function applyCurrencyOverride(product, currency) {
|
|
2353
|
+
var _a, _b, _c, _d, _e, _f;
|
|
2354
|
+
const priceMin = (_b = (_a = product.priceMin) != null ? _a : product.price) != null ? _b : 0;
|
|
2355
|
+
const priceMax = (_d = (_c = product.priceMax) != null ? _c : product.price) != null ? _d : 0;
|
|
2356
|
+
const compareAtMin = (_f = (_e = product.compareAtPriceMin) != null ? _e : product.compareAtPrice) != null ? _f : 0;
|
|
2357
|
+
return {
|
|
2358
|
+
...product,
|
|
2359
|
+
currency,
|
|
2360
|
+
localizedPricing: {
|
|
2361
|
+
currency,
|
|
2362
|
+
priceFormatted: formatPrice(priceMin, currency),
|
|
2363
|
+
priceMinFormatted: formatPrice(priceMin, currency),
|
|
2364
|
+
priceMaxFormatted: formatPrice(priceMax, currency),
|
|
2365
|
+
compareAtPriceFormatted: formatPrice(compareAtMin, currency)
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
function maybeOverrideProductsCurrency(products, currency) {
|
|
2370
|
+
if (!products || !currency) return products;
|
|
2371
|
+
return products.map((p) => applyCurrencyOverride(p, currency));
|
|
2372
|
+
}
|
|
2373
|
+
const operations = {
|
|
2374
|
+
/**
|
|
2375
|
+
* Fetches all products from the store across all pages.
|
|
2376
|
+
*
|
|
2377
|
+
* @returns {Promise<Product[] | null>} Array of all products or null if error occurs
|
|
2378
|
+
*
|
|
2379
|
+
* @throws {Error} When there's a network error or API failure
|
|
2380
|
+
*
|
|
2381
|
+
* @example
|
|
2382
|
+
* ```typescript
|
|
2383
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2384
|
+
* const allProducts = await shop.products.all();
|
|
2385
|
+
*
|
|
2386
|
+
* console.log(`Found ${allProducts?.length} products`);
|
|
2387
|
+
* allProducts?.forEach(product => {
|
|
2388
|
+
* console.log(product.title, product.price);
|
|
2389
|
+
* });
|
|
2390
|
+
* ```
|
|
2391
|
+
*/
|
|
2392
|
+
all: async (options) => {
|
|
2393
|
+
const limit = 250;
|
|
2394
|
+
const allProducts = [];
|
|
2395
|
+
async function fetchAll() {
|
|
2396
|
+
let currentPage = 1;
|
|
2397
|
+
while (true) {
|
|
2398
|
+
const products = await fetchProducts(currentPage, limit);
|
|
2399
|
+
if (!products || products.length === 0 || products.length < limit) {
|
|
2400
|
+
if (products && products.length > 0) {
|
|
2401
|
+
allProducts.push(...products);
|
|
2402
|
+
}
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
allProducts.push(...products);
|
|
2406
|
+
currentPage++;
|
|
2407
|
+
}
|
|
2408
|
+
return allProducts;
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
const products = await fetchAll();
|
|
2412
|
+
return maybeOverrideProductsCurrency(products, options == null ? void 0 : options.currency);
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
console.error("Failed to fetch all products:", storeDomain, error);
|
|
2415
|
+
throw error;
|
|
2416
|
+
}
|
|
2417
|
+
},
|
|
2418
|
+
/**
|
|
2419
|
+
* Fetches products with pagination support.
|
|
2420
|
+
*
|
|
2421
|
+
* @param options - Pagination options
|
|
2422
|
+
* @param options.page - Page number (default: 1)
|
|
2423
|
+
* @param options.limit - Number of products per page (default: 250, max: 250)
|
|
2424
|
+
*
|
|
2425
|
+
* @returns {Promise<Product[] | null>} Array of products for the specified page or null if error occurs
|
|
2426
|
+
*
|
|
2427
|
+
* @throws {Error} When there's a network error or API failure
|
|
2428
|
+
*
|
|
2429
|
+
* @example
|
|
2430
|
+
* ```typescript
|
|
2431
|
+
* const shop = new ShopClient('https://example.myshopify.com');
|
|
2432
|
+
*
|
|
2433
|
+
* // Get first page with default limit (250)
|
|
2434
|
+
* const firstPage = await shop.products.paginated();
|
|
2435
|
+
*
|
|
2436
|
+
* // Get second page with custom limit
|
|
2437
|
+
* const secondPage = await shop.products.paginated({ page: 2, limit: 50 });
|
|
2438
|
+
* ```
|
|
2439
|
+
*/
|
|
2440
|
+
paginated: async (options) => {
|
|
2441
|
+
var _a, _b;
|
|
2442
|
+
const page = (_a = options == null ? void 0 : options.page) != null ? _a : 1;
|
|
2443
|
+
const limit = Math.min((_b = options == null ? void 0 : options.limit) != null ? _b : 250, 250);
|
|
2444
|
+
const url = `${baseUrl}products.json?limit=${limit}&page=${page}`;
|
|
2445
|
+
try {
|
|
2446
|
+
const response = await rateLimitedFetch(url);
|
|
2447
|
+
if (!response.ok) {
|
|
2448
|
+
console.error(
|
|
2449
|
+
`HTTP error! status: ${response.status} for ${storeDomain} page ${page}`
|
|
2450
|
+
);
|
|
2451
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
2452
|
+
}
|
|
2453
|
+
const data = await response.json();
|
|
2454
|
+
if (data.products.length === 0) {
|
|
2455
|
+
return [];
|
|
2456
|
+
}
|
|
2457
|
+
const normalized = productsDto(data.products);
|
|
2458
|
+
return maybeOverrideProductsCurrency(normalized, options == null ? void 0 : options.currency);
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
console.error(
|
|
2461
|
+
`Error fetching products for ${storeDomain} page ${page} with limit ${limit}:`,
|
|
2462
|
+
error
|
|
2463
|
+
);
|
|
2464
|
+
return null;
|
|
2465
|
+
}
|
|
2466
|
+
},
|
|
2467
|
+
/**
|
|
2468
|
+
* Finds a specific product by its handle.
|
|
2469
|
+
*
|
|
2470
|
+
* @param productHandle - The product handle (URL slug) to search for
|
|
2471
|
+
*
|
|
2472
|
+
* @returns {Promise<Product | null>} The product if found, null if not found
|
|
2473
|
+
*
|
|
2474
|
+
* @throws {Error} When the handle is invalid or there's a network error
|
|
2475
|
+
*
|
|
2476
|
+
* @example
|
|
2477
|
+
* ```typescript
|
|
2478
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2479
|
+
*
|
|
2480
|
+
* // Find product by handle
|
|
2481
|
+
* const product = await shop.products.find('awesome-t-shirt');
|
|
2482
|
+
*
|
|
2483
|
+
* if (product) {
|
|
2484
|
+
* console.log(product.title, product.price);
|
|
2485
|
+
* console.log('Available variants:', product.variants.length);
|
|
2486
|
+
* }
|
|
2487
|
+
*
|
|
2488
|
+
* // Handle with query string
|
|
2489
|
+
* const productWithVariant = await shop.products.find('t-shirt?variant=123');
|
|
2490
|
+
* ```
|
|
2491
|
+
*/
|
|
2492
|
+
find: async (productHandle, options) => {
|
|
2493
|
+
var _a, _b;
|
|
2494
|
+
if (!productHandle || typeof productHandle !== "string") {
|
|
2495
|
+
throw new Error("Product handle is required and must be a string");
|
|
2496
|
+
}
|
|
2497
|
+
try {
|
|
2498
|
+
let qs = null;
|
|
2499
|
+
if (productHandle.includes("?")) {
|
|
2500
|
+
const parts = productHandle.split("?");
|
|
2501
|
+
const handlePart = (_a = parts[0]) != null ? _a : productHandle;
|
|
2502
|
+
const qsPart = (_b = parts[1]) != null ? _b : null;
|
|
2503
|
+
productHandle = handlePart;
|
|
2504
|
+
qs = qsPart;
|
|
2505
|
+
}
|
|
2506
|
+
const sanitizedHandle = productHandle.trim().replace(/[^a-zA-Z0-9\-_]/g, "");
|
|
2507
|
+
if (!sanitizedHandle) {
|
|
2508
|
+
throw new Error("Invalid product handle format");
|
|
2509
|
+
}
|
|
2510
|
+
if (sanitizedHandle.length > 255) {
|
|
2511
|
+
throw new Error("Product handle is too long");
|
|
2512
|
+
}
|
|
2513
|
+
let finalHandle = sanitizedHandle;
|
|
2514
|
+
try {
|
|
2515
|
+
const htmlResp = await rateLimitedFetch(
|
|
2516
|
+
`${baseUrl}products/${encodeURIComponent(sanitizedHandle)}`
|
|
2517
|
+
);
|
|
2518
|
+
if (htmlResp.ok) {
|
|
2519
|
+
const finalUrl = htmlResp.url;
|
|
2520
|
+
if (finalUrl) {
|
|
2521
|
+
const pathname = new URL(finalUrl).pathname.replace(/\/$/, "");
|
|
2522
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
2523
|
+
const idx = parts.indexOf("products");
|
|
2524
|
+
const maybeHandle = idx >= 0 ? parts[idx + 1] : void 0;
|
|
2525
|
+
if (typeof maybeHandle === "string" && maybeHandle.length) {
|
|
2526
|
+
finalHandle = maybeHandle;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
const url = `${baseUrl}products/${encodeURIComponent(finalHandle)}.js${qs ? `?${qs}` : ""}`;
|
|
2533
|
+
const response = await rateLimitedFetch(url);
|
|
2534
|
+
if (!response.ok) {
|
|
2535
|
+
if (response.status === 404) {
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
2539
|
+
}
|
|
2540
|
+
const product = await response.json();
|
|
2541
|
+
const productData = productDto(product);
|
|
2542
|
+
return (options == null ? void 0 : options.currency) ? applyCurrencyOverride(productData, options.currency) : productData;
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
if (error instanceof Error) {
|
|
2545
|
+
console.error(
|
|
2546
|
+
`Error fetching product ${productHandle}:`,
|
|
2547
|
+
baseUrl,
|
|
2548
|
+
error.message
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
throw error;
|
|
2552
|
+
}
|
|
2553
|
+
},
|
|
2554
|
+
/**
|
|
2555
|
+
* Enrich a product by generating merged markdown from body_html and product page.
|
|
2556
|
+
* Adds `enriched_content` to the returned product.
|
|
2557
|
+
*/
|
|
2558
|
+
enriched: async (productHandle, options) => {
|
|
2559
|
+
if (!productHandle || typeof productHandle !== "string") {
|
|
2560
|
+
throw new Error("Product handle is required and must be a string");
|
|
2561
|
+
}
|
|
2562
|
+
const apiKey = (options == null ? void 0 : options.apiKey) || process.env.OPENROUTER_API_KEY;
|
|
2563
|
+
if (!apiKey) {
|
|
2564
|
+
throw new Error(
|
|
2565
|
+
"Missing OpenRouter API key. Pass options.apiKey or set OPENROUTER_API_KEY."
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
const baseProduct = await operations.find(productHandle);
|
|
2569
|
+
if (!baseProduct) {
|
|
2570
|
+
return null;
|
|
2571
|
+
}
|
|
2572
|
+
const handle = baseProduct.handle;
|
|
2573
|
+
const enriched = await enrichProduct(storeDomain, handle, {
|
|
2574
|
+
apiKey,
|
|
2575
|
+
useGfm: options == null ? void 0 : options.useGfm,
|
|
2576
|
+
inputType: options == null ? void 0 : options.inputType,
|
|
2577
|
+
model: options == null ? void 0 : options.model,
|
|
2578
|
+
outputFormat: options == null ? void 0 : options.outputFormat
|
|
2579
|
+
});
|
|
2580
|
+
return {
|
|
2581
|
+
...baseProduct,
|
|
2582
|
+
enriched_content: enriched.mergedMarkdown
|
|
2583
|
+
};
|
|
2584
|
+
},
|
|
2585
|
+
classify: async (productHandle, options) => {
|
|
2586
|
+
if (!productHandle || typeof productHandle !== "string") {
|
|
2587
|
+
throw new Error("Product handle is required and must be a string");
|
|
2588
|
+
}
|
|
2589
|
+
const apiKey = (options == null ? void 0 : options.apiKey) || process.env.OPENROUTER_API_KEY;
|
|
2590
|
+
if (!apiKey) {
|
|
2591
|
+
throw new Error(
|
|
2592
|
+
"Missing OpenRouter API key. Pass options.apiKey or set OPENROUTER_API_KEY."
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
const enrichedProduct = await operations.enriched(productHandle, {
|
|
2596
|
+
apiKey,
|
|
2597
|
+
inputType: "html",
|
|
2598
|
+
model: options == null ? void 0 : options.model,
|
|
2599
|
+
outputFormat: "json"
|
|
2600
|
+
});
|
|
2601
|
+
if (!enrichedProduct || !enrichedProduct.enriched_content) return null;
|
|
2602
|
+
let productContent = enrichedProduct.enriched_content;
|
|
2603
|
+
try {
|
|
2604
|
+
const obj = JSON.parse(enrichedProduct.enriched_content);
|
|
2605
|
+
const lines = [];
|
|
2606
|
+
if (obj.title && typeof obj.title === "string")
|
|
2607
|
+
lines.push(`Title: ${obj.title}`);
|
|
2608
|
+
if (obj.description && typeof obj.description === "string")
|
|
2609
|
+
lines.push(`Description: ${obj.description}`);
|
|
2610
|
+
if (Array.isArray(obj.materials) && obj.materials.length)
|
|
2611
|
+
lines.push(`Materials: ${obj.materials.join(", ")}`);
|
|
2612
|
+
if (Array.isArray(obj.care) && obj.care.length)
|
|
2613
|
+
lines.push(`Care: ${obj.care.join(", ")}`);
|
|
2614
|
+
if (obj.fit && typeof obj.fit === "string")
|
|
2615
|
+
lines.push(`Fit: ${obj.fit}`);
|
|
2616
|
+
if (obj.returnPolicy && typeof obj.returnPolicy === "string")
|
|
2617
|
+
lines.push(`ReturnPolicy: ${obj.returnPolicy}`);
|
|
2618
|
+
productContent = lines.join("\n");
|
|
2619
|
+
} catch {
|
|
2620
|
+
}
|
|
2621
|
+
const classification = await classifyProduct(productContent, {
|
|
2622
|
+
apiKey,
|
|
2623
|
+
model: options == null ? void 0 : options.model
|
|
2624
|
+
});
|
|
2625
|
+
return classification;
|
|
2626
|
+
},
|
|
2627
|
+
generateSEOContent: async (productHandle, options) => {
|
|
2628
|
+
if (!productHandle || typeof productHandle !== "string") {
|
|
2629
|
+
throw new Error("Product handle is required and must be a string");
|
|
2630
|
+
}
|
|
2631
|
+
const apiKey = (options == null ? void 0 : options.apiKey) || process.env.OPENROUTER_API_KEY;
|
|
2632
|
+
if (!apiKey) {
|
|
2633
|
+
throw new Error(
|
|
2634
|
+
"Missing OpenRouter API key. Pass options.apiKey or set OPENROUTER_API_KEY."
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
const baseProduct = await operations.find(productHandle);
|
|
2638
|
+
if (!baseProduct) return null;
|
|
2639
|
+
const payload = {
|
|
2640
|
+
title: baseProduct.title,
|
|
2641
|
+
description: baseProduct.bodyHtml || void 0,
|
|
2642
|
+
vendor: baseProduct.vendor,
|
|
2643
|
+
price: baseProduct.price,
|
|
2644
|
+
tags: baseProduct.tags
|
|
2645
|
+
};
|
|
2646
|
+
const seo = await generateSEOContent(payload, {
|
|
2647
|
+
apiKey,
|
|
2648
|
+
model: options == null ? void 0 : options.model
|
|
2649
|
+
});
|
|
2650
|
+
return seo;
|
|
2651
|
+
},
|
|
2652
|
+
/**
|
|
2653
|
+
* Fetches products that are showcased/featured on the store's homepage.
|
|
2654
|
+
*
|
|
2655
|
+
* @returns {Promise<Product[]>} Array of showcased products found on the homepage
|
|
2656
|
+
*
|
|
2657
|
+
* @throws {Error} When there's a network error or API failure
|
|
2658
|
+
*
|
|
2659
|
+
* @example
|
|
2660
|
+
* ```typescript
|
|
2661
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2662
|
+
* const showcasedProducts = await shop.products.showcased();
|
|
2663
|
+
*
|
|
2664
|
+
* console.log(`Found ${showcasedProducts.length} showcased products`);
|
|
2665
|
+
* showcasedProducts.forEach(product => {
|
|
2666
|
+
* console.log(`Featured: ${product.title} - ${product.price}`);
|
|
2667
|
+
* });
|
|
2668
|
+
* ```
|
|
2669
|
+
*/
|
|
2670
|
+
showcased: async () => {
|
|
2671
|
+
const storeInfo = await getStoreInfo();
|
|
2672
|
+
const products = await Promise.all(
|
|
2673
|
+
storeInfo.showcase.products.map(
|
|
2674
|
+
(productHandle) => findProduct(productHandle)
|
|
2675
|
+
)
|
|
2676
|
+
);
|
|
2677
|
+
return (0, import_remeda3.filter)(products, import_remeda3.isNonNullish);
|
|
2678
|
+
},
|
|
2679
|
+
/**
|
|
2680
|
+
* Creates a filter map of variant options and their distinct values from all products.
|
|
2681
|
+
*
|
|
2682
|
+
* @returns {Promise<Record<string, string[]> | null>} Map of option names to their distinct values or null if error occurs
|
|
2683
|
+
*
|
|
2684
|
+
* @throws {Error} When there's a network error or API failure
|
|
2685
|
+
*
|
|
2686
|
+
* @example
|
|
2687
|
+
* ```typescript
|
|
2688
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2689
|
+
* const filters = await shop.products.filter();
|
|
2690
|
+
*
|
|
2691
|
+
* console.log('Available filters:', filters);
|
|
2692
|
+
* // Output: { "Size": ["S", "M", "L", "XL"], "Color": ["Red", "Blue", "Green"] }
|
|
2693
|
+
*
|
|
2694
|
+
* // Use filters for UI components
|
|
2695
|
+
* Object.entries(filters || {}).forEach(([optionName, values]) => {
|
|
2696
|
+
* console.log(`${optionName}: ${values.join(', ')}`);
|
|
2697
|
+
* });
|
|
2698
|
+
* ```
|
|
2699
|
+
*/
|
|
2700
|
+
filter: async () => {
|
|
2701
|
+
try {
|
|
2702
|
+
const products = await operations.all();
|
|
2703
|
+
if (!products || products.length === 0) {
|
|
2704
|
+
return {};
|
|
2705
|
+
}
|
|
2706
|
+
const filterMap = {};
|
|
2707
|
+
products.forEach((product) => {
|
|
2708
|
+
if (product.variants && product.variants.length > 0) {
|
|
2709
|
+
if (product.options && product.options.length > 0) {
|
|
2710
|
+
product.options.forEach((option) => {
|
|
2711
|
+
const lowercaseOptionName = option.name.toLowerCase();
|
|
2712
|
+
if (!filterMap[lowercaseOptionName]) {
|
|
2713
|
+
filterMap[lowercaseOptionName] = /* @__PURE__ */ new Set();
|
|
2714
|
+
}
|
|
2715
|
+
option.values.forEach((value) => {
|
|
2716
|
+
const trimmed = value == null ? void 0 : value.trim();
|
|
2717
|
+
if (trimmed) {
|
|
2718
|
+
let set = filterMap[lowercaseOptionName];
|
|
2719
|
+
if (!set) {
|
|
2720
|
+
set = /* @__PURE__ */ new Set();
|
|
2721
|
+
filterMap[lowercaseOptionName] = set;
|
|
2722
|
+
}
|
|
2723
|
+
set.add(trimmed.toLowerCase());
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
product.variants.forEach((variant) => {
|
|
2729
|
+
var _a, _b, _c, _d, _e, _f;
|
|
2730
|
+
if (variant.option1) {
|
|
2731
|
+
const optionName = (((_b = (_a = product.options) == null ? void 0 : _a[0]) == null ? void 0 : _b.name) || "Option 1").toLowerCase();
|
|
2732
|
+
let set1 = filterMap[optionName];
|
|
2733
|
+
if (!set1) {
|
|
2734
|
+
set1 = /* @__PURE__ */ new Set();
|
|
2735
|
+
filterMap[optionName] = set1;
|
|
2736
|
+
}
|
|
2737
|
+
set1.add(variant.option1.trim().toLowerCase());
|
|
2738
|
+
}
|
|
2739
|
+
if (variant.option2) {
|
|
2740
|
+
const optionName = (((_d = (_c = product.options) == null ? void 0 : _c[1]) == null ? void 0 : _d.name) || "Option 2").toLowerCase();
|
|
2741
|
+
let set2 = filterMap[optionName];
|
|
2742
|
+
if (!set2) {
|
|
2743
|
+
set2 = /* @__PURE__ */ new Set();
|
|
2744
|
+
filterMap[optionName] = set2;
|
|
2745
|
+
}
|
|
2746
|
+
set2.add(variant.option2.trim().toLowerCase());
|
|
2747
|
+
}
|
|
2748
|
+
if (variant.option3) {
|
|
2749
|
+
const optionName = (((_f = (_e = product.options) == null ? void 0 : _e[2]) == null ? void 0 : _f.name) || "Option 3").toLowerCase();
|
|
2750
|
+
if (!filterMap[optionName]) {
|
|
2751
|
+
filterMap[optionName] = /* @__PURE__ */ new Set();
|
|
2752
|
+
}
|
|
2753
|
+
filterMap[optionName].add(variant.option3.trim().toLowerCase());
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
});
|
|
2758
|
+
const result = {};
|
|
2759
|
+
Object.entries(filterMap).forEach(([optionName, valueSet]) => {
|
|
2760
|
+
result[optionName] = Array.from(valueSet).sort();
|
|
2761
|
+
});
|
|
2762
|
+
return result;
|
|
2763
|
+
} catch (error) {
|
|
2764
|
+
console.error("Failed to create product filters:", storeDomain, error);
|
|
2765
|
+
throw error;
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
return operations;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// src/store.ts
|
|
2773
|
+
function createStoreOperations(context) {
|
|
2774
|
+
return {
|
|
2775
|
+
/**
|
|
2776
|
+
* Fetches comprehensive store information including metadata, social links, and showcase content.
|
|
2777
|
+
*
|
|
2778
|
+
* @returns {Promise<StoreInfo>} Store information object containing:
|
|
2779
|
+
* - `name` - Store name from meta tags or domain
|
|
2780
|
+
* - `domain` - Store domain URL
|
|
2781
|
+
* - `slug` - Generated store slug
|
|
2782
|
+
* - `title` - Store title from meta tags
|
|
2783
|
+
* - `description` - Store description from meta tags
|
|
2784
|
+
* - `logoUrl` - Store logo URL from Open Graph or CDN
|
|
2785
|
+
* - `socialLinks` - Object with social media links (facebook, twitter, instagram, etc.)
|
|
2786
|
+
* - `contactLinks` - Object with contact information (tel, email, contactPage)
|
|
2787
|
+
* - `headerLinks` - Array of navigation links from header
|
|
2788
|
+
* - `showcase` - Object with featured products and collections from homepage
|
|
2789
|
+
* - `jsonLdData` - Structured data from JSON-LD scripts
|
|
2790
|
+
* - `techProvider` - Shopify-specific information (walletId, subDomain)
|
|
2791
|
+
* - `country` - Country detection results with ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
|
|
2792
|
+
*
|
|
2793
|
+
* @throws {Error} When the store URL is unreachable or returns an error
|
|
2794
|
+
*
|
|
2795
|
+
* @example
|
|
2796
|
+
* ```typescript
|
|
2797
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2798
|
+
* const storeInfo = await shop.getInfo();
|
|
2799
|
+
*
|
|
2800
|
+
* console.log(storeInfo.name); // "Example Store"
|
|
2801
|
+
* console.log(storeInfo.socialLinks.instagram); // "https://instagram.com/example"
|
|
2802
|
+
* console.log(storeInfo.showcase.products); // ["product-handle-1", "product-handle-2"]
|
|
2803
|
+
* console.log(storeInfo.country); // "US"
|
|
2804
|
+
* ```
|
|
2805
|
+
*/
|
|
2806
|
+
info: async () => {
|
|
2807
|
+
try {
|
|
2808
|
+
const { info } = await getInfoForStore({
|
|
2809
|
+
baseUrl: context.baseUrl,
|
|
2810
|
+
storeDomain: context.storeDomain,
|
|
2811
|
+
validateProductExists: context.validateProductExists,
|
|
2812
|
+
validateCollectionExists: context.validateCollectionExists,
|
|
2813
|
+
validateLinksInBatches: context.validateLinksInBatches
|
|
2814
|
+
});
|
|
2815
|
+
return info;
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
context.handleFetchError(error, "fetching store info", context.baseUrl);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// src/index.ts
|
|
2824
|
+
var ShopClient = class {
|
|
2825
|
+
/**
|
|
2826
|
+
* Creates a new ShopClient instance for interacting with a Shopify store.
|
|
2827
|
+
*
|
|
2828
|
+
* @param urlPath - The Shopify store URL (e.g., 'https://exampleshop.com' or 'exampleshop.com')
|
|
2829
|
+
*
|
|
2830
|
+
* @throws {Error} When the URL is invalid or contains malicious patterns
|
|
2831
|
+
*
|
|
2832
|
+
* @example
|
|
2833
|
+
* ```typescript
|
|
2834
|
+
* // With full URL
|
|
2835
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
2836
|
+
*
|
|
2837
|
+
* // Without protocol (automatically adds https://)
|
|
2838
|
+
* const shop = new ShopClient('exampleshop.com');
|
|
2839
|
+
*
|
|
2840
|
+
* // Works with any Shopify store domain
|
|
2841
|
+
* const shop1 = new ShopClient('https://example.myshopify.com');
|
|
2842
|
+
* const shop2 = new ShopClient('https://boutique.fashion');
|
|
2843
|
+
* ```
|
|
2844
|
+
*/
|
|
2845
|
+
constructor(urlPath) {
|
|
2846
|
+
this.validationCache = /* @__PURE__ */ new Map();
|
|
2847
|
+
// Simple cache for validation results
|
|
2848
|
+
this.cacheExpiry = 5 * 60 * 1e3;
|
|
2849
|
+
// 5 minutes cache expiry
|
|
2850
|
+
this.cacheTimestamps = /* @__PURE__ */ new Map();
|
|
2851
|
+
this.normalizeImageUrlCache = /* @__PURE__ */ new Map();
|
|
2852
|
+
if (!urlPath || typeof urlPath !== "string") {
|
|
2853
|
+
throw new Error("Store URL is required and must be a string");
|
|
2854
|
+
}
|
|
2855
|
+
let normalizedUrl = urlPath.trim();
|
|
2856
|
+
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
|
2857
|
+
normalizedUrl = `https://${normalizedUrl}`;
|
|
2858
|
+
}
|
|
2859
|
+
let storeUrl;
|
|
2860
|
+
try {
|
|
2861
|
+
storeUrl = new URL(normalizedUrl);
|
|
2862
|
+
} catch (_error) {
|
|
2863
|
+
throw new Error("Invalid store URL format");
|
|
2864
|
+
}
|
|
2865
|
+
const hostname = storeUrl.hostname;
|
|
2866
|
+
if (!hostname || hostname.length < 3) {
|
|
2867
|
+
throw new Error("Invalid domain name");
|
|
2868
|
+
}
|
|
2869
|
+
if (hostname.includes("..") || hostname.includes("//") || hostname.includes("@")) {
|
|
2870
|
+
throw new Error("Invalid characters in domain name");
|
|
2871
|
+
}
|
|
2872
|
+
if (!hostname.includes(".") || hostname.startsWith(".") || hostname.endsWith(".")) {
|
|
2873
|
+
throw new Error(
|
|
2874
|
+
"Invalid domain format - must be a valid domain with TLD"
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
2878
|
+
if (!domainPattern.test(hostname)) {
|
|
2879
|
+
throw new Error("Invalid domain format");
|
|
2880
|
+
}
|
|
2881
|
+
this.storeDomain = `https://${hostname}`;
|
|
2882
|
+
let fetchUrl = `https://${hostname}${storeUrl.pathname}`;
|
|
2883
|
+
if (!fetchUrl.endsWith("/")) {
|
|
2884
|
+
fetchUrl = `${fetchUrl}/`;
|
|
2885
|
+
}
|
|
2886
|
+
this.baseUrl = fetchUrl;
|
|
2887
|
+
this.storeSlug = generateStoreSlug(this.storeDomain);
|
|
2888
|
+
this.storeOperations = createStoreOperations({
|
|
2889
|
+
baseUrl: this.baseUrl,
|
|
2890
|
+
storeDomain: this.storeDomain,
|
|
2891
|
+
validateProductExists: this.validateProductExists.bind(this),
|
|
2892
|
+
validateCollectionExists: this.validateCollectionExists.bind(this),
|
|
2893
|
+
validateLinksInBatches: this.validateLinksInBatches.bind(this),
|
|
2894
|
+
handleFetchError: this.handleFetchError.bind(this)
|
|
2895
|
+
});
|
|
2896
|
+
this.products = createProductOperations(
|
|
2897
|
+
this.baseUrl,
|
|
2898
|
+
this.storeDomain,
|
|
2899
|
+
this.fetchProducts.bind(this),
|
|
2900
|
+
this.productsDto.bind(this),
|
|
2901
|
+
this.productDto.bind(this),
|
|
2902
|
+
() => this.getInfo(),
|
|
2903
|
+
(handle) => this.products.find(handle)
|
|
2904
|
+
);
|
|
2905
|
+
this.collections = createCollectionOperations(
|
|
2906
|
+
this.baseUrl,
|
|
2907
|
+
this.storeDomain,
|
|
2908
|
+
this.fetchCollections.bind(this),
|
|
2909
|
+
this.collectionsDto.bind(this),
|
|
2910
|
+
this.fetchPaginatedProductsFromCollection.bind(this),
|
|
2911
|
+
() => this.getInfo(),
|
|
2912
|
+
(handle) => this.collections.find(handle)
|
|
2913
|
+
);
|
|
2914
|
+
this.checkout = createCheckoutOperations(this.baseUrl);
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Optimized image URL normalization with caching
|
|
2918
|
+
*/
|
|
2919
|
+
normalizeImageUrl(url) {
|
|
2920
|
+
if (!url) {
|
|
2921
|
+
return "";
|
|
2922
|
+
}
|
|
2923
|
+
if (this.normalizeImageUrlCache.has(url)) {
|
|
2924
|
+
return this.normalizeImageUrlCache.get(url);
|
|
2925
|
+
}
|
|
2926
|
+
const normalized = url.startsWith("//") ? `https:${url}` : url;
|
|
2927
|
+
this.normalizeImageUrlCache.set(url, normalized);
|
|
2928
|
+
return normalized;
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Format a price amount (in cents) using the store currency.
|
|
2932
|
+
*/
|
|
2933
|
+
formatPrice(amountInCents) {
|
|
2934
|
+
var _a;
|
|
2935
|
+
const currency = (_a = this.storeCurrency) != null ? _a : "USD";
|
|
2936
|
+
try {
|
|
2937
|
+
return new Intl.NumberFormat(void 0, {
|
|
2938
|
+
style: "currency",
|
|
2939
|
+
currency
|
|
2940
|
+
}).format((amountInCents || 0) / 100);
|
|
2941
|
+
} catch {
|
|
2942
|
+
const val = (amountInCents || 0) / 100;
|
|
2943
|
+
return `${val} ${currency}`;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Transform Shopify products to our Product format
|
|
2948
|
+
*/
|
|
2949
|
+
productsDto(products) {
|
|
2950
|
+
var _a;
|
|
2951
|
+
return mapProductsDto(products, {
|
|
2952
|
+
storeDomain: this.storeDomain,
|
|
2953
|
+
storeSlug: this.storeSlug,
|
|
2954
|
+
currency: (_a = this.storeCurrency) != null ? _a : "USD",
|
|
2955
|
+
normalizeImageUrl: (url) => this.normalizeImageUrl(url),
|
|
2956
|
+
formatPrice: (amount) => this.formatPrice(amount)
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
productDto(product) {
|
|
2960
|
+
var _a;
|
|
2961
|
+
return mapProductDto(product, {
|
|
2962
|
+
storeDomain: this.storeDomain,
|
|
2963
|
+
storeSlug: this.storeSlug,
|
|
2964
|
+
currency: (_a = this.storeCurrency) != null ? _a : "USD",
|
|
2965
|
+
normalizeImageUrl: (url) => this.normalizeImageUrl(url),
|
|
2966
|
+
formatPrice: (amount) => this.formatPrice(amount)
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
collectionsDto(collections) {
|
|
2970
|
+
var _a;
|
|
2971
|
+
return (_a = collectionsDto(collections)) != null ? _a : [];
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Enhanced error handling with context
|
|
2975
|
+
*/
|
|
2976
|
+
handleFetchError(error, context, url) {
|
|
2977
|
+
let errorMessage = `Error ${context}`;
|
|
2978
|
+
let statusCode;
|
|
2979
|
+
if (error instanceof Error) {
|
|
2980
|
+
errorMessage += `: ${error.message}`;
|
|
2981
|
+
if ("status" in error) {
|
|
2982
|
+
statusCode = error.status;
|
|
2983
|
+
}
|
|
2984
|
+
} else if (typeof error === "string") {
|
|
2985
|
+
errorMessage += `: ${error}`;
|
|
2986
|
+
} else {
|
|
2987
|
+
errorMessage += ": Unknown error occurred";
|
|
2988
|
+
}
|
|
2989
|
+
errorMessage += ` (URL: ${url})`;
|
|
2990
|
+
if (statusCode) {
|
|
2991
|
+
errorMessage += ` (Status: ${statusCode})`;
|
|
2992
|
+
}
|
|
2993
|
+
const enhancedError = new Error(errorMessage);
|
|
2994
|
+
enhancedError.context = context;
|
|
2995
|
+
enhancedError.url = url;
|
|
2996
|
+
enhancedError.statusCode = statusCode;
|
|
2997
|
+
enhancedError.originalError = error;
|
|
2998
|
+
throw enhancedError;
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Fetch products with pagination
|
|
3002
|
+
*/
|
|
3003
|
+
async fetchProducts(page, limit) {
|
|
3004
|
+
try {
|
|
3005
|
+
const url = `${this.baseUrl}products.json?page=${page}&limit=${limit}`;
|
|
3006
|
+
const response = await rateLimitedFetch(url);
|
|
3007
|
+
if (!response.ok) {
|
|
3008
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
3009
|
+
}
|
|
3010
|
+
const data = await response.json();
|
|
3011
|
+
return this.productsDto(data.products);
|
|
3012
|
+
} catch (error) {
|
|
3013
|
+
this.handleFetchError(
|
|
3014
|
+
error,
|
|
3015
|
+
"fetching products",
|
|
3016
|
+
`${this.baseUrl}products.json`
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Fetch collections with pagination
|
|
3022
|
+
*/
|
|
3023
|
+
async fetchCollections(page, limit) {
|
|
3024
|
+
try {
|
|
3025
|
+
const url = `${this.baseUrl}collections.json?page=${page}&limit=${limit}`;
|
|
3026
|
+
const response = await rateLimitedFetch(url);
|
|
3027
|
+
if (!response.ok) {
|
|
3028
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
3029
|
+
}
|
|
3030
|
+
const data = await response.json();
|
|
3031
|
+
return this.collectionsDto(data.collections);
|
|
3032
|
+
} catch (error) {
|
|
3033
|
+
this.handleFetchError(
|
|
3034
|
+
error,
|
|
3035
|
+
"fetching collections",
|
|
3036
|
+
`${this.baseUrl}collections.json`
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
3041
|
+
* Fetch paginated products from a specific collection
|
|
3042
|
+
*/
|
|
3043
|
+
async fetchPaginatedProductsFromCollection(collectionHandle, options = {}) {
|
|
3044
|
+
try {
|
|
3045
|
+
const { page = 1, limit = 250 } = options;
|
|
3046
|
+
let finalHandle = collectionHandle;
|
|
3047
|
+
try {
|
|
3048
|
+
const htmlResp = await rateLimitedFetch(
|
|
3049
|
+
`${this.baseUrl}collections/${encodeURIComponent(collectionHandle)}`
|
|
3050
|
+
);
|
|
3051
|
+
if (htmlResp.ok) {
|
|
3052
|
+
const finalUrl = htmlResp.url;
|
|
3053
|
+
if (finalUrl) {
|
|
3054
|
+
const pathname = new URL(finalUrl).pathname.replace(/\/$/, "");
|
|
3055
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
3056
|
+
const idx = parts.indexOf("collections");
|
|
3057
|
+
const maybeHandle = idx >= 0 ? parts[idx + 1] : void 0;
|
|
3058
|
+
if (typeof maybeHandle === "string" && maybeHandle.length) {
|
|
3059
|
+
finalHandle = maybeHandle;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
} catch {
|
|
3064
|
+
}
|
|
3065
|
+
const url = `${this.baseUrl}collections/${finalHandle}/products.json?page=${page}&limit=${limit}`;
|
|
3066
|
+
const response = await rateLimitedFetch(url);
|
|
3067
|
+
if (!response.ok) {
|
|
3068
|
+
if (response.status === 404) {
|
|
3069
|
+
return null;
|
|
3070
|
+
}
|
|
3071
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
3072
|
+
}
|
|
3073
|
+
const data = await response.json();
|
|
3074
|
+
return this.productsDto(data.products);
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
this.handleFetchError(
|
|
3077
|
+
error,
|
|
3078
|
+
"fetching products from collection",
|
|
3079
|
+
`${this.baseUrl}collections/${collectionHandle}/products.json`
|
|
3080
|
+
);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Validate if a product exists (with caching)
|
|
3085
|
+
*/
|
|
3086
|
+
async validateProductExists(handle) {
|
|
3087
|
+
const cacheKey = `product:${handle}`;
|
|
3088
|
+
if (this.isCacheValid(cacheKey)) {
|
|
3089
|
+
return this.validationCache.get(cacheKey) || false;
|
|
3090
|
+
}
|
|
3091
|
+
try {
|
|
3092
|
+
const url = `${this.baseUrl}products/${handle}.js`;
|
|
3093
|
+
const response = await rateLimitedFetch(url, { method: "HEAD" });
|
|
3094
|
+
const exists = response.ok;
|
|
3095
|
+
this.setCacheValue(cacheKey, exists);
|
|
3096
|
+
return exists;
|
|
3097
|
+
} catch (_error) {
|
|
3098
|
+
this.setCacheValue(cacheKey, false);
|
|
3099
|
+
return false;
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Validate if a collection exists (with caching)
|
|
3104
|
+
*/
|
|
3105
|
+
async validateCollectionExists(handle) {
|
|
3106
|
+
const cacheKey = `collection:${handle}`;
|
|
3107
|
+
if (this.isCacheValid(cacheKey)) {
|
|
3108
|
+
return this.validationCache.get(cacheKey) || false;
|
|
3109
|
+
}
|
|
3110
|
+
try {
|
|
3111
|
+
const url = `${this.baseUrl}collections/${handle}.json`;
|
|
3112
|
+
const response = await rateLimitedFetch(url, { method: "HEAD" });
|
|
3113
|
+
const exists = response.ok;
|
|
3114
|
+
this.setCacheValue(cacheKey, exists);
|
|
3115
|
+
return exists;
|
|
3116
|
+
} catch (_error) {
|
|
3117
|
+
this.setCacheValue(cacheKey, false);
|
|
3118
|
+
return false;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* Check if cache entry is still valid
|
|
3123
|
+
*/
|
|
3124
|
+
isCacheValid(key) {
|
|
3125
|
+
const timestamp = this.cacheTimestamps.get(key);
|
|
3126
|
+
if (!timestamp) return false;
|
|
3127
|
+
return Date.now() - timestamp < this.cacheExpiry;
|
|
3128
|
+
}
|
|
3129
|
+
/**
|
|
3130
|
+
* Set cache value with timestamp
|
|
3131
|
+
*/
|
|
3132
|
+
setCacheValue(key, value) {
|
|
3133
|
+
this.validationCache.set(key, value);
|
|
3134
|
+
this.cacheTimestamps.set(key, Date.now());
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Validate links in batches to avoid overwhelming the server
|
|
3138
|
+
*/
|
|
3139
|
+
async validateLinksInBatches(items, validator, batchSize = 10) {
|
|
3140
|
+
const validItems = [];
|
|
3141
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
3142
|
+
const batch = items.slice(i, i + batchSize);
|
|
3143
|
+
const validationPromises = batch.map(async (item) => {
|
|
3144
|
+
const isValid = await validator(item);
|
|
3145
|
+
return isValid ? item : null;
|
|
3146
|
+
});
|
|
3147
|
+
const results = await Promise.all(validationPromises);
|
|
3148
|
+
const validBatchItems = results.filter(
|
|
3149
|
+
(item) => item !== null
|
|
3150
|
+
);
|
|
3151
|
+
validItems.push(...validBatchItems);
|
|
3152
|
+
if (i + batchSize < items.length) {
|
|
3153
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
return validItems;
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Fetches comprehensive store information including metadata, social links, and showcase content.
|
|
3160
|
+
*
|
|
3161
|
+
* @returns {Promise<StoreInfo>} Store information object containing:
|
|
3162
|
+
* - `name` - Store name from meta tags or domain
|
|
3163
|
+
* - `domain` - Store domain URL
|
|
3164
|
+
* - `slug` - Generated store slug
|
|
3165
|
+
* - `title` - Store title from meta tags
|
|
3166
|
+
* - `description` - Store description from meta tags
|
|
3167
|
+
* - `logoUrl` - Store logo URL from Open Graph or CDN
|
|
3168
|
+
* - `socialLinks` - Object with social media links (facebook, twitter, instagram, etc.)
|
|
3169
|
+
* - `contactLinks` - Object with contact information (tel, email, contactPage)
|
|
3170
|
+
* - `headerLinks` - Array of navigation links from header
|
|
3171
|
+
* - `showcase` - Object with featured products and collections from homepage
|
|
3172
|
+
* - `jsonLdData` - Structured data from JSON-LD scripts
|
|
3173
|
+
* - `techProvider` - Shopify-specific information (walletId, subDomain)
|
|
3174
|
+
* - `country` - Country detection results with ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
|
|
3175
|
+
*
|
|
3176
|
+
* @throws {Error} When the store URL is unreachable or returns an error
|
|
3177
|
+
*
|
|
3178
|
+
* @example
|
|
3179
|
+
* ```typescript
|
|
3180
|
+
* const shop = new ShopClient('https://exampleshop.com');
|
|
3181
|
+
* const storeInfo = await shop.getInfo();
|
|
3182
|
+
*
|
|
3183
|
+
* console.log(storeInfo.name); // "Example Store"
|
|
3184
|
+
* console.log(storeInfo.socialLinks.instagram); // "https://instagram.com/example"
|
|
3185
|
+
* console.log(storeInfo.showcase.products); // ["product-handle-1", "product-handle-2"]
|
|
3186
|
+
* console.log(storeInfo.country); // "US"
|
|
3187
|
+
* ```
|
|
3188
|
+
*/
|
|
3189
|
+
async getInfo() {
|
|
3190
|
+
try {
|
|
3191
|
+
const { info, currencyCode } = await getInfoForStore({
|
|
3192
|
+
baseUrl: this.baseUrl,
|
|
3193
|
+
storeDomain: this.storeDomain,
|
|
3194
|
+
validateProductExists: (handle) => this.validateProductExists(handle),
|
|
3195
|
+
validateCollectionExists: (handle) => this.validateCollectionExists(handle),
|
|
3196
|
+
validateLinksInBatches: (items, validator, batchSize) => this.validateLinksInBatches(items, validator, batchSize)
|
|
3197
|
+
});
|
|
3198
|
+
if (typeof currencyCode === "string") {
|
|
3199
|
+
this.storeCurrency = currencyCode;
|
|
3200
|
+
}
|
|
3201
|
+
return info;
|
|
3202
|
+
} catch (error) {
|
|
3203
|
+
this.handleFetchError(error, "fetching store info", this.baseUrl);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Determine the store's primary vertical and target audience.
|
|
3208
|
+
* Uses `getInfo()` internally; no input required.
|
|
3209
|
+
*/
|
|
3210
|
+
async determineStoreType(options) {
|
|
3211
|
+
try {
|
|
3212
|
+
const breakdown = await determineStoreTypeForStore({
|
|
3213
|
+
baseUrl: this.baseUrl,
|
|
3214
|
+
getInfo: () => this.getInfo(),
|
|
3215
|
+
findProduct: (handle) => this.products.find(handle),
|
|
3216
|
+
apiKey: options == null ? void 0 : options.apiKey,
|
|
3217
|
+
model: options == null ? void 0 : options.model,
|
|
3218
|
+
maxShowcaseProducts: options == null ? void 0 : options.maxShowcaseProducts,
|
|
3219
|
+
maxShowcaseCollections: options == null ? void 0 : options.maxShowcaseCollections
|
|
3220
|
+
});
|
|
3221
|
+
return breakdown;
|
|
3222
|
+
} catch (error) {
|
|
3223
|
+
throw this.handleFetchError(error, "determineStoreType", this.baseUrl);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
};
|
|
3227
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3228
|
+
0 && (module.exports = {
|
|
3229
|
+
ShopClient,
|
|
3230
|
+
calculateDiscount,
|
|
3231
|
+
classifyProduct,
|
|
3232
|
+
configureRateLimit,
|
|
3233
|
+
detectShopifyCountry,
|
|
3234
|
+
extractDomainWithoutSuffix,
|
|
3235
|
+
genProductSlug,
|
|
3236
|
+
generateSEOContent,
|
|
3237
|
+
generateStoreSlug,
|
|
3238
|
+
safeParseDate,
|
|
3239
|
+
sanitizeDomain
|
|
3240
|
+
});
|
|
3241
|
+
//# sourceMappingURL=index.js.map
|