burst/content_recommendation

Content recommendation for Drupal.

1.2.0 2023-11-10 09:41 UTC

This package is not auto-updated.

Last update: 2024-05-01 00:32:46 UTC


README

Getting started

Start off by enabling the content_recommendation core module. This will expose the methods and plugins you need. After this you'll need to determine which ways of content recommendation you want to support.

  1. Enable content_recommendation.
  2. Enable each content_recommendation submodule that you want to support.
  3. If you're using Headless Ninja (HN) framework, enable the content_recommendation_hn submodule.
  4. Visit the API url, depending on whether you use HN: http://drupal.dev/hn?path=/api/content-recommendation?path=/.
  5. You should now see a content_recommendation property on the Homepage entity.

Fetch API

After enabling the content_recommendation and content_recommendation_hn modules, you are able to fetch content recommendation items for a certain entity. You can do this by requesting the exposed API from within your HN front end application.

To make the request, simply go inside the React component you need the items in, and execute `site.getPage(/api/content_recommendation?path=${pathOfEntityToRequest})`.

If you're using the content_recommendation_related submodule, you'd probably want to specify a related type. You can do this by adding the related_type parameter to the site.getPage() request like so: `site.getPage(/api/content_recommendation?path=${pathOfEntityToRequest}&related_type=${relatedType}). relatedType` should correspondent with one of the [predefined related types](#Related types).

Extend

All core functionality is extendable with your custom code. You can add extra ways to calculate content items for a certain entity by using the provided plugins and annotations.

ContentRecommendationPlugin

An example of extending the Content Recommendation response with your custom variant. The ContentRecommendationPlugin annotation needs an id property. This property has to be unique and will be used as the key on the response. The example below will therefore add a unique_key_here property to the response.

/**
 * @ContentRecommendationPlugin(
 *   id = "unique_key_here",
 *   priority = 10
 * )
 */
class ContentRecommendation extends ContentRecommendationPluginBase {
  public function recommend(EntityInterface $entity, $query) {
    // Get some random nids.
    $nids = \Drupal::entityQuery('node')
                ->range(0, 3)
                ->execute();

    if (!empty($nids)) {
      // Load nodes.
      $nodes = Node::loadMultiple($nids);
    }

    // All nodes that are returned from the recommend method will
    // be added to the response under the key provided in the annotation.
    return $nodes;
  }
}

Modules

content_recommendation

The core module that provides API's to provide content recommendation and extend the core functionality.

content_recommendation_related

By enabling this module recommendation methods are exposed to calculate the content recommendation for certain items.

Related types

A few related types are supported by default. These can be passed as an argument to the method or by setting the related_type parameter when requesting [the API](#Fetch API).

Available related types:

  • tag_based: will look in the entity.field_tags field to use its tags for the calculation of related content items.
  • random: will randomly return content items.
  • clickstream_1: first variant of the clickstream based content recommendation.
  • default: this will fallback to the default related type, currently tag_based.

Configuration

All of this confiuration is only relevant when you use the clickstream_1 related type.

Minimum visit duration (seconds)

This setting will tell the algorithm to filter out all visits of which the duration was shorter than this number.

Maximum visit duration (seconds)

This setting will tell the algorithm to filter out all visits of which the duration was longer than this number.

Expire related paths (seconds)

This setting will make sure the cache of calculated paths is cleared after this number.

Matomo base path

This setting is usually the full domain your website is hosted on (e.g. example.com).

Weight of the number of visits per path

This number will be used as an exponent to give weight to the number of visits for a path. The higher this number, the more impact this number will have.

Matomo credentials

Please enter all Matomo database connection info.

The Matomo credentials are stored in the Drupal state and are therefore not exportable through configuration management.

content_recommendation_popular

This module will track the pageviews of all viewed content items and will rank the response based on that.

content_recommendation_highlights

This module will provide a configuration page where a content manager can configure node references to be returned in the response.

Configuration

The configuration page will expose a field to reference nodes.

content_recommendation_hn

The HN submodule will expose the API to calculate the content items.

React

Since all React related code is in a closed source project, all relevant classes and components are copied to this README.

Class that holds all Matomo related tracking

export const canTrack = () => root.document && root._paq;

class DataLayerMatomo {
  prevUrl = '';
  currentUrl = '';
  currentPageTitle = '';
  currentBundle = null;
  app = null;
  generationTimeMs = 0;

  init() {
    this.app = root.document.getElementById('root');
    this.currentPageTitle = root.document.title;

    const mutationObserver = new MutationObserver(mutations =>
      this.onMutationNotify(mutations),
    );

    mutationObserver.observe(root.document.querySelector('title'), {
      subtree: true,
      characterData: true,
      childList: true,
      attributes: true,
    });

    const matomoTimeoutsetTimeout = setTimeout(() => {
      this.track(() => ['enableHeartBeatTimer', config.HEART_BEAT_S]);
    }, config.BOUNCE_BEFORE_MS);

    root.ab = {};

    this.track(() => [
      'AbTesting::create',
      AbTestsStore.contentRecommendationClickstream.test,
    ]);

    this.track(() => ['HeatmapSessionRecording::enable']);

    // Return an unsubscribe method, e.g. to call when unmounting.
    return () => {
      clearTimeout(matomoTimeoutsetTimeout);
      mutationObserver.disconnect();
    };
  }

  onMutationNotify(mutations) {
    const newTitle = getNested(() => mutations[0].target.innerText);
    if (newTitle) {
      this.currentPageTitle = newTitle;
      this.trackPageView();
    }
  }

  // eslint-disable-next-line class-methods-use-this
  async track(cb) {
    if (canTrack()) root._paq.push(cb());
  }

  setPage = ({ nextUrl, generationTimeMs = 0, bundle }) => {
    this.prevUrl = this.currentUrl;
    this.currentUrl = nextUrl;
    this.generationTimeMs = generationTimeMs;
    this.currentBundle = bundle;
  };

  async trackPageView() {
    if (!canTrack()) return;

    // remove all previously assigned custom variables, requires Matomo 3.0.2
    await this.track(() => ['deleteCustomVariables', 'page']); // Matomo 3.x

    await this.track(() => ['setReferrerUrl', this.prevUrl]);
    await this.track(() => ['setCustomUrl', this.currentUrl]);
    await this.track(() => ['setDocumentTitle', this.currentPageTitle]);

    await this.track(() => ['setGenerationTimeMs', this.generationTimeMs]);

    await this.track(() => [
      'trackPageView',
      this.currentPageTitle,
      { dimension1: this.currentBundle },
    ]);

    // make Matomo aware of newly added content
    await this.track(() => ['MediaAnalytics::scanForMedia', this.app]); // Matomo 3.x
    await this.track(() => ['FormAnalytics::scanForForms', this.app]); // Matomo 3.x
    await this.track(() => ['trackContentImpressionsWithinNode', this.app]);
    await this.track(() => ['enableLinkTracking']);
  }
}

Component that will trigger a pageview in Matomo.

import DataLayerMatomo from '../common/Helper/analytics/DataLayerMatomo';

class BaseComponent extends Component {
  componentDidMount() {
    this.matomoUnsubscribe = DataLayerMatomo.init();

    const bundle = getNested(() => this.props.page.__hn.entity.bundle);
    DataLayerMatomo.setPage({
      nextUrl: this.props.url,
      generationTimeMs: root.generationTimeMs,
      bundle,
    });

    DataLayerMatomo.trackPageView();
  }

  componentWillUnmount() {
    if (typeof this.matomoUnsubscribe === 'function') {
      this.matomoUnsubscribe();
    }
  }

  componentWillReceiveProps({ url: nextUrl }) {
    const prevUrl = this.props.url;
    if (nextUrl !== prevUrl) {
      // HN is done loading
      this.startRenderAfterFetch = true; // New render is started now
    }
  }

  componentDidUpdate() {
    // Check if this DidUpdate was fired after url was changed
    if (this.startRenderAfterFetch) {
      // Trigger page view
      const bundle = getNested(() => this.props.page.__hn.entity.bundle);
      this.onHistoryChange({
        nextUrl: this.props.url,
        generationTimeMs: Date.now() - root.startGenerationTime,
        bundle,
      });

      this.startRenderAfterFetch = false;
    }
  }

  onHistoryChange = ({ nextUrl, generationTimeMs, bundle }) => {
    DataLayerMatomo.setPage({ nextUrl, generationTimeMs, bundle });
  };
}

Container component which holds A/B variant logic and fetches data from endpoint.

class ContentRecommendation extends Component {
  constructor() {
    super();

    this.state = {
      contentRecommendation: null,
    };
  }

  componentDidMount() {
    this.mounted = true;

    this.props.abTestsStore.contentRecommendationClickstreamVariation =
      this.props.abTestsStore.contentRecommendationClickstreamVariation ||
      AbTestsStore.contentRecommendationClickstream.variations.original.name;

    this.autoUpdateDisposer = autorun(() => {
      this.fetchContentRecommendationItems(
        this.props.abTestsStore.contentRecommendationClickstreamVariation,
      );
    });
  }

  componentWillUnmount() {
    this.mounted = false;

    if (this.autoUpdateDisposer) {
      this.autoUpdateDisposer();
    }
  }

  async fetchContentRecommendationItems(contentRecommendationVariation) {
    const { pathname, search } = this.props.location;
    const path = pathname + search;

    const abVariation =
      AbTestsStore.contentRecommendationClickstream.variations[
        contentRecommendationVariation
      ];
    const relatedType = abVariation && abVariation.name;

    if (relatedType) {
      const uuid = await site.getPage(
        `/api/content-recommendation?related_type=${relatedType}&path=${path}`,
      );

      if (!this.mounted) {
        return;
      }

      const contentRecommendation = site.getData(uuid);

      if (contentRecommendation) {
        // eslint-disable-next-line react/no-did-mount-set-state
        this.setState({ contentRecommendation });
      }
    }
  }

  render() {
    const { contentRecommendation } = this.state;

    if (!contentRecommendation) return null;

    return <ContentRecommendationItems {...contentRecommendation} />;
  }
}

Components that renders the component with the different tabs, depending on the data from the endpoint.

class ContentRecommendation extends Component {
  constructor() {
    super();

    this.state = {
      activeIndex: 0,
    };
  }

  onTabChange = activeIndex => {
    this.setState({ activeIndex });
  };

  render() {
    const { related, popular, highlights, title } = this.props;

    const showRelated = related && related.length > 0;
    const showPopular = popular && popular.length > 0;
    const showHighlights = highlights && highlights.length > 0;

    if (!showRelated && !showPopular && !showHighlights) {
      return null;
    }

    return (
      <div>
        <Tabs
          navId="related-content"
          activeIndex={this.state.activeIndex}
          onChange={this.onTabChange}
        >
          <TitleComponent
            className="container"
            title={title.title || title}
            subtitle={title.subtitle}
          />
          <TabNav mobileTitle={title.title || title} tabNav>
            {showRelated && (
              <TabNavItem navItem key="related" scrollIntoView>
                Voor jou
              </TabNavItem>
            )}
            {showPopular && (
              <TabNavItem navItem key="popular" scrollIntoView>
                Populair op Natuurmonumenten
              </TabNavItem>
            )}
            {showHighlights && (
              <TabNavItem navItem key="highlights" scrollIntoView>
                Tips van de redactie
              </TabNavItem>
            )}
          </TabNav>
          <MultiColContent>
            <TabContent>
              {showRelated && (
                <TabSection>
                  <EntityListMapper
                    mapper={ContentRecommendationMapper}
                    entities={related}
                    entityProps={{ tabName: 'related' }}
                  />
                </TabSection>
              )}
              {showPopular && (
                <TabSection>
                  <EntityListMapper
                    mapper={ContentRecommendationMapper}
                    entities={popular}
                    entityProps={{ tabName: 'popular' }}
                  />
                </TabSection>
              )}
              {showHighlights && (
                <TabSection>
                  <EntityListMapper
                    mapper={ContentRecommendationMapper}
                    entities={highlights}
                    entityProps={{ tabName: 'highlights' }}
                  />
                </TabSection>
              )}
            </TabContent>
          </MultiColContent>
        </Tabs>
      </div>
    );
  }
}

ContentRecommendation.propTypes = {
  related: PropTypes.arrayOf(PropTypes.string).isRequired,
  popular: PropTypes.arrayOf(PropTypes.string),
  highlights: PropTypes.arrayOf(PropTypes.string),
  title: PropTypes.oneOfType([
    PropTypes.shape({
      title: PropTypes.string,
      subtitle: PropTypes.string,
    }),
    PropTypes.bool,
    PropTypes.string,
  ]),
};

Component that holds visual rendering for the individual content reocommendation items and tracking when a user clicks an item.

const ContentRecommendationItem = ({
  entity,
  title,
  subtitle,
  index,
  abTestsStore,
  tabName,
}) => (
  <MultiColItem
    title={title || entity.title}
    subtitle={subtitle}
    link={entity.__hn.url}
    image={entity.field_media}
    onClick={() =>
      ContentRecommendationItem.trackNavigate({
        index,
        tabName,
        relatedType: abTestsStore.contentRecommendationVariation,
      })
    }
  >
    <p>{multiline(entity.field_teaser)}</p>
  </MultiColItem>
);

ContentRecommendationItem.trackNavigate = ({ index, relatedType, tabName }) => {
  Matomo.track(() => {
    const recommendedCategory = tabName === 'related' ? relatedType : tabName;

    return [
      'trackEvent',
      'Content recommendation',
      'content_recommendation_navigate',
      `bottom_${recommendedCategory}_${index}`,
    ];
  });
};

ContentRecommendationItem.propTypes = {
  entity: PropTypes.shape({
    title: PropTypes.string.isRequired,
    field_tags: PropTypes.array,
    field_teaser: PropTypes.string,
    __hn: PropTypes.shape({
      url: PropTypes.string.isRequired,
    }).isRequired,
  }).isRequired,
  title: PropTypes.string,
  subtitle: PropTypes.string,
  index: PropTypes.number.isRequired,
  tabName: PropTypes.string.isRequired,
  abTestsStore: PropTypes.instanceOf(AbTestsStore).isRequired,
};

ContentRecommendationItem.defaultProps = {
  title: undefined,
  subtitle: undefined,
};