Setting the file. One moment.
Quick Mode · Rp Quick Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page — line 40
This file
Number 39.1
Position 1 of 2
Type JavaScript
Size 19 KB
Lines 168 scripts/ quick-mode.js
JavaScript · 168 lines · 19 KB
=
path.
resolve
(__dirname,
'..'
);
10 const CONTRACT = require (path. join ( ADAPTER_DIR , 'quick-mode.json' ));
11 const { buildProduct , toWixSlug } = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-build.js' ));
12 const recovery = require (path. join ( ADAPTER_DIR , '..' , 'rp-quick-runtime' , 'lib' , 'limit-recovery.js' ));
13
14 const PRODUCT_LIMIT = 250 ;
15 const MAX_DESCRIPTION_LENGTH = 16000 ;
16 // Collection membership can require many public requests. Stay below the conservative
17 // storefront throttle instead of making parallel requests or treating a transient 429 as data.
18 const SOURCE_REQUEST_DELAY_MS = 750 ;
19 const SOURCE_MAX_RETRIES = 4 ;
20 const hash = ( value ) => crypto. createHash ( 'sha256' ). update ( JSON . stringify (value)). digest ( 'hex' );
21 const readJson = async ( file ) => JSON . parse ( await fs. readFile (file, 'utf8' ));
22 async function writeJson ( file , value ) { await fs. mkdir (path. dirname (file), { recursive: true }); await fs. writeFile (file, `${ JSON . stringify ( value , null , 2 ) } \n ` ); }
23 async function writeNdjson ( file , rows ) { await fs. mkdir (path. dirname (file), { recursive: true }); await fs. writeFile (file, rows. map (( row ) => JSON . stringify (row)). join ( ' \n ' ) + (rows. length ? ' \n ' : '' )); }
24 async function readNdjson ( file ) { const raw = await fs. readFile (file, 'utf8' ); return raw. split ( / \r ? \n / ). filter (Boolean). map ( JSON .parse); }
25 function sourceOrigin ( value ) { const url = new URL (value); if ( ! [ 'https:' , 'http:' ]. includes (url.protocol)) throw new Error ( 'sourceUrl must use http or https' ); return url.origin; }
26 function text ( value ) { return String (value == null ? '' : value). trim (); }
27 function validPrice ( value ) { const amount = Number (value); return Number. isFinite (amount) && amount >= 0 ; }
28 function file ( projectDir , name ) { return path. join (projectDir, 'data' , 'source-extract' , `${ name }.ndjson` ); }
29 const sleep = ( milliseconds ) => new Promise (( resolve ) => setTimeout (resolve, milliseconds));
30
31 async function context ( projectDir ) {
32 const decisions = await readJson (path. join (projectDir, 'orchestration' , 'decisions.json' ));
33 const sourceUrl = decisions.sourceUrl && decisions.sourceUrl.value;
34 const platform = decisions.sourcePlatform && decisions.sourcePlatform.value;
35 if ( ! sourceUrl) throw new Error ( 'quick mode requires orchestration.decisions.sourceUrl' );
36 if (platform !== 'shopify' ) throw new Error ( `quick Shopify adapter requires sourcePlatform=shopify (got ${ platform || 'missing'})` );
37 return { sourceUrl: sourceOrigin (sourceUrl) };
38 }
39
40 async function fetchPage ( origin , pathname , page ) {
41 const url = new URL (pathname, `${ origin }/` );
42 url.searchParams. set ( 'limit' , String ( PRODUCT_LIMIT ));
43 url.searchParams. set ( 'page' , String (page));
44 for ( let attempt = 0 ; attempt <= SOURCE_MAX_RETRIES ; attempt += 1 ) {
45 const response = await fetch (url, { headers: { accept: 'application/json' } });
46 const bodyText = await response. text ();
47 let body = null ; try { body = bodyText ? JSON . parse (bodyText) : null ; } catch { /* shape checked below */ }
48 if (response.status !== 429 || attempt === SOURCE_MAX_RETRIES ) return { ok: response.ok, status: response.status, url: url. toString (), body, attempts: attempt + 1 };
49 const retryAfter = Number (response.headers. get ( 'retry-after' ));
50 await sleep (Number. isFinite (retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * (attempt + 1 ));
51 }
52 throw new Error ( 'unreachable retry state' );
53 }
54 async function fetchCollection ( origin , pathname , key ) {
55 const records = []; const pages = [];
56 for ( let page = 1 ; ; page += 1 ) {
57 const result = await fetchPage (origin, pathname, page);
58 const rows = result.body && result.body[key];
59 if ( ! result.ok || ! Array. isArray (rows)) return { ok: false , status: result.status, url: result.url, responseShape: Array. isArray (rows) ? 'array' : typeof rows, records, pages };
60 records. push ( ... rows); pages. push ({ page, count: rows. length , url: result.url });
61 await sleep ( SOURCE_REQUEST_DELAY_MS );
62 if (rows. length < PRODUCT_LIMIT ) break ;
63 if (page >= 10000 ) return { ok: false , status: 508 , url: result.url, responseShape: 'pagination_limit' , records, pages };
64 }
65 return { ok: true , records, pages };
66 }
67 function collectionPath ( handle ) { return `/collections/${ encodeURIComponent ( handle ) }/products.json` ; }
68 async function captureSource ( origin ) {
69 const products = await fetchCollection (origin, '/products.json' , 'products' );
70 const collections = await fetchCollection (origin, '/collections.json' , 'collections' );
71 const memberships = [];
72 const membershipChecks = [];
73 if (collections.ok) {
74 for ( const collection of collections.records) {
75 const handle = text (collection.handle);
76 if ( ! handle) { membershipChecks. push ({ collectionId: collection.id, ok: false , status: 422 , responseShape: 'missing_handle' }); continue ; }
77 const result = await fetchCollection (origin, collectionPath (handle), 'products' );
78 membershipChecks. push ({ collectionId: collection.id, handle, ok: result.ok, status: result.status || 200 , url: result.url || result.pages[ 0 ]?.url || null , responseShape: result.ok ? 'object.products[]' : result.responseShape, pages: result.pages. length });
79 if (result.ok) for ( const product of result.records) memberships. push ({ collectionId: String (collection.id), productId: String (product.id) });
80 }
81 }
82 return { products, collections, memberships, membershipChecks };
83 }
84 async function preflight ( projectDir ) {
85 const { sourceUrl } = await context (projectDir); const data = await captureSource (sourceUrl);
86 const checks = [
87 { entity: 'product' , ok: data.products.ok, status: data.products.status || 200 , url: data.products.url || data.products.pages[ 0 ]?.url || null , responseShape: data.products.ok ? 'object.products[]' : data.products.responseShape, pages: data.products.pages. length },
88 { entity: 'collection' , ok: data.collections.ok, status: data.collections.status || 200 , url: data.collections.url || data.collections.pages[ 0 ]?.url || null , responseShape: data.collections.ok ? 'object.collections[]' : data.collections.responseShape, pages: data.collections.pages. length },
89 ... data.membershipChecks. map (( check ) => ({ entity: 'collection_product' , ... check })),
90 ];
91 const adapter = { id: CONTRACT .id, version: CONTRACT .version, fingerprint: hash ( CONTRACT ) };
92 const out = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), adapter, sourceUrl, status: checks. every (( check ) => check.ok) ? 'passed' : 'blocked' , checks };
93 await writeJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ), out);
94 if (out.status === 'passed' ) await writeJson (path. join (projectDir, 'quick-mode' , 'preflight-capture.json' ), { schemaVersion: 1 , generatedAt: out.generatedAt, adapter, sourceUrl, data });
95 return out;
96 }
97 async function plan ( projectDir ) {
98 const { sourceUrl } = await context (projectDir); const preflightResult = await readJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ));
99 if (preflightResult.status !== 'passed' ) throw new Error ( 'quick mode preflight must pass before planning' );
100 const adapter = { id: CONTRACT .id, version: CONTRACT .version, fingerprint: hash ( CONTRACT ) };
101 const out = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter, sourceUrl, entities: CONTRACT .entities, requiredWixCapabilities: CONTRACT .requiredWixCapabilities, excluded: CONTRACT .excluded, sourceAuth: 'none' };
102 await writeJson (path. join (projectDir, 'quick-mode' , 'plan.json' ), out);
103 await fs. writeFile (path. join (projectDir, 'quick-mode' , 'plan.md' ), `# Shopify quick-mode plan \n\n Adapter: ${ adapter . id } ${ adapter . version } \n\n Imported: collections, products, variants, tags, media, and collection membership. \n\n Excluded: ${ CONTRACT . excluded . join ( ', ' ) }. \n ` );
104 await writeJson (path. join (projectDir, 'execution' , 'execution-manifest.json' ), { schemaVersion: 1 , generatedAt: out.generatedAt, managementImportMode: 'quick' , adapter, extractCommand: 'rp-quick-shopify extract' , importCommand: 'rp-quick-shopify import' , entities: CONTRACT .entities. map (( entity ) => entity.id) });
105 return out;
106 }
107 async function extract ( projectDir ) {
108 const { sourceUrl } = await context (projectDir); const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' ));
109 if (planResult.adapter.fingerprint !== hash ( CONTRACT )) throw new Error ( 'quick adapter contract changed; re-run preflight and plan' );
110 let data;
111 try {
112 const capture = await readJson (path. join (projectDir, 'quick-mode' , 'preflight-capture.json' ));
113 if (capture.adapter?.fingerprint === planResult.adapter.fingerprint && capture.sourceUrl === sourceUrl) data = capture.data;
114 } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
115 if ( ! data) data = await captureSource (sourceUrl);
116 if ( ! data.products.ok || ! data.collections.ok || ! data.membershipChecks. every (( check ) => check.ok)) throw new Error ( 'source changed after preflight; re-run preflight before extracting' );
117 await writeNdjson ( file (projectDir, 'product' ), data.products.records);
118 await writeNdjson ( file (projectDir, 'collection' ), data.collections.records);
119 const knownProductIds = new Set (data.products.records. map (( row ) => String (row.id)));
120 const edges = data.memberships. filter (( edge ) => knownProductIds. has (edge.productId));
121 await writeNdjson ( file (projectDir, 'collection_product' ), edges);
122 const entities = {
123 product: { file: 'data/source-extract/product.ndjson' , recordCount: data.products.records. length , pages: data.products.pages },
124 collection: { file: 'data/source-extract/collection.ndjson' , recordCount: data.collections.records. length , pages: data.collections.pages },
125 collection_product: { file: 'data/source-extract/collection_product.ndjson' , recordCount: edges. length , sourceOnlyCount: data.memberships. length - edges. length },
126 };
127 const manifest = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter: planResult.adapter, sourceUrl, entities };
128 await writeJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ), manifest); return manifest;
129 }
130 async function loadWixEnv ( projectDir ) { const values = {}; try { for ( const line of ( await fs. readFile (path. join (projectDir, 'config' , 'wix.env' ), 'utf8' )). split ( / \r ? \n / )) { const match = line. trim (). match ( / ^ ( [A-Z0-9_] + )=( . * ) $ / ); if (match) values[match[ 1 ]] = match[ 2 ]. replace ( / ^ ['"] | ['"] $ / g , '' ). trim (); } } catch (error) { if (error.code !== 'ENOENT' ) throw error; } return { ... values, ... process.env }; }
131 async function loadCrosswalk ( projectDir ) { try { return await readJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-shopify.json' )); } catch (error) { if (error.code === 'ENOENT' ) return {}; throw error; } }
132 function canonicalProduct ( row ) {
133 if ( ! text (row.id) || ! text (row.title) || ! text (row.handle)) throw new Error ( 'missing product id, title, or handle' );
134 if ( text (row.body_html). length > MAX_DESCRIPTION_LENGTH ) throw new Error ( 'description exceeds Wix limit' );
135 const names = (row.options || []). map (( option ) => text (option.name)). filter (( name ) => name && name. toLowerCase () !== 'title' );
136 if (names. length > 3 ) throw new Error ( 'more than three options' );
137 const variants = (row.variants || []). map (( variant ) => {
138 if ( ! text (variant.id) || ! validPrice (variant.price)) throw new Error ( 'invalid variant id or price' );
139 const choices = names. map (( optionName , index ) => ({ optionName, choiceName: text (variant[ `option${ index + 1 }` ]) })). filter (( choice ) => choice.choiceName);
140 if (choices. length !== names. length ) throw new Error ( 'variant option values do not match product options' );
141 return { sku: text (variant.sku) || undefined , price: variant.price, compareAtPrice: validPrice (variant.compare_at_price) ? variant.compare_at_price : undefined , visible: variant.available !== false , choices };
142 });
143 if ( ! variants. length ) throw new Error ( 'product has no variants' );
144 return { name: text (row.title), slug: toWixSlug (row.handle), description: text (row.body_html) || undefined , visible: Boolean (row.published_at), optionNames: names, variants, images: (row.images || []). map (( image ) => ({ url: image.src, altText: image.alt || undefined })). filter (( image ) => text (image.url)) };
145 }
146 async function importData ( projectDir ) {
147 const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' )); const manifest = await readJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ));
148 if (manifest.adapter.fingerprint !== planResult.adapter.fingerprint) throw new Error ( 'extraction does not match quick plan; re-extract' );
149 const env = await loadWixEnv (projectDir); if ( ! env. WIX_AUTH_TOKEN || ! env. WIX_SITE_ID ) throw new Error ( 'WIX_AUTH_TOKEN and WIX_SITE_ID are required for quick import' );
150 const writers = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-writers.js' )); const dryRun = writers. createDryRunConfig (env, process.argv. slice ( 2 )).dryRun;
151 const wix = writers. createWixClient ({ dryRun, authToken: env. WIX_AUTH_TOKEN , siteId: env. WIX_SITE_ID , projectDir }); const crosswalk = await loadCrosswalk (projectDir);
152 const remember = ( entity , sourceId , targetId ) => { if ( ! dryRun) { crosswalk[entity] ||= {}; crosswalk[entity][ String (sourceId)] = targetId; } }; const seen = ( entity , id ) => crosswalk[entity]?.[ String (id)];
153 const checkpoint = async () => { if ( ! dryRun) await writeJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-shopify.json' ), crosswalk); };
154 const summary = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter: planResult.adapter, dryRun, imported: {}, skipped: { product: [] }, recovered: 0 , recoveryCounts: {}, excluded: CONTRACT .excluded };
155 const categoryIds = new Map (); const existingCategories = new Map (( await writers. queryAllStoresCategories (wix)). map (( category ) => [category.slug, category]));
156 for ( const row of await readNdjson ( file (projectDir, 'collection' ))) { try { const key = toWixSlug (row.handle); const target = seen ( 'collection' , row.id) ? { id: seen ( 'collection' , row.id) } : existingCategories. get (key) || await writers. createStoresCategory (wix, { name: text (row.title), slug: key, description: text (row.body_html). slice ( 0 , 600 ) || undefined , treeReference: writers. STORES_TREE_REFERENCE }); categoryIds. set ( String (row.id), target.id); remember ( 'collection' , row.id, target.id); await checkpoint (); } catch (error) { summary.skipped.collection ||= []; summary.skipped.collection. push ({ sourceId: String (row.id), reason: error.message }); } }
157 summary.imported.collection = categoryIds.size;
158 const existingTags = new Map ((( await writers. sendDirectRest (wix, { method: 'GET' , path: '/tags/v1/tags?fqdn=wix.stores.catalog.v3.product' })).tags || []). map (( tag ) => [ text (tag.name). toLowerCase (), tag]));
159 const tagIds = new Map (); const productRows = await readNdjson ( file (projectDir, 'product' )); let tagLimitReached = false ;
160 for ( const row of productRows) for ( const name of (row.tags || [])) { const key = text (name). toLowerCase (); if ( ! key || tagIds. has (key)) continue ; if ( Object . prototype .hasOwnProperty. call (crosswalk.product_tag || {}, key)) { if (crosswalk.product_tag[key]) tagIds. set (key, crosswalk.product_tag[key]); continue ; } if (tagLimitReached) { summary.skipped.product_tag ||= []; summary.skipped.product_tag. push ({ sourceId: key, reason: 'destination_tag_limit_reached' }); remember ( 'product_tag' , key, null ); await checkpoint (); continue ; } try { const target = existingTags. get (key) || ( await writers. sendDirectRest (wix, { method: 'POST' , path: '/tags/v1/tags' , body: { tag: { name: text (name), fqdn: 'wix.stores.catalog.v3.product' } } })).tag; tagIds. set (key, target.id); remember ( 'product_tag' , key, target.id); } catch (error) { if ( ! /TAG_NAME_ALREADY_EXISTS | TAGS_REACHED_LIMIT/ . test (error.message)) throw error; tagLimitReached = /TAGS_REACHED_LIMIT/ . test (error.message); summary.skipped.product_tag ||= []; summary.skipped.product_tag. push ({ sourceId: key, reason: tagLimitReached ? 'destination_tag_limit_reached' : 'destination_tag_exists_but_is_not_returned_by_tag_list' }); remember ( 'product_tag' , key, null ); } await checkpoint (); }
161 const existingProducts = new Map (( await writers. queryAllStoresProducts (wix)). map (( product ) => [product.slug, product])); const productIds = new Map ();
162 for ( const row of productRows) { try { const payload = buildProduct ( canonicalProduct (row)); const normalized = recovery. normalizeProduct ({ sourceId: row.id, sku: payload.variantsInfo.variants[ 0 ]?.sku || '' , tagIds: (row.tags || []). map (( name ) => tagIds. get ( text (name). toLowerCase ())). filter (Boolean) }); payload.variantsInfo.variants. forEach (( variant ) => { if (variant.sku) variant.sku = recovery. truncate (variant.sku, recovery. STORES_SKU_MAX , `${ row . id }:${ variant . sku }` ); }); payload.tags = { publicTags: { tagIds: normalized.tagIds } }; const target = seen ( 'product' , row.id) ? { id: seen ( 'product' , row.id) } : existingProducts. get (payload.slug) || await writers. createStoresProduct (wix, payload); if (normalized.recoveries. length ) { await recovery. appendRecoveries (projectDir, planResult.adapter, row.id, normalized.recoveries); recovery. recordRecoveredRecord (summary, normalized.recoveries); } productIds. set ( String (row.id), target.id); remember ( 'product' , row.id, target.id); await checkpoint (); } catch (error) { summary.skipped.product. push ({ sourceId: String (row.id), reason: error.message }); } }
163 summary.imported.product = productIds.size;
164 let memberships = 0 ; for ( const edge of await readNdjson ( file (projectDir, 'collection_product' ))) { const categoryId = categoryIds. get (edge.collectionId); const productId = productIds. get (edge.productId); if ( ! categoryId || ! productId) continue ; const edgeId = `${ edge . collectionId }:${ edge . productId }` ; if ( ! seen ( 'collection_product' , edgeId)) { await writers. bulkAddItemToCategories (wix, { productId, categoryIds: [categoryId] }); remember ( 'collection_product' , edgeId, productId); await checkpoint (); } memberships += 1 ; }
165 summary.imported.collection_product = memberships; if ( ! dryRun) await checkpoint (); await recovery. writeFinalReport (projectDir, summary); await writeJson (path. join (projectDir, 'execution' , 'completion-report.json' ), summary); return summary;
166 }
167 async function main () { const [ command , projectDirArg ] = process.argv. slice ( 2 ); if ( ! command || ! projectDirArg || ! [ 'preflight' , 'plan' , 'extract' , 'import' ]. includes (command)) throw new Error ( 'Usage: quick-mode.js <preflight|plan|extract|import> <projectDir>' ); const fn = { preflight, plan, extract, import: importData }[command]; const result = await fn (path. resolve (projectDirArg)); process.stdout. write ( `${ JSON . stringify ({ ok: true , command , status: result.status || 'completed' }) } \n ` ); }
168 main (). catch (( error ) => { process.stderr. write ( `${ error . message } \n ` ); process. exit ( 1 ); });