Skip to content

Commit

Permalink
feat: introduce Opportunity creation for Broken Backlinks (#475)
Browse files Browse the repository at this point in the history
  • Loading branch information
dzehnder authored Nov 29, 2024
1 parent 35fc997 commit 78ce9a3
Show file tree
Hide file tree
Showing 5 changed files with 646 additions and 48 deletions.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,74 @@ export default new AuditBuilder()
.build();

```

### How to add Opportunities and Suggestions
```js
import { syncSuggestions } from '../utils/data-access.js';

export async function auditRunner(url, context) {

// your audit logic goes here...

return {
auditResult: results,
fullAuditRef: baseURL,
};
}

async function convertToOpportunity(auditUrl, auditData, context) {
const { dataAccess } = context;

const opportunities = await dataAccess.Opportunity.allBySiteIdAndStatus(auditData.siteId, 'NEW');
const opportunity = opportunities.find((oppty) => oppty.getType() === 'audit-type');

if (!opportunity) {
const opportunityData = {
siteId: auditData.siteId,
auditId: auditData.id,
runbook: 'link-to-runbook',
type: 'audit-type',
origin: 'AUTOMATON',
title: 'Opportunity Title',
description: 'Opportunity Description',
guidance: {
steps: [
'Step 1',
'Step 2',
],
},
tags: ['tag1', 'tag2'],
};
opportunity = await dataAccess.Opportunity.create(opportunityData);
} else {
opportunity.setAuditId(auditData.id);
await opportunity.save();
}

// this logic changes based on the audit type
const buildKey = (auditData) => `${auditData.property}|${auditData.anotherProperty}`;

await syncSuggestions({
opportunity,
newData: auditData,
buildKey,
mapNewSuggestion: (issue) => ({
opportunityId: opportunity.getId(),
type: 'SUGGESTION_TYPE',
rank: issue.rankMetric,
// data changes based on the audit type
data: {
property: issue.property,
anotherProperty: issue.anotherProperty
}
}),
log
});
}

export default new AuditBuilder()
.withRunner(auditRunner)
.withPostProcessors([ convertToOpportunity ])
.build();

```
72 changes: 68 additions & 4 deletions src/backlinks/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { composeAuditURL, tracingFetch as fetch } from '@adobe/spacecat-shared-utils';
import AhrefsAPIClient from '@adobe/spacecat-shared-ahrefs-client';
import { AbortController, AbortError } from '@adobe/fetch';
import { retrieveSiteBySiteId } from '../utils/data-access.js';
import { retrieveSiteBySiteId, syncSuggestions } from '../utils/data-access.js';
import { enhanceBacklinksWithFixes } from '../support/utils.js';

const TIMEOUT = 3000;
Expand Down Expand Up @@ -139,14 +139,78 @@ export default async function auditBrokenBacklinks(message, context) {
auditResult,
};

await dataAccess.addAudit(auditData);
const data = {
const audit = await dataAccess.addAudit(auditData);
const result = {
type,
url: site.getBaseURL(),
auditContext,
auditResult,
};
await sqs.sendMessage(queueUrl, data);

let brokenBacklinksOppty;

try {
const opportunities = await dataAccess.Opportunity.allBySiteIdAndStatus(siteId, 'NEW');
brokenBacklinksOppty = opportunities.find((oppty) => oppty.getType() === 'broken-backlinks');
} catch (e) {
log.error(`Fetching opportunities for siteId ${siteId} failed with error: ${e.message}`);
return internalServerError(`Failed to fetch opportunities for siteId ${siteId}: ${e.message}`);
}

try {
if (!brokenBacklinksOppty) {
const opportunityData = {
siteId: site.getId(),
auditId: audit.getId(),
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',
type: 'broken-backlinks',
origin: 'AUTOMATION',
title: 'Authoritative Domains are linking to invalid URLs. This could impact your SEO.',
description: 'Provide the correct target URL that each of the broken backlinks should be redirected to.',
guidance: {
steps: [
'Review the list of broken target URLs and the suggested redirects.',
'Manually override redirect URLs as needed.',
'Copy redirects.',
'Paste new entries in your website redirects file.',
'Publish the changes.',
],
},
tags: ['Traffic acquisition'],
};

brokenBacklinksOppty = await dataAccess.Opportunity.create(opportunityData);
} else {
brokenBacklinksOppty.setAuditId(audit.getId());
await brokenBacklinksOppty.save();
}
} catch (e) {
log.error(`Creating opportunity for siteId ${siteId} failed with error: ${e.message}`, e);
return internalServerError(`Failed to create opportunity for siteId ${siteId}: ${e.message}`);
}

if (!result.auditResult.error) {
const buildKey = (data) => `${data.url_from}|${data.url_to}`;

await syncSuggestions({
opportunity: brokenBacklinksOppty,
newData: result.auditResult.brokenBacklinks,
buildKey,
mapNewSuggestion: (backlink) => ({
opportunityId: brokenBacklinksOppty.getId(),
type: 'REDIRECT_UPDATE',
rank: backlink.traffic_domain,
data: {
title: backlink.title,
url_from: backlink.url_from,
url_to: backlink.url_to,
traffic_domain: backlink.traffic_domain,
},
}),
log,
});
}
await sqs.sendMessage(queueUrl, result);

log.info(`Successfully audited ${siteId} for ${type} type audit`);
return noContent();
Expand Down
67 changes: 67 additions & 0 deletions src/utils/data-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,70 @@ export async function retrieveSiteBySiteId(dataAccess, siteId, log) {
throw new Error(`Error getting site ${siteId}: ${e.message}`);
}
}

/**
* Synchronizes existing suggestions with new data by removing outdated suggestions
* and adding new ones.
*
* @param {Object} params - The parameters for the sync operation.
* @param {Object} params.opportunity - The opportunity object to synchronize suggestions for.
* @param {Array} params.newData - Array of new data objects to sync.
* @param {Function} params.buildKey - Function to generate a unique key for each item.
* @param {Function} params.mapNewSuggestion - Function to map new data to suggestion objects.
* @param {Object} params.log - Logger object for error reporting.
* @returns {Promise<void>} - Resolves when the synchronization is complete.
*/
export async function syncSuggestions({
opportunity,
newData,
buildKey,
mapNewSuggestion,
log,
}) {
const newDataKeys = new Set(newData.map(buildKey));
const existingSuggestions = await opportunity.getSuggestions();

// Remove outdated suggestions
await Promise.all(
existingSuggestions
.filter((existing) => !newDataKeys.has(buildKey(existing)))
.map((suggestion) => suggestion.remove()),
);

// Update existing suggestions
await Promise.all(
existingSuggestions
.filter((existing) => {
const existingKey = buildKey(existing);
return newDataKeys.has(existingKey);
})
.map((existing) => {
const newDataItem = newData.find((data) => buildKey(data) === buildKey(existing));
existing.setData(newDataItem);
return existing.save();
}),
);

// Prepare new suggestions
const newSuggestions = newData
.filter((data) => !existingSuggestions.some(
(existing) => buildKey(existing) === buildKey(data),
))
.map(mapNewSuggestion);

// Add new suggestions if any
if (newSuggestions.length > 0) {
const suggestions = await opportunity.addSuggestions(newSuggestions);

if (suggestions.errorItems?.length > 0) {
log.error(`Suggestions for siteId ${opportunity.getSiteId()} contains ${suggestions.errorItems.length} items with errors`);
suggestions.errorItems.forEach((errorItem) => {
log.error(`Item ${JSON.stringify(errorItem.item)} failed with error: ${errorItem.error}`);
});

if (suggestions.createdItems?.length <= 0) {
throw new Error(`Failed to create suggestions for siteId ${opportunity.getSiteId()}`);
}
}
}
}
Loading

0 comments on commit 78ce9a3

Please sign in to comment.