UNPKG

picnic-api

Version:

Unofficial wrapper for the API of the online supermarket Picnic

255 lines 11.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractMarkdowns = exports.findById = exports.stripMarkdownFormatting = exports.stripColorMarkup = void 0; exports.extractProductDetails = extractProductDetails; const jsonpath_plus_1 = require("jsonpath-plus"); /** Strip Picnic color markup like `#(#B40117)` from text. */ const stripColorMarkup = (text) => text.replace(/#\([A-Za-z0-9#_]+\)/g, "").trim(); exports.stripColorMarkup = stripColorMarkup; /** Strip bold/italic markdown formatting (`**`, `__`) from text. */ const stripMarkdownFormatting = (text) => text.replace(/\*\*/g, "").replace(/__/g, ""); exports.stripMarkdownFormatting = stripMarkdownFormatting; /** Recursively find a node by its `id` field anywhere in the tree. */ const findById = (node, id) => { if (!node) return null; if (node.id === id) return node; for (const key of ["child", "children"]) { if (Array.isArray(node[key])) { for (const child of node[key]) { const result = (0, exports.findById)(child, id); if (result) return result; } } else if (node[key] && typeof node[key] === "object") { const result = (0, exports.findById)(node[key], id); if (result) return result; } } return null; }; exports.findById = findById; /** Extract all string markdown values from a node. */ const extractMarkdowns = (node) => { if (!node) return []; return (0, jsonpath_plus_1.JSONPath)({ path: "$..markdown", json: node }).filter((m) => typeof m === "string"); }; exports.extractMarkdowns = extractMarkdowns; /** * Extracts structured product details from a Fusion product details page response. * @param {string} productId The product ID that was requested. * @param {FusionPage} page The raw Fusion page response. */ function extractProductDetails(productId, page) { // ── Name, brand, unit quantity, unit price ── const mainContainer = (0, exports.findById)(page.layout.body, "product-details-page-root-main-container"); const children = (0, jsonpath_plus_1.JSONPath)({ path: "$.pml.component.children", json: mainContainer })[0] ?? []; const nameNode = children.find((child) => child.textType === "HEADER1"); const brandNode = children.find((child) => child.textAttributes?.weight === "REGULAR"); const stackNode = children.find((child) => child.type === "STACK"); const [unitQuantityNode, unitPriceNode] = stackNode?.children?.filter((child) => child.type === "RICH_TEXT") ?? []; const nodeMarkdown = (node) => typeof node?.markdown === "string" ? (0, exports.stripColorMarkup)(node.markdown) : null; const name = nodeMarkdown(nameNode) ?? ""; const brand = nodeMarkdown(brandNode); const unitQuantity = nodeMarkdown(unitQuantityNode) ?? ""; const unitPrice = nodeMarkdown(unitPriceNode); // ── Price & max count ── const allSellingUnits = (0, jsonpath_plus_1.JSONPath)({ path: "$..sellingUnit", json: page }); const mainUnit = allSellingUnits.find((u) => u.id === productId && u.max_count !== undefined) ?? {}; // For bundle products the selling unit carries `display_price`. For non-bundle // products the price is rendered as a PRICE component inside the main container. let displayPrice = mainUnit.display_price ?? 0; // price_ranges may be on the selling unit directly (rare on PDP), or embedded // in the PML script expression params (at __ep1.v1 / __ep2.v1 paths used by // getCurrentPrice / getFormattedPriceValueForPriceRanges expressions). let priceRanges = mainUnit.price_ranges ?? null; if (!priceRanges) { priceRanges = extractPriceRangesFromExpressions(page) ?? null; } if (!displayPrice) { const bundleNode = (0, exports.findById)(page.layout.body, productId); if (bundleNode) { displayPrice = (0, jsonpath_plus_1.JSONPath)({ path: "$..price", json: bundleNode })[0] ?? 0; } } if (!displayPrice && mainContainer) { const priceComponents = (0, jsonpath_plus_1.JSONPath)({ path: "$..price", json: mainContainer }) .filter((p) => typeof p === "number"); displayPrice = priceComponents[0] ?? 0; } const maxCount = mainUnit.max_count ?? 0; // ── Gallery images ── const gallery = (0, exports.findById)(page.layout.body, "product-page-image-gallery-main-image-container"); const imageIds = gallery ? [...new Set((0, jsonpath_plus_1.JSONPath)({ path: "$..source.id", json: gallery }))] : mainUnit.image_id ? [mainUnit.image_id] : []; // ── Description ── const descBlock = (0, exports.findById)(page.layout.body, "description"); const descMarkdowns = (0, exports.extractMarkdowns)(descBlock); const description = descMarkdowns.length > 0 ? descMarkdowns.join("\n") : null; // ── Highlights ── const highlightsBlock = (0, exports.findById)(page.layout.body, "product-page-highlights"); const highlights = (0, exports.extractMarkdowns)(highlightsBlock).map((h) => (0, exports.stripMarkdownFormatting)((0, exports.stripColorMarkup)(h))); // ── Allergens ── const allergiesBlock = (0, exports.findById)(page.layout.body, "product-page-allergies"); const allergenTexts = (0, exports.extractMarkdowns)(allergiesBlock).map(exports.stripColorMarkup); // Allergen list starts with a heading like "Bevat" and may include a separator // like "Bevat mogelijk" partway through. Drop the heading(s) and keep individual allergens. const allergens = allergenTexts.filter((t) => !/^bevat(\s+mogelijk)?$/i.test(t.trim())); // ── Info sections (accordion) ── const accordionBlock = (0, exports.findById)(page.layout.body, "accordion-list"); const infoSections = []; if (accordionBlock) { const items = (0, jsonpath_plus_1.JSONPath)({ path: "$..items", json: accordionBlock })[0]; if (Array.isArray(items)) { for (const item of items) { const headerTexts = (0, exports.extractMarkdowns)(item.header).map(exports.stripMarkdownFormatting).map(exports.stripColorMarkup); const bodyTexts = (0, exports.extractMarkdowns)(item.body).map(exports.stripColorMarkup); infoSections.push({ title: headerTexts[0] ?? "", content: bodyTexts.join("\n"), }); } } } // ── Promotion ── let promotion = null; const promoData = (0, jsonpath_plus_1.JSONPath)({ path: "$..promotion_id", json: page }); const promoLabels = (0, jsonpath_plus_1.JSONPath)({ path: "$..promotion_label", json: page }); if (promoData.length > 0 && promoLabels.length > 0) { promotion = { id: promoData[0], label: promoLabels[0], }; } // ── Bundles ── const bundles = []; const bundleContainer = findBundleContainer(page.layout.body); if (bundleContainer) { // Each bundle item is wrapped in a STATE_BOUNDARY with id matching the selling unit id const bundleItemNodes = []; const walkBundleItems = (node) => { if (!node) return; if (typeof node === "object" && !Array.isArray(node)) { if (node.type === "STATE_BOUNDARY" && typeof node.id === "string" && node.id.startsWith("s")) { bundleItemNodes.push(node); } for (const v of Object.values(node)) walkBundleItems(v); } else if (Array.isArray(node)) { for (const item of node) walkBundleItems(item); } }; walkBundleItems(bundleContainer); for (let i = 0; i < bundleItemNodes.length; i++) { const bNode = bundleItemNodes[i]; const sellingUnits = (0, jsonpath_plus_1.JSONPath)({ path: "$..sellingUnit", json: bNode }); const prices = (0, jsonpath_plus_1.JSONPath)({ path: "$..price", json: bNode }).filter((p) => typeof p === "number"); const su = sellingUnits[0]; if (su) { bundles.push({ id: su.id, quantity: i + 1, pricePerUnit: prices[0] ?? 0, imageId: su.image_id ?? "", maxCount: su.max_count ?? 0, }); } } } // ── Similar products ── const altContainer = (0, exports.findById)(page.layout.body, "alternatives-container"); const altSellingUnits = altContainer ? (0, jsonpath_plus_1.JSONPath)({ path: "$..sellingUnit", json: altContainer }) : []; const similarProducts = altSellingUnits .filter((u) => u.display_price !== undefined) .map((u) => ({ id: u.id, name: u.name, imageId: u.image_id, displayPrice: u.display_price, unitQuantity: u.unit_quantity, maxCount: u.max_count, ...(u.deposit !== undefined ? { deposit: u.deposit } : {}), ...(u.price_ranges ? { priceRanges: u.price_ranges } : {}), })); return { id: productId, name, brand, unitQuantity, unitPrice, displayPrice, maxCount, imageIds, description, highlights, allergens, infoSections, promotion, bundles, priceRanges, similarProducts, }; } /** Walk the Fusion tree to find the `product-page-bundles-*` container node. */ function findBundleContainer(body) { const walk = (n) => { if (!n) return null; if (typeof n === "object" && !Array.isArray(n)) { if (typeof n.id === "string" && n.id.startsWith("product-page-bundles-")) return n; for (const v of Object.values(n)) { const result = walk(v); if (result) return result; } } else if (Array.isArray(n)) { for (const item of n) { const result = walk(item); if (result) return result; } } return null; }; return walk(body); } /** * Extract price_ranges from PML script expression parameters. * On the PDP, price ranges are embedded in the expression payload (e.g. __ep1.v1) * used by scripts like `getCurrentPrice` / `getFormattedPriceValueForPriceRanges`. * Each entry has `{ price: number, from_quantity: number }`. */ function extractPriceRangesFromExpressions(page) { // Search the entire page tree for expression params containing price_range-shaped arrays const allV1Arrays = (0, jsonpath_plus_1.JSONPath)({ path: "$..__ep1.v1", json: page }); for (const arr of allV1Arrays) { if (Array.isArray(arr) && arr.length > 0 && arr[0].price !== undefined && arr[0].from_quantity !== undefined) { return arr; } } // Also check __ep2.v1 as a fallback const allV1Ep2 = (0, jsonpath_plus_1.JSONPath)({ path: "$..__ep2.v1", json: page }); for (const arr of allV1Ep2) { if (Array.isArray(arr) && arr.length > 0 && arr[0].price !== undefined && arr[0].from_quantity !== undefined) { return arr; } } return null; } //# sourceMappingURL=helpers.js.map