diff --git a/src/backlinks/handler.js b/src/backlinks/handler.js index e13a6c96c..e6b8cd173 100644 --- a/src/backlinks/handler.js +++ b/src/backlinks/handler.js @@ -20,6 +20,7 @@ import { AuditBuilder } from '../common/audit-builder.js'; import { getScrapedDataForSiteId } from '../support/utils.js'; import { convertToOpportunity } from '../common/opportunity.js'; import { createOpportunityData } from './opportunity-data-mapper.js'; +import calculateKpiDeltasForAudit from './kpi-metrics.js'; const auditType = Audit.AUDIT_TYPES.BROKEN_BACKLINKS; const TIMEOUT = 3000; @@ -235,22 +236,27 @@ export const generateSuggestionData = async (finalUrl, auditData, context, site) }; /** - * Converts audit data to an opportunity and synchronizes suggestions. - * - * @param {string} auditUrl - The URL of the audit. - * @param {Object} auditData - The data from the audit. - * @param {Object} context - The context contains logging and data access utilities. - */ - -export async function opportunityAndSuggestions(auditUrl, auditData, context) { + * Converts audit data to an opportunity and synchronizes suggestions. + * + * @param {string} auditUrl - The URL of the audit. + * @param {Object} auditData - The data from the audit. + * @param {Object} context - The context contains logging and data access utilities. + * @param {Object} site - The site object. + */ + +export async function opportunityAndSuggestions(auditUrl, auditData, context, site) { + const { log } = context; + + const kpiDeltas = await calculateKpiDeltasForAudit(auditData, context, site); + const opportunity = await convertToOpportunity( auditUrl, auditData, context, createOpportunityData, auditType, + kpiDeltas, ); - const { log } = context; const buildKey = (data) => `${data.url_from}|${data.url_to}`; diff --git a/src/backlinks/kpi-metrics.js b/src/backlinks/kpi-metrics.js new file mode 100644 index 000000000..27ab54133 --- /dev/null +++ b/src/backlinks/kpi-metrics.js @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { getStoredMetrics, isNonEmptyArray } from '@adobe/spacecat-shared-utils'; + +const CPC_DEFAULT_VALUE = 2.69; +const TRAFFIC_BANDS = [ + { threshold: 25000000, band: 0.03 }, + { threshold: 10000000, band: 0.02 }, + { threshold: 1000000, band: 0.01 }, + { threshold: 500000, band: 0.0075 }, + { threshold: 10000, band: 0.005 }, +]; + +const getTrafficBand = (traffic) => { + for (const { threshold, band } of TRAFFIC_BANDS) { + if (traffic > threshold) { + return band; + } + } + return 0.001; +}; + +const calculateKpiMetrics = async (auditData, context, site) => { + const { log } = context; + const storedMetricsConfig = { + ...context, + s3: { + s3Bucket: context.env?.S3_IMPORTER_BUCKET_NAME, + s3Client: context.s3Client, + }, + }; + + const siteId = site.getId(); + const rumTrafficData = await getStoredMetrics( + { source: 'rum', metric: 'all-traffic', siteId }, + storedMetricsConfig, + ); + + if (!isNonEmptyArray(rumTrafficData)) { + log.info(`No RUM traffic data found for site ${siteId}`); + return null; + } + + const organicTrafficData = await getStoredMetrics( + { source: 'ahrefs', metric: 'organic-traffic', siteId }, + storedMetricsConfig, + ); + + let CPC = CPC_DEFAULT_VALUE; + + if (isNonEmptyArray(organicTrafficData)) { + const latestOrganicTrafficData = organicTrafficData.sort( + (a, b) => new Date(b.time) - new Date(a.time), + )[0]; + CPC = latestOrganicTrafficData.cost / latestOrganicTrafficData.value; + } + + const projectedTrafficLost = auditData?.auditResult?.brokenBacklinks?.reduce((sum, backlink) => { + const { traffic_domain: referringTraffic, urlsSuggested } = backlink; + const trafficBand = getTrafficBand(referringTraffic); + const targetUrl = urlsSuggested?.[0]; + const targetTrafficData = rumTrafficData.find((data) => data.url === targetUrl); + const proposedTargetTraffic = targetTrafficData?.earned ?? 0; + return sum + (proposedTargetTraffic * trafficBand); + }, 0); + + const projectedTrafficValue = projectedTrafficLost * CPC; + + return { + projectedTrafficLost, + projectedTrafficValue, + }; +}; + +export default calculateKpiMetrics; diff --git a/src/backlinks/opportunity-data-mapper.js b/src/backlinks/opportunity-data-mapper.js index ba56109a7..b625ad8e3 100644 --- a/src/backlinks/opportunity-data-mapper.js +++ b/src/backlinks/opportunity-data-mapper.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -export function createOpportunityData() { +export function createOpportunityData(kpiMetrics) { return { 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', origin: 'AUTOMATION', @@ -26,6 +26,8 @@ export function createOpportunityData() { ], }, tags: ['Traffic acquisition'], - data: null, + data: { + ...kpiMetrics, + }, }; } diff --git a/test/audits/backlinks.test.js b/test/audits/backlinks.test.js index ffb747fcc..bc5e8922b 100644 --- a/test/audits/backlinks.test.js +++ b/test/audits/backlinks.test.js @@ -18,6 +18,8 @@ import sinonChai from 'sinon-chai'; import nock from 'nock'; import { FirefallClient } from '@adobe/spacecat-shared-gpt-client'; import auditDataMock from '../fixtures/broken-backlinks/audit.json' with { type: 'json' }; +import auditDataSuggestionsMock from '../fixtures/broken-backlinks/auditWithSuggestions.json' with { type: 'json' }; +import rumTraffic from '../fixtures/broken-backlinks/all-traffic.json' with { type: 'json' }; import { brokenBacklinksAuditRunner, opportunityAndSuggestions, generateSuggestionData } from '../../src/backlinks/handler.js'; import { MockContextBuilder } from '../shared.js'; import { @@ -39,6 +41,8 @@ import { brokenBacklinksSuggestions, suggestions, } from '../fixtures/broken-backlinks/suggestion.js'; +import { organicTraffic } from '../fixtures/broken-backlinks/organic-traffic.js'; +import calculateKpiMetrics from '../../src/backlinks/kpi-metrics.js'; use(sinonChai); use(chaiAsPromised); @@ -65,6 +69,7 @@ describe('Backlinks Tests', function () { AHREFS_API_BASE_URL: 'https://ahrefs.com', AHREFS_API_KEY: 'ahrefs-api', S3_SCRAPER_BUCKET_NAME: 'test-bucket', + S3_IMPORTER_BUCKET_NAME: 'test-import-bucket', }, s3Client: { send: sandbox.stub(), @@ -124,6 +129,17 @@ describe('Backlinks Tests', function () { }); it('should transform the audit result into an opportunity in the post processor and create a new opportunity', async () => { + context.s3Client.send.onCall(0).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)), + }, + }); + + context.s3Client.send.onCall(1).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))), + }, + }); context.dataAccess.Site.findById = sinon.stub().withArgs('site1').resolves(site); context.dataAccess.Opportunity.create.resolves(brokenBacklinksOpportunity); brokenBacklinksOpportunity.addSuggestions.resolves(brokenBacklinksSuggestions); @@ -132,13 +148,18 @@ describe('Backlinks Tests', function () { ahrefsMock(site.getBaseURL(), auditDataMock.auditResult); - await opportunityAndSuggestions(auditUrl, auditDataMock, context); + await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site); + + const kpiDeltas = { + projectedTrafficLost: sinon.match.number, + projectedTrafficValue: sinon.match.number, + }; expect(context.dataAccess.Opportunity.create) .to .have .been - .calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id)); + .calledOnceWith(opportunityData(auditDataMock.siteId, auditDataMock.id, kpiDeltas)); expect(brokenBacklinksOpportunity.addSuggestions).to.have.been.calledOnceWith(suggestions); }); @@ -152,7 +173,7 @@ describe('Backlinks Tests', function () { ahrefsMock(site.getBaseURL(), auditDataMock.auditResult); - await opportunityAndSuggestions(auditUrl, auditDataMock, context); + await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site); expect(context.dataAccess.Opportunity.create).to.not.have.been.called; expect(brokenBacklinksOpportunity.setAuditId).to.have.been.calledOnceWith(auditDataMock.id); @@ -177,7 +198,7 @@ describe('Backlinks Tests', function () { .reply(200, auditDataMock.auditResult); try { - await opportunityAndSuggestions(auditUrl, auditDataMock, context); + await opportunityAndSuggestions(auditUrl, auditDataSuggestionsMock, context, site); } catch (e) { expect(e.message).to.equal(errorMessage); } @@ -446,4 +467,60 @@ describe('Backlinks Tests', function () { expect(context.log.error).to.have.been.calledWith('Batch processing error: Firefall error'); }); }); + + describe('calculateKpiMetrics', () => { + const auditData = { + auditResult: { + brokenBacklinks: [ + { traffic_domain: 25000001, urlsSuggested: ['https://foo.com/bar/redirect'] }, + { traffic_domain: 10000001, urlsSuggested: ['https://foo.com/bar/baz/redirect'] }, + { traffic_domain: 10001, urlsSuggested: ['https://foo.com/qux/redirect'] }, + { traffic_domain: 100, urlsSuggested: ['https://foo.com/bar/baz/qux/redirect'] }, + ], + }, + }; + + it('should calculate metrics correctly for a single broken backlink', async () => { + context.s3Client.send.onCall(0).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)), + }, + }); + + context.s3Client.send.onCall(1).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))), + }, + }); + + const result = await calculateKpiMetrics(auditData, context, site); + expect(result.projectedTrafficLost).to.equal(26788.645); + expect(result.projectedTrafficValue).to.equal(534287.974025892); + }); + + it('skips URL if no RUM data is available for just individual URLs', async () => { + delete rumTraffic[0].earned; + context.s3Client.send.onCall(0).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(rumTraffic)), + }, + }); + + context.s3Client.send.onCall(1).resolves({ + Body: { + transformToString: sinon.stub().resolves(JSON.stringify(organicTraffic(site))), + }, + }); + + const result = await calculateKpiMetrics(auditData, context, site); + expect(result.projectedTrafficLost).to.equal(14545.045000000002); + }); + + it('returns early if there is no RUM traffic data', async () => { + context.s3Client.send.onCall(0).resolves(null); + + const result = await calculateKpiMetrics(auditData, context, site); + expect(result).to.be.null; + }); + }); }); diff --git a/test/fixtures/broken-backlinks/all-traffic.json b/test/fixtures/broken-backlinks/all-traffic.json new file mode 100644 index 000000000..d13dda1bd --- /dev/null +++ b/test/fixtures/broken-backlinks/all-traffic.json @@ -0,0 +1,44 @@ +[ + { + "url": "https://foo.com/bar/redirect", + "total": 3864740, + "paid": 5640, + "earned": 408120, + "owned": 3450980 + }, + { + "url": "https://foo.com/bar/baz/redirect", + "total": 419400, + "paid": 226500, + "earned": 565477, + "owned": 192900 + }, + { + "url": "https://foo.com/baz/bar/redirect", + "total": 3864740, + "paid": 5640, + "earned": 408120, + "owned": 3450980 + }, + { + "url": "https://foo.com/qux/redirect", + "total": 419400, + "paid": 226500, + "earned": 565477, + "owned": 192900 + }, + { + "url": "https://foo.com/bar/qux/redirect", + "total": 3864740, + "paid": 5640, + "earned": 408120, + "owned": 3450980 + }, + { + "url": "https://foo.com/bar/baz/qux/redirect", + "total": 3864740, + "paid": 5640, + "earned": 408120, + "owned": 3450980 + } +] diff --git a/test/fixtures/broken-backlinks/auditWithSuggestions.json b/test/fixtures/broken-backlinks/auditWithSuggestions.json new file mode 100644 index 000000000..0bccc2460 --- /dev/null +++ b/test/fixtures/broken-backlinks/auditWithSuggestions.json @@ -0,0 +1,39 @@ +{ + "siteId": "site-id", + "id": "audit-id", + "auditResult": { + "brokenBacklinks": [ + { + "title": "backlink that redirects to www and throw connection error", + "url_from": "https://from.com/from-2", + "url_to": "https://foo.com/redirects-throws-error", + "urlsSuggested": ["https://foo.com/redirects-throws-error-1", "https://foo.com/redirects-throws-error-2"], + "aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.", + "traffic_domain": 550000 + }, + { + "title": "backlink that returns 429", + "url_from": "https://from.com/from-3", + "url_to": "https://foo.com/returns-429", + "urlsSuggested": ["https://foo.com/returns-429-suggestion-1", "https://foo.com/returns-429-suggestion-2"], + "aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.", + "traffic_domain": 11000 + }, + { + "title": "backlink that is not excluded", + "url_from": "https://from.com/from-not-excluded", + "url_to": "https://foo.com/not-excluded", + "urlsSuggested": ["https://foo.com/not-excluded-suggestion-1", "https://foo.com/not-excluded-suggestion-2"], + "aiRationale": "The suggested URLs are similar to the original URL and are likely to be the correct destination.", + "traffic_domain": 5500 + }, + { + "title": "backlink that returns 404", + "url_from": "https://from.com/from-1", + "url_to": "https://foo.com/returns-404", + "traffic_domain": 1100000 + } + ] + }, + "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" +} diff --git a/test/fixtures/broken-backlinks/opportunity.js b/test/fixtures/broken-backlinks/opportunity.js index e41b5295f..eb4edfd2e 100644 --- a/test/fixtures/broken-backlinks/opportunity.js +++ b/test/fixtures/broken-backlinks/opportunity.js @@ -26,7 +26,7 @@ export const otherOpportunity = { getType: () => 'other', }; -export const opportunityData = (siteId, auditId) => ({ +export const opportunityData = (siteId, auditId, kpiDeltas) => ({ siteId, auditId, 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) => ({ ], }, tags: ['Traffic acquisition'], - data: null, + data: { + ...kpiDeltas, + }, }); diff --git a/test/fixtures/broken-backlinks/organic-traffic.js b/test/fixtures/broken-backlinks/organic-traffic.js new file mode 100644 index 000000000..0b9128be8 --- /dev/null +++ b/test/fixtures/broken-backlinks/organic-traffic.js @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export const organicTraffic = (site) => [ + { + siteId: site.getId(), + source: 'ahrefs', + time: '2025-02-28T15:51:00Z', + cost: 14912235, + name: 'organic-traffic', + value: 747684, + }, + { + siteId: site.getId(), + source: 'ahrefs', + time: '2025-01-03T15:51:00Z', + cost: 14912235, + name: 'organic-traffic', + value: 747684, + }, +]; diff --git a/test/fixtures/broken-backlinks/suggestion.js b/test/fixtures/broken-backlinks/suggestion.js index 353743733..fb68152a4 100644 --- a/test/fixtures/broken-backlinks/suggestion.js +++ b/test/fixtures/broken-backlinks/suggestion.js @@ -11,6 +11,7 @@ */ import sinon from 'sinon'; import auditDataMock from './audit.json' with { type: 'json' }; +import auditDataSuggestionsMock from './auditWithSuggestions.json' with { type: 'json' }; import { brokenBacklinksOpportunity } from './opportunity.js'; export const brokenBacklinksSuggestions = { @@ -34,53 +35,29 @@ export const suggestions = [ { opportunityId: 'test-opportunity-id', type: 'REDIRECT_UPDATE', - rank: 2000, - data: { - title: 'backlink that redirects to www and throw connection error', - url_from: 'https://from.com/from-2', - url_to: 'https://foo.com/redirects-throws-error', - urlsSuggested: [], - aiRationale: '', - traffic_domain: 2000, - }, + rank: 550000, + data: auditDataSuggestionsMock.auditResult.brokenBacklinks[0], }, { opportunityId: 'test-opportunity-id', type: 'REDIRECT_UPDATE', - rank: 1000, - data: { - title: 'backlink that returns 429', - url_from: 'https://from.com/from-3', - url_to: 'https://foo.com/returns-429', - urlsSuggested: [], - aiRationale: '', - traffic_domain: 1000, - }, + rank: 11000, + data: auditDataSuggestionsMock.auditResult.brokenBacklinks[1], }, { opportunityId: 'test-opportunity-id', type: 'REDIRECT_UPDATE', - rank: 5000, - data: { - title: 'backlink that is not excluded', - url_from: 'https://from.com/from-not-excluded', - url_to: 'https://foo.com/not-excluded', - urlsSuggested: [], - aiRationale: '', - traffic_domain: 5000, - }, + rank: 5500, + data: auditDataSuggestionsMock.auditResult.brokenBacklinks[2], }, { opportunityId: 'test-opportunity-id', type: 'REDIRECT_UPDATE', - rank: 4000, + rank: 1100000, data: { - title: 'backlink that returns 404', - url_from: 'https://from.com/from-1', - url_to: 'https://foo.com/returns-404', + ...auditDataSuggestionsMock.auditResult.brokenBacklinks[3], urlsSuggested: [], aiRationale: '', - traffic_domain: 4000, }, }, ];