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
This commit is contained in:
Rishabh 2022-09-19 12:15:52 +05:30 committed by Rishabh Garg
parent 2807a35cb0
commit c765c3230e
9 changed files with 568 additions and 81 deletions

View File

@ -9,20 +9,27 @@ class MemberAttributionServiceWrapper {
}
// Wire up all the dependencies
const {MemberAttributionService, UrlTranslator, AttributionBuilder} = require('@tryghost/member-attribution');
const {
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder
} = require('@tryghost/member-attribution');
const models = require('../../models');
const urlTranslator = new UrlTranslator({
urlService,
urlService,
urlUtils,
models: {
Post: models.Post,
User: models.User,
Post: models.Post,
User: models.User,
Tag: models.Tag
}
});
this.attributionBuilder = new AttributionBuilder({urlTranslator});
const referrerTranslator = new ReferrerTranslator({
siteUrl: urlUtils.urlFor('home', true),
adminUrl: urlUtils.urlFor('admin', true)
});
this.attributionBuilder = new AttributionBuilder({urlTranslator, referrerTranslator});
// Expose the service
this.service = new MemberAttributionService({

View File

@ -375,4 +375,30 @@ describe('Member Attribution Service', function () {
});
});
});
/**
* Test that getAttribution correctly resolves all model types that are supported
*/
describe('getAttribution for referrer', function () {
it('resolves urls', async function () {
const attribution = await memberAttributionService.service.getAttribution([
{
id: null,
path: '/',
time: Date.now(),
refSource: 'ghost-explore',
refMedium: null,
refUrl: null
}
]);
attribution.should.match(({
id: null,
url: '/',
type: 'url',
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: null
}));
});
});
});

View File

@ -1,5 +1,6 @@
module.exports = {
MemberAttributionService: require('./lib/service'),
AttributionBuilder: require('./lib/attribution'),
UrlTranslator: require('./lib/url-translator')
UrlTranslator: require('./lib/url-translator'),
ReferrerTranslator: require('./lib/referrer-translator')
};

View File

@ -15,11 +15,19 @@ class Attribution {
* @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}, {urlTranslator}) {
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
@ -80,18 +88,22 @@ class AttributionBuilder {
/**
*/
constructor({urlTranslator}) {
constructor({urlTranslator, referrerTranslator}) {
this.urlTranslator = urlTranslator;
this.referrerTranslator = referrerTranslator;
}
/**
* Creates an Attribution object with the dependencies injected
*/
build({id, url, type}) {
build({id, url, type, refSource, refMedium, refUrl}) {
return new Attribution({
id,
url,
type
type,
refSource,
refMedium,
refUrl
}, {urlTranslator: this.urlTranslator});
}
@ -105,19 +117,31 @@ class AttributionBuilder {
return this.build({
id: null,
url: null,
type: null
type: null,
refSource: null,
refMedium: null,
refUrl: null
});
}
// Note: history iterator is ordered from recent to oldest!
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);
return this.build({
...resource,
...referrerData
});
}
// Store to avoid that we need to look it up again
@ -130,18 +154,25 @@ class AttributionBuilder {
// Return first with an id (page, tag, author)
for (const resource of resources) {
if (resource.id) {
return this.build(resource);
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(resources[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

View File

@ -3,6 +3,9 @@
* @prop {string} [path]
* @prop {string} [id]
* @prop {string} [type]
* @prop {string} [refSource]
* @prop {string} [refMedium]
* @prop {string} [refUrl]
* @prop {number} time
*/

View File

@ -0,0 +1,183 @@
/**
* @typedef {Object} ReferrerData
* @prop {string|null} [refSource]
* @prop {string|null} [refMedium]
* @prop {URL|null} [refUrl]
*/
/**
* Translates referrer info into Source and Medium
*/
class ReferrerTranslator {
/**
*
* @param {Object} deps
* @param {string} deps.siteUrl
* @param {string} deps.adminUrl
*/
constructor({adminUrl, siteUrl}) {
this.adminUrl = this.getUrlFromStr(adminUrl);
this.siteUrl = this.getUrlFromStr(siteUrl);
}
/**
* Calculate referrer details from history
* @param {import('./history').UrlHistoryArray} history
* @returns {ReferrerData|null}
*/
getReferrerDetails(history) {
if (history.length === 0) {
return null;
}
for (const item of history) {
const refUrl = this.getUrlFromStr(item.refUrl);
const refSource = item.refSource;
const refMedium = item.refMedium;
// If referrer is Ghost Explore
if (this.isGhostExploreRef({refUrl, refSource})) {
return {
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: refUrl
};
}
// If referrer is Ghost.org
if (this.isGhostOrgUrl(refUrl)) {
return {
refSource: 'Ghost.org',
refMedium: 'Ghost Network',
refUrl: refUrl
};
}
// If referrer is Ghost Newsletter
if (this.isGhostNewsletter({refSource})) {
return {
refSource: refSource.replace(/-/g, ' '),
refMedium: 'Email',
refUrl: refUrl
};
}
// If referrer is from query params
if (refSource) {
const urlData = this.getDataFromUrl() || {};
return {
refSource: refSource,
refMedium: refMedium || urlData?.medium || null,
refUrl: refUrl
};
}
// If referrer is known external URL
// TODO: Use list of known external urls to fetch source/medium
if (refUrl && !this.isSiteDomain(refUrl)) {
const urlData = this.getDataFromUrl();
if (urlData) {
return {
refSource: urlData?.source,
refMedium: urlData?.medium,
refUrl: refUrl
};
}
}
}
return null;
}
// Fetches referrer data from known external URLs
//TODO: Use list of known external urls to fetch source/medium
getDataFromUrl() {
return null;
}
/**
* @private
* Return URL object for provided URL string
* @param {string} url
* @returns {URL|null}
*/
getUrlFromStr(url) {
try {
return new URL(url);
} catch (e) {
return null;
}
}
/**
* @private
* Return whether the provided URL is a link to the site
* @param {URL} url
* @returns {boolean}
*/
isSiteDomain(url) {
try {
if (this.siteUrl && this.siteUrl?.hostname === url?.hostname) {
if (url?.pathname?.startsWith(this.siteUrl?.pathname)) {
return true;
}
return false;
}
return false;
} catch (e) {
return false;
}
}
/**
* @private
* Return whether provided ref is a Ghost newsletter
* @param {Object} deps
* @param {string|null} deps.refSource
* @returns {boolean}
*/
isGhostNewsletter({refSource}) {
// if refferer source ends with -newsletter
return refSource?.endsWith('-newsletter');
}
/**
* @private
* Return whether provided ref is a Ghost.org URL
* @param {URL|null} refUrl
* @returns {boolean}
*/
isGhostOrgUrl(refUrl) {
return refUrl?.hostname === 'ghost.org';
}
/**
* @private
* Return whether provided ref is Ghost Explore
* @param {Object} deps
* @param {URL|null} deps.refUrl
* @param {string|null} deps.refSource
* @returns {boolean}
*/
isGhostExploreRef({refUrl, refSource}) {
if (refSource === 'ghost-explore') {
return true;
}
if (refUrl?.hostname
&& this.adminUrl?.hostname === refUrl?.hostname
&& refUrl?.pathname?.startsWith(this.adminUrl?.pathname)
) {
return true;
}
if (refUrl?.hostname === 'ghost.org' && refUrl?.pathname?.startsWith('/explore')) {
return true;
}
return false;
}
}
module.exports = ReferrerTranslator;

View File

@ -6,80 +6,94 @@ const AttributionBuilder = require('../lib/attribution');
describe('AttributionBuilder', function () {
let attributionBuilder;
let urlTranslator;
let now;
before(function () {
now = Date.now();
attributionBuilder = new AttributionBuilder({
urlTranslator: {
getResourceDetails(item) {
if (!item.path) {
if (item.id === 'invalid') {
return null;
}
return {
id: item.id,
type: item.type,
url: `/${item.type}/${item.id}`
};
}
const path = this.stripSubdirectoryFromPath(item.path);
if (path === '/my-post') {
return {
id: 123,
type: 'post',
url: path
};
}
if (path === '/my-page') {
return {
id: 845,
type: 'page',
url: path
};
}
return {
id: null,
type: 'url',
url: path
};
},
getResourceById(id, type) {
if (id === 'invalid') {
urlTranslator = {
getResourceDetails(item) {
if (!item.path) {
if (item.id === 'invalid') {
return null;
}
return {
id,
get(prop) {
if (prop === 'title' && type === 'author') {
// Simulate an author doesn't have a title
return undefined;
}
if (id === 'no-props') {
// Simulate a model without properties for branch coverage
return undefined;
}
return prop;
}
id: item.id,
type: item.type,
url: `/${item.type}/${item.id}`
};
},
getUrlTitle(url) {
return url;
},
getUrlByResourceId() {
return 'https://absolute/dir/path';
},
relativeToAbsolute(path) {
return 'https://absolute/dir' + path;
},
stripSubdirectoryFromPath(path) {
if (path.startsWith('/dir/')) {
return path.substring('/dir/'.length - 1);
}
const path = this.stripSubdirectoryFromPath(item.path);
if (path === '/my-post') {
return {
id: 123,
type: 'post',
url: path
};
}
if (path === '/my-page') {
return {
id: 845,
type: 'page',
url: path
};
}
return {
id: null,
type: 'url',
url: path
};
},
getResourceById(id, type) {
if (id === 'invalid') {
return null;
}
return {
id,
get(prop) {
if (prop === 'title' && type === 'author') {
// Simulate an author doesn't have a title
return undefined;
}
if (id === 'no-props') {
// Simulate a model without properties for branch coverage
return undefined;
}
return prop;
}
return path;
};
},
getUrlTitle(url) {
return url;
},
getUrlByResourceId() {
return 'https://absolute/dir/path';
},
relativeToAbsolute(path) {
return 'https://absolute/dir' + path;
},
stripSubdirectoryFromPath(path) {
if (path.startsWith('/dir/')) {
return path.substring('/dir/'.length - 1);
}
return path;
}
};
attributionBuilder = new AttributionBuilder({
urlTranslator,
referrerTranslator: {
getReferrerDetails(history) {
if (history) {
return {
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: 'https://ghost.org/explore'
};
}
return null;
}
}
});
@ -87,7 +101,7 @@ describe('AttributionBuilder', function () {
it('Returns empty if empty history', async function () {
const history = UrlHistory.create([]);
should(await attributionBuilder.getAttribution(history)).match({id: null, type: null, url: null});
should(await attributionBuilder.getAttribution(history)).match({id: null, type: null, url: null, refSource: null, refMedium: null, refUrl: null});
});
it('Returns last url', async function () {
@ -131,6 +145,40 @@ describe('AttributionBuilder', function () {
should(await attributionBuilder.getAttribution(history)).match({type: 'post', id: '123', url: '/post/123'});
});
it('Returns referrer attribution', async function () {
const history = UrlHistory.create([
{path: '/dir/other', time: now + 123},
{id: '123', type: 'post', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(await attributionBuilder.getAttribution(history)).match({
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: 'https://ghost.org/explore'
});
});
it('Returns null referrer attribution', async function () {
attributionBuilder = new AttributionBuilder({
urlTranslator,
referrerTranslator: {
getReferrerDetails() {
return null;
}
}
});
const history = UrlHistory.create([
{path: '/dir/other', time: now + 123},
{id: '123', type: 'post', time: now + 124},
{path: '/dir/unknown-page', time: now + 125}
]);
should(await attributionBuilder.getAttribution(history)).match({
refSource: null,
refMedium: null,
refUrl: null
});
});
it('Returns all null if only invalid ids', async function () {
const history = UrlHistory.create([
{id: 'invalid', type: 'post', time: now + 124},

View File

@ -77,6 +77,14 @@ describe('UrlHistory', function () {
time: Date.now(),
type: 'post',
id: '123'
}],
[{
time: Date.now(),
type: 'post',
id: '123',
refSource: 'ghost-explore',
refMedium: null,
refUrl: 'https://ghost.org'
}]
];
for (const input of inputs) {

View File

@ -0,0 +1,180 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const ReferrerTranslator = require('../lib/referrer-translator');
describe('ReferrerTranslator', function () {
describe('Constructor', function () {
it('doesn\'t throw', function () {
new ReferrerTranslator({});
});
});
describe('getReferrerDetails', function () {
let translator;
before(function () {
translator = new ReferrerTranslator({
siteUrl: 'https://example.com',
adminUrl: 'https://admin.example.com/ghost'
});
});
it('returns ghost explore from source ref for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: 'ghost-explore',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: 'https://t.co'
}
])).eql({
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: null
});
});
it('returns ghost explore from url for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: null,
refMedium: null,
refUrl: 'https://ghost.org/explore'
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: 'https://t.co'
}
])).eql({
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: new URL('https://ghost.org/explore')
});
});
it('returns ghost explore from admin url for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: null,
refMedium: null,
refUrl: 'https://admin.example.com/ghost/#/dashboard'
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: 'https://t.co'
}
])).eql({
refSource: 'Ghost Explore',
refMedium: 'Ghost Network',
refUrl: new URL('https://admin.example.com/ghost/#/dashboard')
});
});
it('returns ghost newsletter ref for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: 'publisher-weekly-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-explore',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-newsletter',
refMedium: null,
refUrl: 'https://t.co'
}
])).eql({
refSource: 'publisher weekly newsletter',
refMedium: 'Email',
refUrl: null
});
});
it('returns ghost.org ref for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: null,
refMedium: null,
refUrl: 'https://ghost.org/creators/'
},
{
refSource: 'publisher-weekly-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-explore',
refMedium: null,
refUrl: null
}
])).eql({
refSource: 'Ghost.org',
refMedium: 'Ghost Network',
refUrl: new URL('https://ghost.org/creators/')
});
});
it('returns ref source for valid history', async function () {
should(translator.getReferrerDetails([
{
refSource: 'twitter',
refMedium: null,
refUrl: null
},
{
refSource: 'publisher-weekly-newsletter',
refMedium: null,
refUrl: null
},
{
refSource: 'ghost-explore',
refMedium: null,
refUrl: null
}
])).eql({
refSource: 'twitter',
refMedium: null,
refUrl: null
});
});
it('returns null for empty history', async function () {
should(translator.getReferrerDetails([])).eql(null);
});
it('returns null for history with only site url', async function () {
should(translator.getReferrerDetails([
{
refSource: null,
refMedium: null,
refUrl: 'https://example.com'
}
])).eql(null);
});
});
});