Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AdHash Bid Adapter: add brand safety #8167

Merged
merged 5 commits into from
Mar 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions modules/adhashBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,79 @@ import {includes} from '../src/polyfill.js';
import {BANNER} from '../src/mediaTypes.js';

const VERSION = '1.0';
const BAD_WORD_STEP = 0.1;
const BAD_WORD_MIN = 0.2;

/**
* Function that checks the page where the ads are being served for brand safety.
* If unsafe words are found the scoring of that page increases.
* If it becomes greater than the maximum allowed score false is returned.
* The rules may vary based on the website language or the publisher.
* The AdHash bidder will not bid on unsafe pages (according to 4A's).
* @param badWords list of scoring rules to chech against
* @param maxScore maximum allowed score for that bidding
* @returns boolean flag is the page safe
*/
function brandSafety(badWords, maxScore) {
/**
* Performs the ROT13 encoding on the string argument and returns the resulting string.
* The Adhash bidder uses ROT13 so that the response is not blocked by:
* - ad blocking software
* - parental control software
* - corporate firewalls
* due to the bad words contained in the response.
* @param value The input string.
* @returns string Returns the ROT13 version of the given string.
*/
const rot13 = value => {
const input = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const output = 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm';
const index = x => input.indexOf(x);
const translate = x => index(x) > -1 ? output[index(x)] : x;
return value.split('').map(translate).join('');
};

/**
* Calculates the scoring for each bad word with dimishing returns
* @param {integer} points points that this word costs
* @param {integer} occurances number of occurances
* @returns {float} final score
*/
const scoreCalculator = (points, occurances) => {
let positive = true;
if (points < 0) {
points *= -1;
positive = false;
}
let result = 0;
for (let i = 0; i < occurances; i++) {
result += Math.max(points - i * BAD_WORD_STEP, BAD_WORD_MIN);
}
return positive ? result : -result;
};

// Default parameters if the bidder is unable to send some of them
badWords = badWords || [];
maxScore = parseInt(maxScore) || 10;

try {
let score = 0;
const content = window.top.document.body.innerText.toLowerCase();
const words = content.trim().split(/\s+/).length;
for (const [word, rule, points] of badWords) {
if (rule === 'full' && new RegExp('\\b' + rot13(word) + '\\b', 'i').test(content)) {
const occurances = content.match(new RegExp('\\b' + rot13(word) + '\\b', 'g')).length;
score += scoreCalculator(points, occurances);
} else if (rule === 'partial' && content.indexOf(rot13(word.toLowerCase())) > -1) {
const occurances = content.match(new RegExp(rot13(word), 'g')).length;
score += scoreCalculator(points, occurances);
}
}
return score < maxScore * words / 500;
} catch (e) {
return true;
}
}

export const spec = {
code: 'adhash',
Expand Down Expand Up @@ -59,7 +132,8 @@ export const spec = {
blockedCreatives: [],
currentTimestamp: new Date().getTime(),
recentAds: [],
GDPR: gdprConsent
GDPRApplies: gdprConsent ? gdprConsent.gdprApplies : null,
GDPR: gdprConsent ? gdprConsent.consentString : null
},
options: {
withCredentials: false,
Expand All @@ -73,7 +147,11 @@ export const spec = {
interpretResponse: (serverResponse, request) => {
const responseBody = serverResponse ? serverResponse.body : {};

if (!responseBody.creatives || responseBody.creatives.length === 0) {
if (
!responseBody.creatives ||
responseBody.creatives.length === 0 ||
!brandSafety(responseBody.badWords, responseBody.maxScore)
) {
return [];
}

Expand Down
84 changes: 73 additions & 11 deletions test/spec/modules/adhashBidAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('adhashBidAdapter', function () {
bidder: 'adhash',
params: {
publisherId: '0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb',
platformURL: 'https://adhash.org/p/struma/'
platformURL: 'https://adhash.com/p/struma/'
},
mediaTypes: {
banner: {
Expand Down Expand Up @@ -73,7 +73,7 @@ describe('adhashBidAdapter', function () {
it('should build the request correctly', function () {
const result = spec.buildRequests(
[ bidRequest ],
{ gdprConsent: true, refererInfo: { referer: 'http://example.com/' } }
{ gdprConsent: { gdprApplies: true, consentString: 'example' }, refererInfo: { referer: 'http://example.com/' } }
);
expect(result.length).to.equal(1);
expect(result[0].method).to.equal('POST');
Expand All @@ -90,7 +90,7 @@ describe('adhashBidAdapter', function () {
expect(result[0].data).to.have.property('recentAds');
});
it('should build the request correctly without referer', function () {
const result = spec.buildRequests([ bidRequest ], { gdprConsent: true });
const result = spec.buildRequests([ bidRequest ], { gdprConsent: { gdprApplies: true, consentString: 'example' } });
expect(result.length).to.equal(1);
expect(result[0].method).to.equal('POST');
expect(result[0].url).to.equal('https://bidder.adhash.com/rtb?version=1.0&prebid=true&publisher=0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb');
Expand All @@ -115,18 +115,31 @@ describe('adhashBidAdapter', function () {
adUnitCode: 'adunit-code',
sizes: [[300, 250]],
params: {
platformURL: 'https://adhash.org/p/struma/'
platformURL: 'https://adhash.com/p/struma/'
}
}
};

let bodyStub;

const serverResponse = {
body: {
creatives: [{ costEUR: 1.234 }],
advertiserDomains: 'adhash.com',
badWords: [
['onqjbeq1', 'full', 1],
['onqjbeq2', 'partial', 1],
['tbbqjbeq', 'full', -1],
],
maxScore: 2
}
};

afterEach(function() {
bodyStub && bodyStub.restore();
});

it('should interpret the response correctly', function () {
const serverResponse = {
body: {
creatives: [{ costEUR: 1.234 }],
advertiserDomains: 'adhash.org'
}
};
const result = spec.interpretResponse(serverResponse, request);
expect(result.length).to.equal(1);
expect(result[0].requestId).to.equal('12345678901234');
Expand All @@ -137,7 +150,56 @@ describe('adhashBidAdapter', function () {
expect(result[0].netRevenue).to.equal(true);
expect(result[0].currency).to.equal('EUR');
expect(result[0].ttl).to.equal(60);
expect(result[0].meta.advertiserDomains).to.eql(['adhash.org']);
expect(result[0].meta.advertiserDomains).to.eql(['adhash.com']);
});

it('should return empty array when there are bad words (full)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example badWord1 text' + ' word'.repeat(493);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (partial)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord2 badword2 example BadWord2text' + ' word'.repeat(494);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return non-empty array when there are not enough bad words (full)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example text' + ' word'.repeat(494);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are not enough bad words (partial)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord2 example' + ' word'.repeat(496);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are no-bad word matches', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord1 example text' + ' word'.repeat(495);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are bad words and good words', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example badWord1 goodWord goodWord ' + ' word'.repeat(492);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there is a problem with the brand-safety', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return null;
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return empty array when there are no creatives returned', function () {
Expand Down