11import type { ProductSet } from '@cloudcommerce/api/types' ;
2+ import { extname } from 'node:path' ;
23import {
34 argv ,
45 fs ,
56 echo ,
67 sleep ,
78} from 'zx' ;
89import { XMLParser } from 'fast-xml-parser' ;
10+ import { parse } from 'csv-parse/sync' ;
911import { imageSize } from 'image-size' ;
1012import api from '@cloudcommerce/api' ;
1113
@@ -21,13 +23,24 @@ const slugify = (name: string) => {
2123} ;
2224
2325const 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+
83189const 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 - z 0 - 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 ) ;
0 commit comments