Skip to content

Instantly share code, notes, and snippets.

@cloudhooks
Last active October 2, 2025 08:03
Show Gist options
  • Select an option

  • Save cloudhooks/996157f0717bb67bd4df4922f3f5f6e7 to your computer and use it in GitHub Desktop.

Select an option

Save cloudhooks/996157f0717bb67bd4df4922f3f5f6e7 to your computer and use it in GitHub Desktop.
Shopify product classification using the API service of Product Classifier
/*
* 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