Added outbound link tagging for web posts (#16201)
fixes https://github.com/TryGhost/Team/issues/2433 - Moved all outbound link tagging code to separate OutboundLinkTagger - Because a site can easily enable/disable this feature, we don't store the ?refs in the HTML but add them on the fly for now in the Content API.
This commit is contained in:
parent
a310ec2e3e
commit
8dfa490638
@ -216,7 +216,7 @@
|
||||
<div class="gh-expandable-block">
|
||||
<div class="gh-expandable-header">
|
||||
<div>
|
||||
<h4 class="gh-expandable-title">External attribution</h4>
|
||||
<h4 class="gh-expandable-title">Outbound Link Tagging</h4>
|
||||
<p class="gh-expandable-description">
|
||||
Adds ?ref to external links in web posts and adds a setting to control this for both web and newsletters.
|
||||
</p>
|
||||
|
@ -18,6 +18,8 @@ const getPostServiceInstance = require('../../../../../../services/posts/posts-s
|
||||
const postsService = getPostServiceInstance();
|
||||
|
||||
const commentsService = require('../../../../../../services/comments');
|
||||
const memberAttribution = require('../../../../../../services/member-attribution');
|
||||
const labs = require('../../../../../../../shared/labs');
|
||||
|
||||
module.exports = async (model, frame, options = {}) => {
|
||||
const {tiers: tiersData} = options || {};
|
||||
@ -65,6 +67,15 @@ module.exports = async (model, frame, options = {}) => {
|
||||
} else {
|
||||
jsonModel.comments = false;
|
||||
}
|
||||
|
||||
// Add outbound link tags
|
||||
if (labs.isSet('outboundLinkTagging')) {
|
||||
// Only add it in the flag! Without the flag we only add it to emails.
|
||||
if (jsonModel.html) {
|
||||
// Only set if HTML was requested
|
||||
jsonModel.html = await memberAttribution.outboundLinkTagger.addToHtml(jsonModel.html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transforms post/page metadata to flat structure
|
||||
|
@ -67,7 +67,8 @@ class EmailServiceWrapper {
|
||||
linkReplacer,
|
||||
linkTracking,
|
||||
memberAttributionService: memberAttribution.service,
|
||||
audienceFeedbackService: audienceFeedback.service
|
||||
audienceFeedbackService: audienceFeedback.service,
|
||||
outboundLinkTagger: memberAttribution.outboundLinkTagger
|
||||
});
|
||||
|
||||
const sendingService = new SendingService({
|
||||
|
@ -400,13 +400,13 @@ const PostEmailSerializer = {
|
||||
|
||||
if (isSite) {
|
||||
// Add newsletter name as ref to the URL
|
||||
url = memberAttribution.service.addOutboundLinkTagging(url, newsletter);
|
||||
url = memberAttribution.outboundLinkTagger.addToUrl(url, newsletter);
|
||||
|
||||
// Only add post attribution to our own site (because external sites could/should not process this information)
|
||||
url = memberAttribution.service.addPostAttributionTracking(url, post);
|
||||
} else {
|
||||
// Add email source attribution without the newsletter name
|
||||
url = memberAttribution.service.addOutboundLinkTagging(url);
|
||||
url = memberAttribution.outboundLinkTagger.addToUrl(url);
|
||||
}
|
||||
|
||||
// Add link click tracking
|
||||
|
@ -12,7 +12,7 @@ class MemberAttributionServiceWrapper {
|
||||
|
||||
// Wire up all the dependencies
|
||||
const {
|
||||
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder
|
||||
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder, OutboundLinkTagger
|
||||
} = require('@tryghost/member-attribution');
|
||||
const models = require('../../models');
|
||||
|
||||
@ -33,6 +33,12 @@ class MemberAttributionServiceWrapper {
|
||||
|
||||
this.attributionBuilder = new AttributionBuilder({urlTranslator, referrerTranslator});
|
||||
|
||||
this.outboundLinkTagger = new OutboundLinkTagger({
|
||||
isEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
|
||||
getSiteTitle: () => settingsCache.get('title'),
|
||||
urlUtils
|
||||
});
|
||||
|
||||
// Expose the service
|
||||
this.service = new MemberAttributionService({
|
||||
models: {
|
||||
@ -41,9 +47,7 @@ class MemberAttributionServiceWrapper {
|
||||
Integration: models.Integration
|
||||
},
|
||||
attributionBuilder: this.attributionBuilder,
|
||||
getTrackingEnabled: () => !!settingsCache.get('members_track_sources'),
|
||||
getOutboundLinkTaggingEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
|
||||
getSiteTitle: () => settingsCache.get('title')
|
||||
getTrackingEnabled: () => !!settingsCache.get('members_track_sources')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ Hopefully you don't find it a bore.",
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
|
||||
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
|
||||
"id": "618ba1ffbe2896088840a6e9",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -48,7 +48,7 @@ exports[`Pages Content API Can request page 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=0",
|
||||
"content-length": "1083",
|
||||
"content-length": "1088",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
@ -90,7 +90,7 @@ Tip: If you're reading any post or page on your site and you notice something yo
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<p>Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.</p><p>An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!</p><blockquote><strong>Tip: </strong>If you're reading any post or page on your site and you notice something you want to edit, you can add <code>/edit</code> to the end of the URL – and you'll be taken directly to the Ghost editor.</blockquote><p>Now tell the world what your site is all about.</p>",
|
||||
"html": "<p>Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.</p><p>An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!</p><blockquote><strong>Tip: </strong>If you're reading any post or page on your site and you notice something you want to edit, you can add <code>/edit</code> to the end of the URL – and you'll be taken directly to the Ghost editor.</blockquote><p>Now tell the world what your site is all about.</p>",
|
||||
"id": "6194d3ce51e2700162531a78",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -134,7 +134,7 @@ If you prefer to use a contact form, almost all of the great embedded form servi
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<p>If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.</p><h3 id=\\"for-example-heres-how-to-reach-us\\">For example, here's how to reach us!</h3><ul><li><a href=\\"https://twitter.com/ghost\\">@Ghost</a> on Twitter</li><li><a href=\\"https://www.facebook.com/ghost\\">@Ghost</a> on Facebook</li><li><a href=\\"https://instagram.com/ghost\\">@Ghost</a> on Instagram</li></ul><p>If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:</p><figure class=\\"kg-card kg-image-card\\"><a href=\\"https://ghost.org/integrations/?tag=forms\\"><img src=\\"https://static.ghost.org/v4.0.0/images/integrations.png\\" class=\\"kg-image\\" alt loading=\\"lazy\\" width=\\"2944\\" height=\\"1716\\"></a></figure>",
|
||||
"html": "<p>If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.</p><h3 id=\\"for-example-heres-how-to-reach-us\\">For example, here's how to reach us!</h3><ul><li><a href=\\"https://twitter.com/ghost?ref=ghost\\">@Ghost</a> on Twitter</li><li><a href=\\"https://www.facebook.com/ghost\\">@Ghost</a> on Facebook</li><li><a href=\\"https://instagram.com/ghost?ref=ghost\\">@Ghost</a> on Instagram</li></ul><p>If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:</p><figure class=\\"kg-card kg-image-card\\"><a href=\\"https://ghost.org/integrations/?tag=forms&ref=ghost\\"><img src=\\"https://static.ghost.org/v4.0.0/images/integrations.png\\" class=\\"kg-image\\" alt loading=\\"lazy\\" width=\\"2944\\" height=\\"1716\\"></a></figure>",
|
||||
"id": "6194d3ce51e2700162531a79",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -174,7 +174,7 @@ Ghost is a non-profit organization, and we give away all our intellectual proper
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<p>Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it.</p><p>Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:</p><ul><li>Contribute code via <a href=\\"https://github.com/tryghost\\">GitHub</a></li><li>Contribute financially via <a href=\\"https://github.com/sponsors/TryGhost\\">GitHub Sponsors</a></li><li>Contribute financially via <a href=\\"https://opencollective.com/ghost\\">Open Collective</a></li><li>Contribute reviews via <strong>writing a blog post</strong></li><li>Contribute good vibes via <strong>telling your friends</strong> about us</li></ul><p>Thanks for checking us out!</p>",
|
||||
"html": "<p>Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it.</p><p>Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:</p><ul><li>Contribute code via <a href=\\"https://github.com/tryghost?ref=ghost\\">GitHub</a></li><li>Contribute financially via <a href=\\"https://github.com/sponsors/TryGhost?ref=ghost\\">GitHub Sponsors</a></li><li>Contribute financially via <a href=\\"https://opencollective.com/ghost?ref=ghost\\">Open Collective</a></li><li>Contribute reviews via <strong>writing a blog post</strong></li><li>Contribute good vibes via <strong>telling your friends</strong> about us</li></ul><p>Thanks for checking us out!</p>",
|
||||
"id": "6194d3ce51e2700162531a7b",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -211,7 +211,7 @@ You can integrate any products, services, ads or integrations with Ghost yoursel
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<p>Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.</p><p>You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.</p>",
|
||||
"html": "<p>Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.</p><p>You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.</p>",
|
||||
"id": "6194d3ce51e2700162531a7a",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -248,7 +248,7 @@ Hopefully you don't find it a bore.",
|
||||
"feature_image_caption": null,
|
||||
"featured": false,
|
||||
"frontmatter": null,
|
||||
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
|
||||
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
|
||||
"id": "618ba1ffbe2896088840a6e9",
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
@ -275,7 +275,7 @@ exports[`Pages Content API Can request pages 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=0",
|
||||
"content-length": "9149",
|
||||
"content-length": "9263",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
File diff suppressed because one or more lines are too long
@ -4,7 +4,7 @@ const moment = require('moment');
|
||||
const testUtils = require('../../utils');
|
||||
const models = require('../../../core/server/models');
|
||||
|
||||
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {agentProvider, fixtureManager, matchers, mockManager} = require('../../utils/e2e-framework');
|
||||
const {anyArray, anyContentVersion, anyEtag, anyUuid, anyISODateTimeWithTZ} = matchers;
|
||||
|
||||
const postMatcher = {
|
||||
@ -342,4 +342,25 @@ describe('Posts Content API', function () {
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot();
|
||||
});
|
||||
|
||||
it('Adds ?ref tags', async function () {
|
||||
const post = await models.Post.add({
|
||||
title: 'title',
|
||||
status: 'published',
|
||||
slug: 'add-ref-tags',
|
||||
mobiledoc: JSON.stringify({version: '0.3.1',atoms: [],cards: [['html',{html: '<a href="https://example.com">Link</a><a href="invalid">Test</a>'}]],markups: [],sections: [[10,0],[1,'p',[]]],ghostVersion: '4.0'})
|
||||
}, {context: {internal: true}});
|
||||
|
||||
let response = await agent
|
||||
.get(`posts/${post.id}/`)
|
||||
.expectStatus(200);
|
||||
assert(response.body.posts[0].html.includes('<a href="https://example.com/?ref=ghost">Link</a><a href="invalid">Test</a>'), 'Html not expected (should contain ?ref): ' + response.body.posts[0].html);
|
||||
|
||||
// Disable outbound link tracking
|
||||
mockManager.mockSetting('outbound_link_tagging', false);
|
||||
response = await agent
|
||||
.get(`posts/${post.id}/`)
|
||||
.expectStatus(200);
|
||||
assert(response.body.posts[0].html.includes('<a href="https://example.com">Link</a><a href="invalid">Test</a>'), 'Html not expected: ' + response.body.posts[0].html);
|
||||
});
|
||||
});
|
||||
|
@ -57,6 +57,7 @@ class EmailRenderer {
|
||||
#linkReplacer;
|
||||
#linkTracking;
|
||||
#memberAttributionService;
|
||||
#outboundLinkTagger;
|
||||
#audienceFeedbackService;
|
||||
|
||||
/**
|
||||
@ -74,6 +75,7 @@ class EmailRenderer {
|
||||
* @param {object} dependencies.linkTracking
|
||||
* @param {object} dependencies.memberAttributionService
|
||||
* @param {object} dependencies.audienceFeedbackService
|
||||
* @param {object} dependencies.outboundLinkTagger
|
||||
*/
|
||||
constructor({
|
||||
settingsCache,
|
||||
@ -86,7 +88,8 @@ class EmailRenderer {
|
||||
linkReplacer,
|
||||
linkTracking,
|
||||
memberAttributionService,
|
||||
audienceFeedbackService
|
||||
audienceFeedbackService,
|
||||
outboundLinkTagger
|
||||
}) {
|
||||
this.#settingsCache = settingsCache;
|
||||
this.#settingsHelpers = settingsHelpers;
|
||||
@ -99,6 +102,7 @@ class EmailRenderer {
|
||||
this.#linkTracking = linkTracking;
|
||||
this.#memberAttributionService = memberAttributionService;
|
||||
this.#audienceFeedbackService = audienceFeedbackService;
|
||||
this.#outboundLinkTagger = outboundLinkTagger;
|
||||
}
|
||||
|
||||
getSubject(post) {
|
||||
@ -243,13 +247,13 @@ class EmailRenderer {
|
||||
|
||||
if (isSite) {
|
||||
// Add newsletter name as ref to the URL
|
||||
url = this.#memberAttributionService.addOutboundLinkTagging(url, newsletter);
|
||||
url = this.#outboundLinkTagger.addToUrl(url, newsletter);
|
||||
|
||||
// Only add post attribution to our own site (because external sites could/should not process this information)
|
||||
url = this.#memberAttributionService.addPostAttributionTracking(url, post);
|
||||
} else {
|
||||
// Add email source attribution without the newsletter name
|
||||
url = this.#memberAttributionService.addOutboundLinkTagging(url);
|
||||
url = this.#outboundLinkTagger.addToUrl(url);
|
||||
}
|
||||
|
||||
// Add link click tracking
|
||||
|
@ -391,10 +391,6 @@ describe('Email renderer', function () {
|
||||
},
|
||||
linkReplacer,
|
||||
memberAttributionService: {
|
||||
addOutboundLinkTagging: (u, newsletter) => {
|
||||
u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
|
||||
return u;
|
||||
},
|
||||
addPostAttributionTracking: (u) => {
|
||||
u.searchParams.append('post_tracking', 'added');
|
||||
return u;
|
||||
@ -406,6 +402,12 @@ describe('Email renderer', function () {
|
||||
return new URL('http://tracked-link.com/?m=' + encodeURIComponent(uuid) + '&url=' + encodeURIComponent(u.href));
|
||||
}
|
||||
}
|
||||
},
|
||||
outboundLinkTagger: {
|
||||
addToUrl: (u, newsletter) => {
|
||||
u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
|
||||
return u;
|
||||
}
|
||||
}
|
||||
});
|
||||
let basePost;
|
||||
|
@ -7,26 +7,31 @@ class LinkReplacer {
|
||||
*/
|
||||
async replace(html, replaceLink) {
|
||||
const cheerio = require('cheerio');
|
||||
const $ = cheerio.load(html);
|
||||
try {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
for (const el of $('a').toArray()) {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch (e) {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
if (url) {
|
||||
url = await replaceLink(url);
|
||||
const str = url.toString();
|
||||
$(el).attr('href', str);
|
||||
for (const el of $('a').toArray()) {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch (e) {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
if (url) {
|
||||
url = await replaceLink(url);
|
||||
const str = url.toString();
|
||||
$(el).attr('href', str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $.html();
|
||||
return $.html();
|
||||
} catch (e) {
|
||||
// Catch errors from cheerio
|
||||
return html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,5 +2,6 @@ module.exports = {
|
||||
MemberAttributionService: require('./lib/service'),
|
||||
AttributionBuilder: require('./lib/attribution'),
|
||||
UrlTranslator: require('./lib/url-translator'),
|
||||
ReferrerTranslator: require('./lib/referrer-translator')
|
||||
ReferrerTranslator: require('./lib/referrer-translator'),
|
||||
OutboundLinkTagger: require('./lib/outbound-link-tagger')
|
||||
};
|
||||
|
87
ghost/member-attribution/lib/outbound-link-tagger.js
Normal file
87
ghost/member-attribution/lib/outbound-link-tagger.js
Normal file
@ -0,0 +1,87 @@
|
||||
const {slugify} = require('@tryghost/string');
|
||||
const LinkReplacer = require('@tryghost/link-replacer');
|
||||
|
||||
const blockedReferrerDomains = [
|
||||
// Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string
|
||||
'facebook.com',
|
||||
'www.facebook.com'
|
||||
];
|
||||
|
||||
/**
|
||||
* Adds ?ref to outbound links
|
||||
*/
|
||||
class OutboundLinkTagger {
|
||||
/**
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {() => boolean} deps.isEnabled
|
||||
* @param {() => string} deps.getSiteTitle
|
||||
* @param {{isSiteUrl(url, context): boolean}} deps.urlUtils
|
||||
*/
|
||||
constructor({isEnabled, getSiteTitle, urlUtils}) {
|
||||
this._isEnabled = isEnabled;
|
||||
this._getSiteTitle = getSiteTitle;
|
||||
this._urlUtils = urlUtils;
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this._isEnabled();
|
||||
}
|
||||
|
||||
get siteTitle() {
|
||||
return this._getSiteTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
|
||||
* Note that this is disabled if outboundLinkTagging setting is disabled.
|
||||
* @param {URL} url instance that will get updated
|
||||
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
|
||||
* @returns {URL}
|
||||
*/
|
||||
addToUrl(url, useNewsletter) {
|
||||
// Create a deep copy
|
||||
url = new URL(url);
|
||||
|
||||
if (!this.isEnabled) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
|
||||
// Don't overwrite + keep existing source attribution
|
||||
return url;
|
||||
}
|
||||
|
||||
// Check blocked domains
|
||||
const referrerDomain = url.hostname;
|
||||
if (blockedReferrerDomains.includes(referrerDomain)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (useNewsletter) {
|
||||
const name = slugify(useNewsletter.get('name'));
|
||||
|
||||
// If newsletter name ends with newsletter, don't add it again
|
||||
const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`;
|
||||
url.searchParams.append('ref', ref);
|
||||
} else {
|
||||
url.searchParams.append('ref', slugify(this.siteTitle));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async addToHtml(html) {
|
||||
if (!this.isEnabled) {
|
||||
return html;
|
||||
}
|
||||
return await LinkReplacer.replace(html, (url) => {
|
||||
const isSite = this._urlUtils.isSiteUrl(url);
|
||||
if (isSite) {
|
||||
return url;
|
||||
}
|
||||
return this.addToUrl(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OutboundLinkTagger;
|
@ -1,11 +1,4 @@
|
||||
const UrlHistory = require('./history');
|
||||
const {slugify} = require('@tryghost/string');
|
||||
|
||||
const blacklistedReferrerDomains = [
|
||||
// Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string
|
||||
'facebook.com',
|
||||
'www.facebook.com'
|
||||
];
|
||||
|
||||
class MemberAttributionService {
|
||||
/**
|
||||
@ -16,29 +9,17 @@ class MemberAttributionService {
|
||||
* @param {Object} deps.models.MemberCreatedEvent
|
||||
* @param {Object} deps.models.SubscriptionCreatedEvent
|
||||
* @param {() => boolean} deps.getTrackingEnabled
|
||||
* @param {() => boolean} deps.getOutboundLinkTaggingEnabled
|
||||
* @param {() => string} deps.getSiteTitle
|
||||
*/
|
||||
constructor({attributionBuilder, models, getTrackingEnabled, getOutboundLinkTaggingEnabled, getSiteTitle}) {
|
||||
constructor({attributionBuilder, models, getTrackingEnabled}) {
|
||||
this.models = models;
|
||||
this.attributionBuilder = attributionBuilder;
|
||||
this._getTrackingEnabled = getTrackingEnabled;
|
||||
this._getOutboundLinkTaggingEnabled = getOutboundLinkTaggingEnabled;
|
||||
this._getSiteTitle = getSiteTitle;
|
||||
}
|
||||
|
||||
get isTrackingEnabled() {
|
||||
return this._getTrackingEnabled();
|
||||
}
|
||||
|
||||
get isOutboundLinkTaggingEnabled() {
|
||||
return this._getOutboundLinkTaggingEnabled();
|
||||
}
|
||||
|
||||
get siteTitle() {
|
||||
return this._getSiteTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} context instance of ghost framework context object
|
||||
@ -100,44 +81,6 @@ class MemberAttributionService {
|
||||
return await this.attributionBuilder.getAttribution(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
|
||||
* Note that this is disabled if outboundLinkTagging setting is disabled.
|
||||
* @param {URL} url instance that will get updated
|
||||
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
|
||||
* @returns {URL}
|
||||
*/
|
||||
addOutboundLinkTagging(url, useNewsletter) {
|
||||
// Create a deep copy
|
||||
url = new URL(url);
|
||||
|
||||
if (!this.isOutboundLinkTaggingEnabled) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
|
||||
// Don't overwrite + keep existing source attribution
|
||||
return url;
|
||||
}
|
||||
|
||||
// Check blacklist domains
|
||||
const referrerDomain = url.hostname;
|
||||
if (blacklistedReferrerDomains.includes(referrerDomain)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (useNewsletter) {
|
||||
const name = slugify(useNewsletter.get('name'));
|
||||
|
||||
// If newsletter name ends with newsletter, don't add it again
|
||||
const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`;
|
||||
url.searchParams.append('ref', ref);
|
||||
} else {
|
||||
url.searchParams.append('ref', slugify(this.siteTitle));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add some parameters to a URL so that the frontend script can detect this and add the required records
|
||||
* in the URLHistory.
|
||||
|
205
ghost/member-attribution/test/outbound-link-tagger.test.js
Normal file
205
ghost/member-attribution/test/outbound-link-tagger.test.js
Normal file
@ -0,0 +1,205 @@
|
||||
// Switch these lines once there are useful utils
|
||||
// const testUtils = require('./utils');
|
||||
require('./utils');
|
||||
const {OutboundLinkTagger} = require('../');
|
||||
const assert = require('assert');
|
||||
|
||||
describe('OutboundLinkTagger', function () {
|
||||
describe('Constructor', function () {
|
||||
it('doesn\'t throw', function () {
|
||||
new OutboundLinkTagger({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToUrl', function () {
|
||||
it('uses sluggified sitename for external urls', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world');
|
||||
});
|
||||
|
||||
it('does not add if disabled', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => false
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/');
|
||||
});
|
||||
|
||||
it('uses sluggified newsletter name for internal urls', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const newsletterName = 'used newsletter name';
|
||||
const newsletter = {
|
||||
get: (t) => {
|
||||
if (t === 'name') {
|
||||
return newsletterName;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updatedUrl = await service.addToUrl(url, newsletter);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=used-newsletter-name-newsletter');
|
||||
});
|
||||
|
||||
it('does not repeat newsletter at the end of the newsletter name', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const newsletterName = 'Weekly newsletter';
|
||||
const newsletter = {
|
||||
get: (t) => {
|
||||
if (t === 'name') {
|
||||
return newsletterName;
|
||||
}
|
||||
}
|
||||
};
|
||||
const updatedUrl = await service.addToUrl(url, newsletter);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=weekly-newsletter');
|
||||
});
|
||||
|
||||
it('does not add ref to blocked domains', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://facebook.com/');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://facebook.com/');
|
||||
});
|
||||
|
||||
it('does not add ref if utm_source is present', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?utm_source=hello');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?utm_source=hello');
|
||||
});
|
||||
|
||||
it('does not add ref if ref is present', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?ref=hello');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=hello');
|
||||
});
|
||||
|
||||
it('does not add ref if source is present', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?source=hello');
|
||||
const updatedUrl = await service.addToUrl(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?source=hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToHtml', function () {
|
||||
it('adds refs to external links', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => false
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
assert.equal(html, '<a href="https://example.com/test-site?ref=hello-world">Hello world</a><a href="https://other.com/test/?ref=hello-world">Hello world</a>');
|
||||
});
|
||||
|
||||
it('does not add refs to internal links', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => true
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
});
|
||||
|
||||
it('does not add refs if disabled', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => false,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => false
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
});
|
||||
|
||||
it('does not add refs to anchors', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => false
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="#test">Hello world</a><a href="#">Hello world</a>');
|
||||
assert.equal(html, '<a href="#test">Hello world</a><a href="#">Hello world</a>');
|
||||
});
|
||||
|
||||
it('does not add refs to relative links', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => false
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="test">Hello world</a><a href="">Hello world</a>');
|
||||
assert.equal(html, '<a href="test">Hello world</a><a href>Hello world</a>');
|
||||
});
|
||||
|
||||
it('keeps HTML if throws', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => {
|
||||
throw new Error('Oops!');
|
||||
}
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
|
||||
});
|
||||
|
||||
it('keeps HTML comments', async function () {
|
||||
const service = new OutboundLinkTagger({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
isEnabled: () => true,
|
||||
urlUtils: {
|
||||
isSiteUrl: () => false
|
||||
}
|
||||
});
|
||||
const html = await service.addToHtml('<!-- comment -->');
|
||||
assert.equal(html, '<!-- comment -->');
|
||||
});
|
||||
});
|
||||
});
|
@ -10,110 +10,6 @@ describe('MemberAttributionService', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOutboundLinkTagging', function () {
|
||||
it('uses sluggified sitename for external urls', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world');
|
||||
});
|
||||
|
||||
it('does not add if disabled', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => false
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/');
|
||||
});
|
||||
|
||||
it('uses sluggified newsletter name for internal urls', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const newsletterName = 'used newsletter name';
|
||||
const newsletter = {
|
||||
get: (t) => {
|
||||
if (t === 'name') {
|
||||
return newsletterName;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url, newsletter);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=used-newsletter-name-newsletter');
|
||||
});
|
||||
|
||||
it('does not repeat newsletter at the end of the newsletter name', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/');
|
||||
const newsletterName = 'Weekly newsletter';
|
||||
const newsletter = {
|
||||
get: (t) => {
|
||||
if (t === 'name') {
|
||||
return newsletterName;
|
||||
}
|
||||
}
|
||||
};
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url, newsletter);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=weekly-newsletter');
|
||||
});
|
||||
|
||||
it('does not add ref to blacklisted domains', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://facebook.com/');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
|
||||
should(updatedUrl.toString()).equal('https://facebook.com/');
|
||||
});
|
||||
|
||||
it('does not add ref if utm_source is present', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?utm_source=hello');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?utm_source=hello');
|
||||
});
|
||||
|
||||
it('does not add ref if ref is present', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?ref=hello');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?ref=hello');
|
||||
});
|
||||
|
||||
it('does not add ref if source is present', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
getSiteTitle: () => 'Hello world',
|
||||
getOutboundLinkTaggingEnabled: () => true
|
||||
});
|
||||
const url = new URL('https://example.com/?source=hello');
|
||||
const updatedUrl = await service.addOutboundLinkTagging(url);
|
||||
should(updatedUrl.toString()).equal('https://example.com/?source=hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttributionFromContext', function () {
|
||||
it('returns null if no context is provided', async function () {
|
||||
const service = new MemberAttributionService({
|
||||
|
Loading…
Reference in New Issue
Block a user