Ghost/ghost/member-attribution/lib/attribution.js
Rishabh c765c3230e Updated attribution service to handle referrer information
refs TryGhost/Team#1907

- calculates final attribution source and medium using captured referrer information in history
- adds new referrer-translator that goes through available history and based to determine most valid referrer info
- includes referrer url, source and medium in the attribution data for storage
2022-09-19 12:29:22 +05:30

184 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @typedef {object} AttributionResource
* @prop {string|null} id
* @prop {string|null} url (absolute URL)
* @prop {'page'|'post'|'author'|'tag'|'url'} type
* @prop {string|null} title
*/
class Attribution {
/** @type {import('./url-translator')} */
#urlTranslator;
/**
* @param {object} data
* @param {string|null} [data.id]
* @param {string|null} [data.url] Relative to subdirectory
* @param {'page'|'post'|'author'|'tag'|'url'} [data.type]
* @param {string|null} [data.refSource]
* @param {string|null} [data.refMedium]
* @param {URL|null} [data.refUrl]
*/
constructor({
id, url, type, refSource, refMedium, refUrl
}, {urlTranslator}) {
this.id = id;
this.url = url;
this.type = type;
this.refSource = refSource;
this.refMedium = refMedium;
this.refUrl = refUrl;
/**
* @private
*/
this.#urlTranslator = urlTranslator;
}
/**
* Converts the instance to a parsed instance with more information about the resource included.
* It does:
* - Uses the passed model and adds a title to the attribution
* - If the resource exists and has a new url, it updates the url if possible
* - Returns an absolute URL instead of a relative one
* @param {Object|null} [model] The Post/User/Tag model of the resource associated with this attribution
* @returns {AttributionResource}
*/
getResource(model) {
if (!this.id || this.type === 'url' || !this.type || !model) {
return {
id: null,
type: 'url',
url: this.#urlTranslator.relativeToAbsolute(this.url),
title: this.#urlTranslator.getUrlTitle(this.url)
};
}
const updatedUrl = this.#urlTranslator.getUrlByResourceId(this.id, {absolute: true});
return {
id: model.id,
type: this.type,
url: updatedUrl,
title: model.get('title') ?? model.get('name') ?? this.#urlTranslator.getUrlTitle(this.url)
};
}
/**
* Same as getResource, but fetches the model by ID instead of passing it as a parameter
*/
async fetchResource() {
if (!this.id || this.type === 'url' || !this.type) {
// No fetch required
return this.getResource();
}
// Fetch model
const model = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true});
return this.getResource(model);
}
}
/**
* Convert a UrlHistory to an attribution object
*/
class AttributionBuilder {
/** @type {import('./url-translator')} */
urlTranslator;
/**
*/
constructor({urlTranslator, referrerTranslator}) {
this.urlTranslator = urlTranslator;
this.referrerTranslator = referrerTranslator;
}
/**
* Creates an Attribution object with the dependencies injected
*/
build({id, url, type, refSource, refMedium, refUrl}) {
return new Attribution({
id,
url,
type,
refSource,
refMedium,
refUrl
}, {urlTranslator: this.urlTranslator});
}
/**
* Last Post Algorithm™
* @param {import('./history').UrlHistoryArray} history
* @returns {Promise<Attribution>}
*/
async getAttribution(history) {
if (history.length === 0) {
return this.build({
id: null,
url: null,
type: null,
refSource: null,
refMedium: null,
refUrl: null
});
}
const referrerData = this.referrerTranslator.getReferrerDetails(history) || {
refSource: null,
refMedium: null,
refUrl: null
};
// Start at the end. Return the first post we find
const resources = [];
// Note: history iterator is ordered from recent to oldest!
for (const item of history) {
const resource = await this.urlTranslator.getResourceDetails(item);
if (resource && resource.type === 'post') {
return this.build({
...resource,
...referrerData
});
}
// Store to avoid that we need to look it up again
if (resource) {
resources.push(resource);
}
}
// No post found?
// Return first with an id (page, tag, author)
for (const resource of resources) {
if (resource.id) {
return this.build({
...resource,
...referrerData
});
}
}
// No post/page/tag/author found?
// Return the last path that was visited
if (resources.length > 0) {
return this.build({
...referrerData,
...resources[0]
});
}
// We only have history items without a path that have invalid ids
return this.build({
...referrerData,
id: null,
url: null,
type: null
});
}
}
module.exports = AttributionBuilder;