Last active
October 2, 2025 08:03
-
-
Save cloudhooks/996157f0717bb67bd4df4922f3f5f6e7 to your computer and use it in GitHub Desktop.
Shopify product classification using the API service of Product Classifier
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Environment variables to configure: | |
| * - API_URL: URL of the Product Classifier API | |
| * - API_KEY: API KEY generated in Product Classifier | |
| * - CATEGORIZATION_TAG: The tag added to the categorized product to prevent infinite "Product updated" loops | |
| * - CUSTOM_INSTRUCTIONS: Any instructions to the AI how to categorize products (optional) | |
| */ | |
| module.exports = async function(payload, actions, context) { | |
| const PRODUCT_CLASSIFIER_API_KEY = context.env.API_KEY; | |
| const PRODUCT_CLASSIFIER_API_URL = context.env.API_URL; | |
| const PRODUCT_CLASSIFIER_CUSTOM_INSTRUCTIONS = context.env.CUSTOM_INSTRUCTIONS; | |
| const CATEGORIZATION_TAG = context.env.CATEGORIZATION_TAG; | |
| const SHOPIFY_API_VERSION = '/admin/api/2025-07/graphql.json'; | |
| // ---- Utility: helpers ---- | |
| const toProductGID = (id) => { | |
| if (!id) return null; | |
| const str = String(id); | |
| return str.startsWith('gid://') ? str : `gid://shopify/Product/${str}`; | |
| }; | |
| const toCategoryGID = (raw) => { | |
| if (!raw) return null; | |
| const str = String(raw); | |
| return str.startsWith('gid://') ? str : `gid://shopify/TaxonomyCategory/${str}`; | |
| }; | |
| const hasText = (v) => typeof v === 'string' && v.trim().length > 0; | |
| // Strip all HTML tags (simple & fast) | |
| const stripTags = (input) => { | |
| if (typeof input !== 'string') input = input == null ? '' : String(input); | |
| return input | |
| .replace(/<script[\s\S]*?<\/script>/gi, '') | |
| .replace(/<style[\s\S]*?<\/style>/gi, '') | |
| .replace(/<!--[\s\S]*?-->/g, '') | |
| .replace(/<[^>]+>/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| }; | |
| // Build classifier payload from product data; sanitize to plain text | |
| const buildClassifierPayload = (title, body_html) => { | |
| const safeTitle = stripTags(title || ''); | |
| const safeBody = stripTags(body_html || ''); | |
| const combinedText = `${safeTitle}\n\n${safeBody}`.trim(); | |
| const apiPayload = { taxonomy: 'Shopify', product: combinedText } | |
| if (PRODUCT_CLASSIFIER_CUSTOM_INSTRUCTIONS) { | |
| apiPayload.customInstructionsForAi = PRODUCT_CLASSIFIER_CUSTOM_INSTRUCTIONS | |
| } | |
| return apiPayload; | |
| }; | |
| // External API: classify product | |
| const classifyProduct = async (httpPost, apiKey, title, body_html) => { | |
| const data = buildClassifierPayload(title, body_html); | |
| if (!hasText(data.product)) { | |
| console.log('No title or description to classify. Skipping classification.'); | |
| return null; | |
| } | |
| if (!apiKey) { | |
| console.error('Missing PRODUCT_CLASSIFIER_API_KEY. Skipping classification.'); | |
| return null; | |
| } | |
| const config = { | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| timeout: 30000 | |
| }; | |
| try { | |
| const response = await httpPost(PRODUCT_CLASSIFIER_API_URL, data, config); | |
| const result = response && response.data ? response.data : null; | |
| if (!result || !result.success || !result.categoryFound || !result.categoryId) { | |
| console.log('No category found for product.', result || {}); | |
| return null; | |
| } | |
| console.log('Classifier result:', { | |
| categoryId: result.categoryId, | |
| categoryPath: result.categoryPath, | |
| categoryLine: result.categoryLine, | |
| categoryTags: result.categoryTags | |
| }); | |
| return toCategoryGID(result.categoryId); | |
| } catch (err) { | |
| console.error('Error during product classification:', err && err.message ? err.message : err); | |
| return null; | |
| } | |
| }; | |
| // Shopify GraphQL: update product category & add tag | |
| const updateProductCategoryAndTag = async (graphql, productId, categoryId, newTags) => { | |
| const mutation = { | |
| query: ` | |
| mutation SetProductCategory($productId: ID!, $categoryId: ID!, $tags: [String!]) { | |
| productUpdate(product: { | |
| id: $productId, | |
| category: $categoryId, | |
| tags: $tags | |
| }) { | |
| product { | |
| id | |
| title | |
| tags | |
| category { | |
| id | |
| fullName | |
| } | |
| } | |
| userErrors { | |
| field | |
| message | |
| } | |
| } | |
| } | |
| `, | |
| variables: { productId, categoryId, tags: newTags } | |
| }; | |
| try { | |
| const response = await graphql(SHOPIFY_API_VERSION, mutation); | |
| const update = response && response.data ? response.data.productUpdate : null; | |
| if (!update) { | |
| console.error('productUpdate not returned in GraphQL response:', response); | |
| return { ok: false, error: 'NO_PRODUCT_UPDATE' }; | |
| } | |
| if (update.userErrors && update.userErrors.length > 0) { | |
| console.error('Product update errors:', update.userErrors); | |
| return { ok: false, error: 'USER_ERRORS', details: update.userErrors }; | |
| } | |
| console.log('Product category and tag updated:', update.product); | |
| return { ok: true, product: update.product }; | |
| } catch (err) { | |
| console.error('Error updating product category and tag:', err && err.message ? err.message : err); | |
| return { ok: false, error: 'EXCEPTION', details: err }; | |
| } | |
| }; | |
| // Orchestrator: determine & update category | |
| const determineAndApplyCategory = async () => { | |
| const productIdRaw = payload && payload.id; | |
| const title = payload && payload.title; | |
| const body_html = payload && payload.body_html; | |
| // Simple tag parsing (comma-separated string -> trimmed array) | |
| const tags = (payload && typeof payload.tags === 'string' ? payload.tags : '') | |
| .split(',') | |
| .map(tag => tag.trim()) | |
| .filter(Boolean); | |
| // Early exit if already categorized | |
| if (tags.includes(CATEGORIZATION_TAG)) { | |
| console.log(`Product '${productIdRaw}' already has '${CATEGORIZATION_TAG}' tag, exiting.`); | |
| return; | |
| } | |
| const productGID = toProductGID(productIdRaw); | |
| if (!productGID) { | |
| console.log('Missing product id. Exiting.'); | |
| return; | |
| } | |
| // Classify | |
| const categoryGID = await classifyProduct(actions.http.post, PRODUCT_CLASSIFIER_API_KEY, title, body_html); | |
| if (!categoryGID) { | |
| console.log('No category to apply. Exiting.'); | |
| return; | |
| } | |
| // Prepare updated tags | |
| const newTags = tags.includes(CATEGORIZATION_TAG) ? tags : [...tags, CATEGORIZATION_TAG]; | |
| // Update (skip write in test mode) | |
| if (context && context.isTestMode) { | |
| console.log('[TEST MODE] Would update product category and tag:', { | |
| productId: productGID, | |
| categoryId: categoryGID, | |
| tags: newTags | |
| }); | |
| return; | |
| } | |
| await updateProductCategoryAndTag(actions.shopify.graphql, productGID, categoryGID, newTags); | |
| }; | |
| // ---- Entry ---- | |
| try { | |
| await determineAndApplyCategory(); | |
| } catch (e) { | |
| console.error('Unhandled error in hook:', e && e.message ? e.message : e); | |
| } | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment