Extend the feed created by Magento2TweakwiseExport

To add content or other item types to the feed.


Our partners at Sparkable provided an example of how they extended the Magento2TweakwiseExport feed with content from within Magento—in this case, blogs from MageFan.

Concept

For each store view, a content category is created alongside the default product category. This setup allows you to retrieve separate blocks of suggestions or search results via our API, based on the unique category IDs. As a result, you can fetch and display content and products using distinct API calls, enabling different placements and styling options.

Technical Integration

This code integrates with the Tweakwise\Magento2TweakwiseExport\Model\Write\WriterInterface, allowing you to inject custom products and categories into the feed.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Tweakwise\Magento2TweakwiseExport\Model\Write\Writer">
        <arguments>
            <argument name="writers" xsi:type="array">
                <item name="contentcategories" xsi:type="object">Sparkable\Tweakwise\Model\Write\ContentCategories</item>
                <item name="blogposts" xsi:type="object">Sparkable\Tweakwise\Model\Write\BlogPosts</item>
            </argument>
        </arguments>
    </type>
</config>
<?php

namespace Sparkable\Tweakwise\Model\Write;

use Magento\Store\Api\Data\StoreInterface;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManager;
use Tweakwise\Magento2TweakwiseExport\Model\Config;
use Tweakwise\Magento2TweakwiseExport\Model\Helper;
use Tweakwise\Magento2TweakwiseExport\Model\Logger;
use Tweakwise\Magento2TweakwiseExport\Model\Write\WriterInterface;
use Tweakwise\Magento2TweakwiseExport\Model\Write\Writer;
use Tweakwise\Magento2TweakwiseExport\Model\Write\XMLWriter;

class ContentCategories implements WriterInterface
{
    protected Config $config;
    protected StoreManager $storeManager;
    protected Helper $helper;
    protected Logger $log;

    /**
     * Track created categories to avoid duplicates
     * @var array
     */
    protected array $createdCategories = [];

    /**
     * ContentCategories constructor.
     *
     * @param Config $config
     * @param StoreManager $storeManager
     * @param Helper $helper
     * @param Logger $log
     */
    public function __construct(
        Config $config,
        StoreManager $storeManager,
        Helper $helper,
        Logger $log
    ) {
        $this->config = $config;
        $this->storeManager = $storeManager;
        $this->helper = $helper;
        $this->log = $log;
    }

    /**
     * @param Writer $writer
     * @param XMLWriter $xml
     * @param StoreInterface|null $store
     */
    public function write(Writer $writer, XMLWriter $xml, StoreInterface $store = null): void
    {
        $xml->startElement('categories');
        $writer->flush();

        $stores = [];
        if ($store) {
            $stores[] = $store;
        } else {
            $stores = $this->storeManager->getStores();
        }

        /** @var Store $store */
        foreach ($stores as $store) {
            if ($this->config->isEnabled($store)) {
                $storeName = $store->getName();
                $storeCode = $store->getCode();

                // Create the content category for this store
                // Use hash of store code to generate a unique ID
                $contentCategoryId = $this->getContentCategoryId($storeCode);
                $contentCategoryName = $storeName . ' - Content';

                // Check if this category has already been created
                if (!isset($this->createdCategories[$contentCategoryId])) {
                    $this->writeCategory($xml, $contentCategoryId, $contentCategoryName, 1);
                    $this->createdCategories[$contentCategoryId] = true;
                }

                // Create the blogs subcategory
                $blogsCategoryId = $this->getBlogCategoryId($storeCode);

                // Check if this category has already been created
                if (!isset($this->createdCategories[$blogsCategoryId])) {
                    $this->writeCategory($xml, $blogsCategoryId, 'blogs', $contentCategoryId);
                    $this->createdCategories[$blogsCategoryId] = true;
                }

                $this->log->debug(sprintf('Created content category structure for store %s', $storeName));
            }
        }

        $xml->endElement();
        $writer->flush();
    }

    /**
     * Get the content category ID for a store
     *
     * @param string $storeCode
     * @return int
     */
    public function getContentCategoryId(string $storeCode): int
    {
        return 5000 + crc32($storeCode) % 1000;
    }

    /**
     * Get the blog category ID for a store
     *
     * @param string $storeCode
     * @return int
     */
    public function getBlogCategoryId(string $storeCode): int
    {
        return 6000 + crc32($storeCode) % 1000;
    }

    /**
     * Write a category to the XML
     *
     * @param XMLWriter $xml
     * @param int $storeId
     * @param string $categoryId
     * @param string $name
     * @param string|null $parentId
     */
    protected function writeCategory(XMLWriter $xml, int $categoryId, string $name, ?int $parentId): void
    {
        $xml->startElement('category');
        $xml->writeElement('categoryid', $categoryId);
        $xml->writeElement('rank', '0');
        $xml->writeElement('name', $name);

        if ($parentId) {
            $xml->startElement('parents');
            $xml->writeElement('categoryid', $parentId);
            $xml->endElement();
        }

        $xml->endElement();
    }
}
<?php

namespace Sparkable\Tweakwise\Model\Write;

use Magefan\Blog\Model\Post;
use Magefan\Blog\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
use Magento\Store\Api\Data\StoreInterface;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManager;
use Symfony\Component\DomCrawler\Crawler;
use Tweakwise\Magento2TweakwiseExport\Model\Config;
use Tweakwise\Magento2TweakwiseExport\Model\Helper;
use Tweakwise\Magento2TweakwiseExport\Model\Logger;
use Tweakwise\Magento2TweakwiseExport\Model\Write\WriterInterface;
use Tweakwise\Magento2TweakwiseExport\Model\Write\Writer;
use Tweakwise\Magento2TweakwiseExport\Model\Write\XMLWriter;

class BlogPosts implements WriterInterface
{
    protected Config $config;
    protected StoreManager $storeManager;
    protected Helper $helper;
    protected Logger $log;
    protected PostCollectionFactory $postCollectionFactory;
    protected ContentCategories $contentCategories;

    /**
     * BlogPosts constructor.
     *
     * @param Config $config
     * @param StoreManager $storeManager
     * @param Helper $helper
     * @param Logger $log
     * @param PostCollectionFactory $postCollectionFactory
     * @param ContentCategories $contentCategories
     */
    public function __construct(
        Config $config,
        StoreManager $storeManager,
        Helper $helper,
        Logger $log,
        PostCollectionFactory $postCollectionFactory,
        ContentCategories $contentCategories
    ) {
        $this->config = $config;
        $this->storeManager = $storeManager;
        $this->helper = $helper;
        $this->log = $log;
        $this->postCollectionFactory = $postCollectionFactory;
        $this->contentCategories = $contentCategories;
    }

    /**
     * @param Writer $writer
     * @param XMLWriter $xml
     * @param StoreInterface|null $store
     */
    public function write(Writer $writer, XMLWriter $xml, StoreInterface $store = null): void
    {
        $xml->startElement('items');

        $stores = [];
        if ($store) {
            $stores[] = $store;
        } else {
            $stores = $this->storeManager->getStores();
        }

        /** @var Store $store */
        foreach ($stores as $store) {
            if ($this->config->isEnabled($store)) {
                try {
                    $this->exportStore($writer, $xml, $store);
                } catch (\Exception $e) {
                    $this->log->error(sprintf('Error exporting blog posts for store %s: %s', $store->getName(), $e->getMessage()));
                }

                $this->log->debug(sprintf('Export blog posts for store %s', $store->getName()));
            } else {
                $this->log->debug(sprintf('Skip blog posts for store %s (disabled)', $store->getName()));
            }
        }

        $xml->endElement();
        $writer->flush();
    }

    /**
     * @param Writer $writer
     * @param XMLWriter $xml
     * @param Store $store
     */
    public function exportStore(Writer $writer, XMLWriter $xml, Store $store): void
    {
        $collection = $this->postCollectionFactory->create();
        $collection->addStoreFilter($store->getId());
        $collection->addActiveFilter();
        $collection->addFieldToSelect('*');

        $index = 0;
        foreach ($collection as $post) {
            $this->writeBlogPost($xml, $store, $post);
            // Flush every so often
            if ($index % 100 === 0) {
                $writer->flush();
            }
            $index++;
        }

        $writer->flush();
    }

    /**
     * @param XMLWriter $xml
     * @param Store $store
     * @param Post $post
     */
    protected function writeBlogPost(XMLWriter $xml, Store $store, Post $post): void
    {
        $xml->startElement('item');

        // Use blog_post_ prefix to distinguish from products
        $tweakwiseId = 'blog_post_' . $post->getId();
        $xml->writeElement('id', $tweakwiseId);
        $xml->writeElement('name', $this->scalarValue($post->getTitle()));

        // Set a default price of 0 since blog posts don't have prices
        $xml->writeElement('price', '0');

        // Set stock to 1 to make it available
        $xml->writeElement('stock', '1');

        $xml->writeElement('url', $post->getUrl());
        $xml->writeElement('image', $post->getPostImage());

        $xml->startElement('categories');
        $storeCode = $store->getCode();
        $blogsCategoryId = $this->contentCategories->getBlogCategoryId($storeCode);
        $xml->writeElement('categoryid', $blogsCategoryId);
        $xml->endElement();

        // Begin to write blog post extra attributes
        $xml->startElement('attributes');
        $this->writeAttribute($xml, 'post_id', $post->getId());
        $this->writeAttribute($xml, 'item_type', 'blog_post');

        // Get stripped content from a blog post
        if ($post->getShortContent()) {
            $content = $post->getShortContent() ?: '';
        } else {
            $content = $post->getContent() ?: '';
        }
        $cleanContent = $this->cleanText(new Crawler($content));
        $this->writeAttribute($xml, 'content', $post->getShortContentExtractor()->execute($cleanContent, 400));

        $this->writeAttribute($xml, 'publish_date', $post->getPublishDate());
        $this->writeAttribute($xml, 'meta_title', $post->getMetaTitle());
        $this->writeAttribute($xml, 'meta_keywords', $post->getMetaKeywords());
        $this->writeAttribute($xml, 'meta_description', $post->getMetaDescription());

        $author = $post->getAuthor();
        if ($author) {
            $this->writeAttribute($xml, 'author', $author->getName());
        }

        // Add tags as attributes
        $tags = $post->getRelatedTags();
        if (count($tags) > 0) {
            $tagNames = [];
            foreach ($tags as $tag) {
                $tagNames[] = $tag->getName();
            }
            $this->writeAttribute($xml, 'tags', implode(', ', $tagNames));
        }

        // Add related post IDs
        $relatedPosts = $post->getRelatedPosts();
        if (count($relatedPosts) > 0) {
            foreach ($relatedPosts as $relatedPost) {
                $this->writeAttribute($xml, 'related_post', $relatedPost->getId());
            }
        }

        // Add related product IDs
        $relatedProducts = $post->getRelatedProducts();
        if (count($relatedProducts) > 0) {
            foreach ($relatedProducts as $relatedProduct) {
                $this->writeAttribute($xml, 'related_product', $relatedProduct->getId());
            }
        }

        $xml->endElement();
        $xml->endElement();

        $this->log->debug(sprintf('Export blog post [%s] %s', $tweakwiseId, $post->getTitle()));
    }

    /**
     * @param XMLWriter $xml
     * @param string $name
     * @param string|string[]|int|int[]|float|float[] $attributeValue
     */
    public function writeAttribute(
        XMLWriter $xml,
        $name,
        $attributeValue
    ): void {
        $values = $this->ensureArray($attributeValue);
        $values = array_unique($values);

        foreach ($values as $value) {
            if (empty($value) && $value !== "0") {
                continue;
            }

            $xml->startElement('attribute');
            $xml->writeAttribute('datatype', is_numeric($value) ? 'numeric' : 'text');
            $xml->writeElement('name', $name);
            $xml->writeElement('value', $this->scalarValue($value));
            $xml->endElement();
        }
    }

    /**
     * Get scalar value from object, array or scalar value
     *
     * @param mixed $value
     *
     * @return string|array
     */
    protected function scalarValue($value)
    {
        if (is_array($value)) {
            $data = [];
            foreach ($value as $key => $childValue) {
                $data[$key] = $this->scalarValue($childValue);
            }

            return $data;
        }

        if (is_object($value)) {
            if (method_exists($value, 'toString')) {
                $value = $value->toString();
            } elseif (method_exists($value, '__toString')) {
                $value = (string)$value;
            } else {
                $value = spl_object_hash($value);
            }
        }

        if (is_numeric($value)) {
            $value = $this->normalizeExponent($value);
        }

        if ($value !== null) {
            return html_entity_decode($value, ENT_NOQUOTES | ENT_HTML5);
        }

        return '';
    }

    /**
     * @param float|int $value
     * @return float|string
     */
    protected function normalizeExponent($value)
    {
        if (stripos($value, 'E+') !== false) {
            $decimals = 0;
            if (is_float($value)) {
                // Update decimals if not int
                $decimals = 5;
            }

            return number_format($value, $decimals, '.', '');
        }

        return $value;
    }

    /**
     * @param mixed $data
     * @return array
     */
    protected function ensureArray($data): array
    {
        return is_array($data) ? $data : [$data];
    }

    /**
     * Clean the text off all elements not used in feed
     *
     * @param Crawler $crawler
     * @return string
     */
    protected function cleanText(Crawler $crawler): string
    {
        $cleanedText = $crawler
            ->reduce(function (Crawler $node) {
                return $node->nodeName() !== 'style';
            })
            ->each(function (Crawler $node)  {
                if ($node->children()->count() > 0) {
                    return $this->cleanText($node->children());
                }
                if (str_contains($node->text(), '{{widget')) {
                    return '';
                }
                return trim($node->text());
            });

        return implode(PHP_EOL, $cleanedText);
    }
}