Skip to content

Commit 7111f59

Browse files
authored
feat: introduce KPI metrics for broken-backlinks (#725)
1 parent ec6c22d commit 7111f59

File tree

9 files changed

+310
-49
lines changed

9 files changed

+310
-49
lines changed

src/backlinks/handler.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { AuditBuilder } from '../common/audit-builder.js';
2020
import { getScrapedDataForSiteId } from '../support/utils.js';
2121
import { convertToOpportunity } from '../common/opportunity.js';
2222
import { createOpportunityData } from './opportunity-data-mapper.js';
23+
import calculateKpiDeltasForAudit from './kpi-metrics.js';
2324

2425
const auditType = Audit.AUDIT_TYPES.BROKEN_BACKLINKS;
2526
const TIMEOUT = 3000;
@@ -235,22 +236,27 @@ export const generateSuggestionData = async (finalUrl, auditData, context, site)
235236
};
236237

237238
/**
238-
* Converts audit data to an opportunity and synchronizes suggestions.
239-
*
240-
* @param {string} auditUrl - The URL of the audit.
241-
* @param {Object} auditData - The data from the audit.
242-
* @param {Object} context - The context contains logging and data access utilities.
243-
*/
244-
245-
export async function opportunityAndSuggestions(auditUrl, auditData, context) {
239+
* Converts audit data to an opportunity and synchronizes suggestions.
240+
*
241+
* @param {string} auditUrl - The URL of the audit.
242+
* @param {Object} auditData - The data from the audit.
243+
* @param {Object} context - The context contains logging and data access utilities.
244+
* @param {Object} site - The site object.
245+
*/
246+
247+
export async function opportunityAndSuggestions(auditUrl, auditData, context, site) {
248+
const { log } = context;
249+
250+
const kpiDeltas = await calculateKpiDeltasForAudit(auditData, context, site);
251+
246252
const opportunity = await convertToOpportunity(
247253
auditUrl,
248254
auditData,
249255
context,
250256
createOpportunityData,
251257
auditType,
258+
kpiDeltas,
252259
);
253-
const { log } = context;
254260

255261
const buildKey = (data) => `${data.url_from}|${data.url_to}`;
256262

src/backlinks/kpi-metrics.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { getStoredMetrics, isNonEmptyArray } from '@adobe/spacecat-shared-utils';
14+
15+
const CPC_DEFAULT_VALUE = 2.69;
16+
const TRAFFIC_BANDS = [
17+
{ threshold: 25000000, band: 0.03 },
18+
{ threshold: 10000000, band: 0.02 },
19+
{ threshold: 1000000, band: 0.01 },
20+
{ threshold: 500000, band: 0.0075 },
21+
{ threshold: 10000, band: 0.005 },
22+
];
23+
24+
const getTrafficBand = (traffic) => {
25+
for (const { threshold, band } of TRAFFIC_BANDS) {
26+
if (traffic > threshold) {
27+
return band;
28+
}
29+
}
30+
return 0.001;
31+
};
32+
33+
const calculateKpiMetrics = async (auditData, context, site) => {
34+
const { log } = context;
35+
const storedMetricsConfig = {
36+
...context,
37+
s3: {
38+
s3Bucket: context.env?.S3_IMPORTER_BUCKET_NAME,
39+
s3Client: context.s3Client,
40+
},
41+
};
42+
43+
const siteId = site.getId();
44+
const rumTrafficData = await getStoredMetrics(
45+
{ source: 'rum', metric: 'all-traffic', siteId },
46+
storedMetricsConfig,
47+
);
48+
49+
if (!isNonEmptyArray(rumTrafficData)) {
50+
log.info(`No RUM traffic data found for site ${siteId}`);
51+
return null;
52+
}
53+
54+
const organicTrafficData = await getStoredMetrics(
55+
{ source: 'ahrefs', metric: 'organic-traffic', siteId },
56+
storedMetricsConfig,
57+
);
58+
59+
let CPC = CPC_DEFAULT_VALUE;
60+
61+
if (isNonEmptyArray(organicTrafficData)) {
62+
const latestOrganicTrafficData = organicTrafficData.sort(
63+
(a, b) => new Date(b.time) - new Date(a.time),
64+
)[0];
65+
CPC = latestOrganicTrafficData.cost / latestOrganicTrafficData.value;
66+
}
67+
68+
const projectedTrafficLost = auditData?.auditResult?.brokenBacklinks?.reduce((sum, backlink) => {
69+
const { traffic_domain: referringTraffic, urlsSuggested } = backlink;
70+
const trafficBand = getTrafficBand(referringTraffic);
71+
const targetUrl = urlsSuggested?.[0];
72+
const targetTrafficData = rumTrafficData.find((data) => data.url === targetUrl);
73+
const proposedTargetTraffic = targetTrafficData?.earned ?? 0;
74+
return sum + (proposedTargetTraffic * trafficBand);
75+
}, 0);
76+
77+
const projectedTrafficValue = projectedTrafficLost * CPC;
78+
79+
return {
80+
projectedTrafficLost,
81+
projectedTrafficValue,
82+
};
83+
};
84+
85+
export default calculateKpiMetrics;

src/backlinks/opportunity-data-mapper.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
export function createOpportunityData() {
13+
export function createOpportunityData(kpiMetrics) {
1414
return {
1515
runbook: 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/_layouts/15/doc2.aspx?sourcedoc=%7BAC174971-BA97-44A9-9560-90BE6C7CF789%7D&file=Experience_Success_Studio_Broken_Backlinks_Runbook.docx&action=default&mobileredirect=true',
1616
origin: 'AUTOMATION',
@@ -26,6 +26,8 @@ export function createOpportunityData() {
2626
],
2727
},
2828
tags: ['Traffic acquisition'],
29-
data: null,
29+
data: {
30+
...kpiMetrics,
31+
},
3032
};
3133
}

test/audits/backlinks.test.js

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import sinonChai from 'sinon-chai';
1818
import nock from 'nock';
1919
import { FirefallClient } from '@adobe/spacecat-shared-gpt-client';
2020
import auditDataMock from '../fixtures/broken-backlinks/audit.json' with { type: 'json' };
21+
import auditDataSuggestionsMock from '../fixtures/broken-backlinks/auditWithSuggestions.json' with { type: 'json' };
22+
import rumTraffic from '../fixtures/broken-backlinks/all-traffic.json' with { type: 'json' };
2123
import { brokenBacklinksAuditRunner, opportunityAndSuggestions, generateSuggestionData } from '../../src/backlinks/handler.js';
2224
import { MockContextBuilder } from '../shared.js';
2325
import {
@@ -39,6 +41,8 @@ import {
3941
brokenBacklinksSuggestions,
4042
suggestions,
4143
} from '../fixtures/broken-backlinks/suggestion.js';
44+
import { organicTraffic } from '../fixtures/broken-backlinks/organic-traffic.js';
45+
import calculateKpiMetrics from '../../src/backlinks/kpi-metrics.js';
4246

4347
use(sinonChai);
4448
use(chaiAsPromised);
@@ -65,6 +69,7 @@ describe('Backlinks Tests', function () {
6569
AHREFS_API_BASE_URL: 'https://ahrefs.com',
6670
AHREFS_API_KEY: 'ahrefs-api',
6771
S3_SCRAPER_BUCKET_NAME: 'test-bucket',
72+
S3_IMPORTER_BUCKET_NAME: 'test-import-bucket',
6873
},
6974
s3Client: {
7075
send: sandbox.stub(),
@@ -124,6 +129,17 @@ describe('Backlinks Tests', function () {
124129
});
125130

126131
it('should transform the audit result into an opportunity in the post processor and create a new opportunity', async () => {
132+
context.s3Client.send.onCall(0).resolves({
133+
Body: {
134+
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
135+
},
136+
});
137+
138+
context.s3Client.send.onCall(1).resolves({
139+
Body: {
140+
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
141+
},
142+
});
127143
context.dataAccess.Site.findById = sinon.stub().withArgs('site1').resolves(site);
128144
context.dataAccess.Opportunity.create.resolves(brokenBacklinksOpportunity);
129145
brokenBacklinksOpportunity.addSuggestions.resolves(brokenBacklinksSuggestions);
@@ -132,13 +148,18 @@ describe('Backlinks Tests', function () {
132148

133149
ahrefsMock(site.getBaseURL(), auditDataMock.auditResult);
134150

135-
await opportunityAndSuggestions(auditUrl, auditDataMock, context);
151+
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);
152+
153+
const kpiDeltas = {
154+
projectedTrafficLost: sinon.match.number,
155+
projectedTrafficValue: sinon.match.number,
156+
};
136157

137158
expect(context.dataAccess.Opportunity.create)
138159
.to
139160
.have
140161
.been
141-
.calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id));
162+
.calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id, kpiDeltas));
142163
expect(brokenBacklinksOpportunity.addSuggestions).to.have.been.calledOnceWith(suggestions);
143164
});
144165

@@ -152,7 +173,7 @@ describe('Backlinks Tests', function () {
152173

153174
ahrefsMock(site.getBaseURL(), auditDataMock.auditResult);
154175

155-
await opportunityAndSuggestions(auditUrl, auditDataMock, context);
176+
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);
156177

157178
expect(context.dataAccess.Opportunity.create).to.not.have.been.called;
158179
expect(brokenBacklinksOpportunity.setAuditId).to.have.been.calledOnceWith(auditDataMock.id);
@@ -177,7 +198,7 @@ describe('Backlinks Tests', function () {
177198
.reply(200, auditDataMock.auditResult);
178199

179200
try {
180-
await opportunityAndSuggestions(auditUrl, auditDataMock, context);
201+
await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site);
181202
} catch (e) {
182203
expect(e.message).to.equal(errorMessage);
183204
}
@@ -446,4 +467,60 @@ describe('Backlinks Tests', function () {
446467
expect(context.log.error).to.have.been.calledWith('Batch processing error: Firefall error');
447468
});
448469
});
470+
471+
describe('calculateKpiMetrics', () => {
472+
const auditData = {
473+
auditResult: {
474+
brokenBacklinks: [
475+
{ traffic_domain: 25000001, urlsSuggested: ['https://foo.com/bar/redirect'] },
476+
{ traffic_domain: 10000001, urlsSuggested: ['https://foo.com/bar/baz/redirect'] },
477+
{ traffic_domain: 10001, urlsSuggested: ['https://foo.com/qux/redirect'] },
478+
{ traffic_domain: 100, urlsSuggested: ['https://foo.com/bar/baz/qux/redirect'] },
479+
],
480+
},
481+
};
482+
483+
it('should calculate metrics correctly for a single broken backlink', async () => {
484+
context.s3Client.send.onCall(0).resolves({
485+
Body: {
486+
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
487+
},
488+
});
489+
490+
context.s3Client.send.onCall(1).resolves({
491+
Body: {
492+
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
493+
},
494+
});
495+
496+
const result = await calculateKpiMetrics(auditData, context, site);
497+
expect(result.projectedTrafficLost).to.equal(26788.645);
498+
expect(result.projectedTrafficValue).to.equal(534287.974025892);
499+
});
500+
501+
it('skips URL if no RUM data is available for just individual URLs', async () => {
502+
delete rumTraffic[0].earned;
503+
context.s3Client.send.onCall(0).resolves({
504+
Body: {
505+
transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)),
506+
},
507+
});
508+
509+
context.s3Client.send.onCall(1).resolves({
510+
Body: {
511+
transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))),
512+
},
513+
});
514+
515+
const result = await calculateKpiMetrics(auditData, context, site);
516+
expect(result.projectedTrafficLost).to.equal(14545.045000000002);
517+
});
518+
519+
it('returns early if there is no RUM traffic data', async () => {
520+
context.s3Client.send.onCall(0).resolves(null);
521+
522+
const result = await calculateKpiMetrics(auditData, context, site);
523+
expect(result).to.be.null;
524+
});
525+
});
449526
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[
2+
{
3+
"url": "https://foo.com/bar/redirect",
4+
"total": 3864740,
5+
"paid": 5640,
6+
"earned": 408120,
7+
"owned": 3450980
8+
},
9+
{
10+
"url": "https://foo.com/bar/baz/redirect",
11+
"total": 419400,
12+
"paid": 226500,
13+
"earned": 565477,
14+
"owned": 192900
15+
},
16+
{
17+
"url": "https://foo.com/baz/bar/redirect",
18+
"total": 3864740,
19+
"paid": 5640,
20+
"earned": 408120,
21+
"owned": 3450980
22+
},
23+
{
24+
"url": "https://foo.com/qux/redirect",
25+
"total": 419400,
26+
"paid": 226500,
27+
"earned": 565477,
28+
"owned": 192900
29+
},
30+
{
31+
"url": "https://foo.com/bar/qux/redirect",
32+
"total": 3864740,
33+
"paid": 5640,
34+
"earned": 408120,
35+
"owned": 3450980
36+
},
37+
{
38+
"url": "https://foo.com/bar/baz/qux/redirect",
39+
"total": 3864740,
40+
"paid": 5640,
41+
"earned": 408120,
42+
"owned": 3450980
43+
}
44+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"siteId": "site-id",
3+
"id": "audit-id",
4+
"auditResult": {
5+
"brokenBacklinks": [
6+
{
7+
"title": "backlink that redirects to www and throw connection error",
8+
"url_from": "https://from.com/from-2",
9+
"url_to": "https://foo.com/redirects-throws-error",
10+
"urlsSuggested": ["https://foo.com/redirects-throws-error-1", "https://foo.com/redirects-throws-error-2"],
11+
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
12+
"traffic_domain": 550000
13+
},
14+
{
15+
"title": "backlink that returns 429",
16+
"url_from": "https://from.com/from-3",
17+
"url_to": "https://foo.com/returns-429",
18+
"urlsSuggested": ["https://foo.com/returns-429-suggestion-1", "https://foo.com/returns-429-suggestion-2"],
19+
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
20+
"traffic_domain": 11000
21+
},
22+
{
23+
"title": "backlink that is not excluded",
24+
"url_from": "https://from.com/from-not-excluded",
25+
"url_to": "https://foo.com/not-excluded",
26+
"urlsSuggested": ["https://foo.com/not-excluded-suggestion-1", "https://foo.com/not-excluded-suggestion-2"],
27+
"aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.",
28+
"traffic_domain": 5500
29+
},
30+
{
31+
"title": "backlink that returns 404",
32+
"url_from": "https://from.com/from-1",
33+
"url_to": "https://foo.com/returns-404",
34+
"traffic_domain": 1100000
35+
}
36+
]
37+
},
38+
"fullAuditRef": "https://ahrefs.com/site-explorer/broken-backlinks?select=title%2Curl_from%2Curl_to%2Ctraffic_domain&limit=50&mode=prefix&order_by=domain_rating_source%3Adesc%2Ctraffic_domain%3Adesc&target=https%3A%2F%2Faudit.url&output=json&where=%7B%22and%22%3A%5B%7B%22field%22%3A%22domain_rating_source%22%2C%22is%22%3A%5B%22gte%22%2C29.5%5D%7D%2C%7B%22field%22%3A%22traffic_domain%22%2C%22is%22%3A%5B%22gte%22%2C500%5D%7D%2C%7B%22field%22%3A%22links_external%22%2C%22is%22%3A%5B%22lte%22%2C300%5D%7D%5D%7D"
39+
}

test/fixtures/broken-backlinks/opportunity.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const otherOpportunity = {
2626
getType: () => 'other',
2727
};
2828

29-
export const opportunityData = (siteId, auditId) => ({
29+
export const opportunityData = (siteId, auditId, kpiDeltas) => ({
3030
siteId,
3131
auditId,
3232
runbook: 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/_layouts/15/doc2.aspx?sourcedoc=%7BAC174971-BA97-44A9-9560-90BE6C7CF789%7D&file=Experience_Success_Studio_Broken_Backlinks_Runbook.docx&action=default&mobileredirect=true',
@@ -44,5 +44,7 @@ export const opportunityData = (siteId, auditId) => ({
4444
],
4545
},
4646
tags: ['Traffic acquisition'],
47-
data: null,
47+
data: {
48+
...kpiDeltas,
49+
},
4850
});

0 commit comments

Comments
 (0)