burst / content_recommendation
Content recommendation for Drupal.
This package is not auto-updated.
Last update: 2024-11-13 02:58:01 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.
- Enable
content_recommendation
. - Enable each
content_recommendation
submodule that you want to support. - If you're using Headless Ninja (HN) framework, enable the
content_recommendation_hn
submodule. - Visit the API url, depending on whether you use HN: http://drupal.dev/hn?path=/api/content-recommendation?path=/.
- 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 theentity.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, currentlytag_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,
};