Skip to content

Commit 39fde63

Browse files
committed
chore(cli): Supporting cloudcommerce import with .tsv (separated by tabs) tables
Typed table item schema, common pt-br fields
1 parent 55888bd commit 39fde63

File tree

3 files changed

+167
-40
lines changed

3 files changed

+167
-40
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dependencies": {
3636
"@cloudcommerce/api": "workspace:*",
3737
"@fastify/deepmerge": "^3.1.0",
38+
"csv-parse": "^5.6.0",
3839
"dotenv": "^16.5.0",
3940
"fast-xml-parser": "^5.2.3",
4041
"image-size": "^2.0.2",

packages/cli/src/ext/import-feed.ts

Lines changed: 158 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { ProductSet } from '@cloudcommerce/api/types';
2+
import { extname } from 'node:path';
23
import {
34
argv,
45
fs,
56
echo,
67
sleep,
78
} from 'zx';
89
import { XMLParser } from 'fast-xml-parser';
10+
import { parse } from 'csv-parse/sync';
911
import { imageSize } from 'image-size';
1012
import api from '@cloudcommerce/api';
1113

@@ -21,13 +23,24 @@ const slugify = (name: string) => {
2123
};
2224

2325
const uploadPicture = async (downloadUrl: string, authHeaders: Record<string, any>) => {
24-
const downloadRes = await fetch(downloadUrl);
26+
const downloadRes = await fetch(downloadUrl, {
27+
method: 'GET',
28+
redirect: 'follow',
29+
headers: {
30+
'User-Agent': 'Mozilla/5.0 (compatible; E-commerce Bot/1.0)',
31+
},
32+
});
2533
if (!downloadRes.ok) {
26-
throw new Error(`Failed downloading ${downloadUrl} with status ${downloadRes.status}`);
34+
const msg = `Failed downloading ${downloadUrl} with status ${downloadRes.status}`;
35+
const err = new Error(msg) as any;
36+
err.statusCode = downloadRes.status || 0;
37+
throw err;
2738
}
2839
const contentType = downloadRes.headers.get('content-type');
2940
if (!contentType) {
30-
throw new Error(`No mime type returned for ${downloadUrl}`);
41+
const err = new Error(`No mime type returned for ${downloadUrl}`) as any;
42+
err.statusCode = 0;
43+
throw err;
3144
}
3245
const imageBuffer = await downloadRes.arrayBuffer();
3346
const imageBlob = new Blob([imageBuffer], { type: contentType });
@@ -80,50 +93,143 @@ const uploadPicture = async (downloadUrl: string, authHeaders: Record<string, an
8093
return picture;
8194
};
8295

96+
type FeedItem = {
97+
'g:id': string,
98+
'g:title': string,
99+
'g:description'?: string,
100+
'g:link'?: `https://www/${string}?${string}`,
101+
'g:image_link'?: string,
102+
'g:condition'?: 'new' | 'refurbished' | 'used',
103+
'g:shipping_weight'?: `${string} g` | `${string} kg`,
104+
'g:additional_image_link'?: string[],
105+
'g:product_type'?: string,
106+
'g:availability'?: 'in stock' | 'out of stock' | 'preorder' | 'backorder',
107+
'g:price'?: `${string} BRL`,
108+
'g:sale_price'?: `${string} BRL`,
109+
'g:brand'?: string,
110+
'g:item_group_id'?: string,
111+
'g:color'?: string,
112+
'g:gender'?: 'male' | 'female' | 'unisex',
113+
'g:material'?: string,
114+
'g:pattern'?: string,
115+
'g:size'?: string,
116+
'g:size_system'?: string,
117+
'g:gtin'?: string,
118+
'g:mpn'?: string,
119+
'available'?: boolean,
120+
'visible'?: boolean,
121+
'quantity'?: number,
122+
};
123+
type CSVItem = {
124+
'sku': string,
125+
'tipo': string,
126+
'ativo': string,
127+
'usado': string,
128+
'destaque': string,
129+
'ncm': string,
130+
'gtin': string,
131+
'mpn': string,
132+
'nome': string,
133+
'seo-tag-title': string,
134+
'seo-tag-description': string,
135+
'descricao-completa': string,
136+
'estoque-gerenciado': string,
137+
'estoque-quantidade': string,
138+
'preco-cheio': string,
139+
'preco-promocional': string,
140+
'marca': string,
141+
'peso-em-kg': string,
142+
'altura-em-cm': string,
143+
'largura-em-cm': string,
144+
'comprimento-em-cm': string,
145+
'categoria-nome-nivel-1': string,
146+
'imagem-1': string,
147+
'imagem-2': string,
148+
'imagem-3': string,
149+
'imagem-4': string,
150+
'imagem-5': string,
151+
};
152+
const mapCSVToFeed = (item: CSVItem): FeedItem => {
153+
const basePrice = parseFloat(item['preco-cheio']?.replace(',', '.')) || 0;
154+
const salePrice = parseFloat(item['preco-promocional']?.replace(',', '.')) || 0;
155+
const isAvailable = item.ativo === 'S';
156+
const quantity = Number(item['estoque-quantidade']) || 0;
157+
return {
158+
'g:id': item.sku,
159+
'g:title': item.nome,
160+
'g:description': item['descricao-completa'] || item['seo-tag-description'],
161+
'g:image_link': item['imagem-1'],
162+
'g:condition': item.usado === 'S' ? 'used' as const : 'new' as const,
163+
'g:shipping_weight': item['peso-em-kg']
164+
? `${item['peso-em-kg']?.replace(',', '.')} kg` as const
165+
: undefined,
166+
'g:additional_image_link': [
167+
item['imagem-2'],
168+
item['imagem-3'],
169+
item['imagem-4'],
170+
item['imagem-5'],
171+
].filter(Boolean),
172+
'g:product_type': item['categoria-nome-nivel-1'],
173+
'g:availability': (isAvailable && quantity > 0)
174+
? 'in stock' as const
175+
: 'out of stock' as const,
176+
'g:price': `${(basePrice || salePrice)} BRL` as const,
177+
'g:sale_price': (salePrice > 0 && salePrice < basePrice)
178+
? `${salePrice} BRL` as const
179+
: undefined,
180+
'g:brand': item.marca,
181+
'g:gtin': item.gtin,
182+
'g:mpn': item.mpn,
183+
'available': isAvailable,
184+
'visible': isAvailable,
185+
'quantity': quantity,
186+
};
187+
};
188+
83189
const importFeed = async () => {
190+
const {
191+
ECOM_STORE_ID,
192+
ECOM_AUTHENTICATION_ID,
193+
ECOM_API_KEY,
194+
ECOM_ACCESS_TOKEN,
195+
} = process.env;
84196
const feedFilepath = typeof argv.feed === 'string' ? argv.feed : '';
85197
if (!feedFilepath) {
86198
await echo`You must specify XML file to import with --feed= argument`;
87199
return process.exit(1);
88200
}
89-
const parser = new XMLParser({
90-
ignoreAttributes: false,
91-
attributeNamePrefix: '',
92-
});
93-
const json = parser.parse(fs.readFileSync(argv.feed, 'utf8'));
94-
const items: Array<{
95-
'g:id': string,
96-
'g:title': string,
97-
'g:description'?: string,
98-
'g:link'?: `https://www.ladofit.com.br/${string}?${string}`,
99-
'g:image_link'?: string,
100-
'g:condition'?: 'new' | 'refurbished' | 'used',
101-
'g:shipping_weight'?: `${string} g` | `${string} kg`,
102-
'g:additional_image_link'?: string[],
103-
'g:product_type'?: string,
104-
'g:availability'?: 'in stock' | 'out of stock' | 'preorder' | 'backorder',
105-
'g:price'?: `${string} BRL`,
106-
'g:sale_price'?: `${string} BRL`,
107-
'g:brand'?: string,
108-
'g:item_group_id'?: string,
109-
'g:color'?: string,
110-
'g:gender'?: 'male' | 'female' | 'unisex',
111-
'g:material'?: string,
112-
'g:pattern'?: string,
113-
'g:size'?: string,
114-
'g:size_system'?: string,
115-
}> = json.rss?.channel?.item?.filter?.((item: any) => {
116-
return item?.['g:id'] && item['g:title'];
117-
});
201+
const fileExtension = extname(feedFilepath).toLowerCase();
202+
await echo`Importing ${fileExtension} file at ${feedFilepath}`;
203+
await echo`Store ${ECOM_STORE_ID} with ${ECOM_AUTHENTICATION_ID}`;
204+
let items: Array<FeedItem> = [];
205+
if (fileExtension === '.xml') {
206+
const parser = new XMLParser({
207+
ignoreAttributes: false,
208+
attributeNamePrefix: '',
209+
});
210+
const json = parser.parse(fs.readFileSync(argv.feed, 'utf8'));
211+
items = json.rss?.channel?.item?.filter?.((item: any) => {
212+
return item?.['g:id'] && item['g:title'];
213+
});
214+
} else if (fileExtension === '.tsv') {
215+
const csvContent = fs.readFileSync(feedFilepath, 'utf8');
216+
const csvRecords: CSVItem[] = parse(csvContent, {
217+
columns: true,
218+
skip_empty_lines: true,
219+
delimiter: '\t',
220+
});
221+
items = csvRecords
222+
.filter((record) => record.sku && record.nome)
223+
.map(mapCSVToFeed);
224+
}
118225
if (!items?.[0] || typeof items?.[0] !== 'object') {
119226
await echo`The XML file does not appear to be a valid RSS 2.0 product feed`;
120227
return process.exit(1);
121228
}
122229
const ecomAuthHeaders: Record<string, any> = {
123-
'X-Store-ID': process.env.ECOM_STORE_ID,
124-
'X-My-ID': process.env.ECOM_AUTHENTICATION_ID,
230+
'X-Store-ID': ECOM_STORE_ID,
231+
'X-My-ID': ECOM_AUTHENTICATION_ID,
125232
};
126-
const { ECOM_ACCESS_TOKEN, ECOM_API_KEY } = process.env;
127233
if (ECOM_ACCESS_TOKEN) {
128234
ecomAuthHeaders['X-Access-Token'] = ECOM_ACCESS_TOKEN;
129235
} else {
@@ -237,7 +343,11 @@ const importFeed = async () => {
237343
productOrVatiation: ProductVariation | ProductSet,
238344
item: (typeof items)[0],
239345
) => {
240-
productOrVatiation.quantity = item['g:availability'] === 'in stock' ? 100 : 0;
346+
if (typeof item.quantity === 'number') {
347+
productOrVatiation.quantity = item.quantity;
348+
} else {
349+
productOrVatiation.quantity = item['g:availability'] === 'in stock' ? 100 : 0;
350+
}
241351
const price = parseFloat(item['g:price'] || item['g:sale_price'] || '');
242352
if (price) {
243353
const salePrice = parseFloat(item['g:sale_price'] || '');
@@ -308,13 +418,20 @@ const importFeed = async () => {
308418
await echo`\n${new Date().toISOString()}`;
309419
await echo`${(productItems.length - i)} products to import\n`;
310420
await echo`SKU: ${sku}`;
421+
let slug = item['g:link']
422+
? new URL(item['g:link']).pathname.substring(1).toLowerCase()
423+
: slugify(name);
424+
if (!/[a-z0-9]/.test(slug.charAt(0))) {
425+
slug = `r${slug}`;
426+
}
311427
const product: ProductSet = {
312428
sku,
313429
name,
314-
slug: item['g:link']
315-
? new URL(item['g:link']).pathname.substring(1).toLowerCase()
316-
: slugify(name),
430+
slug,
317431
condition: item['g:condition'],
432+
available: item.available,
433+
visible: item.visible,
434+
quantity: item.quantity,
318435
};
319436
const description = item['g:description']?.trim();
320437
if (description && description !== name) {
@@ -376,7 +493,8 @@ const importFeed = async () => {
376493
// eslint-disable-next-line no-console
377494
console.error(err);
378495
if (err.statusCode < 500) {
379-
throw err;
496+
retries = 4;
497+
break;
380498
}
381499
retries += 1;
382500
await sleep(4000);

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)